dsh-loopx-plugin
loopx-project/loopxLoopX——面向长周期 Agent 的提供商中立、本地优先状态内核与控制平面:在 DeepSeek Harness 执行层之上持久化 Goal、Todo、门禁、证据、配额、恢复与交接状态;插件负责引导安装 CLI 与技能、准入有界的同会话续跑,并为精确绑定的工作循环提供本地 GoalBar。
协调 Codex 制定计划和独立审查,由 DSH 原会话完成代码修改与测试。
DeepSeek Harness plugin that gives Codex read-only planning/review roles while DSH remains the sole executor. Two flows share the same workflow engine:
From a source checkout, run pnpm demo and open http://127.0.0.1:4175 for a standalone local visual demo. It uses simulated events to demonstrate planning, execution, review and same-thread recovery without connecting to DSH or a model. See the demo guide.
codex_workflow_start for complex development tasks, or the user can request it explicitly; implementation and review still run through the same workflow engine.No browser is opened or controlled anywhere in the product path; no network listener, MCP, hooks, or skills are involved. Browser clicking is a development-only workaround and is not part of the plugin.
Planner and Reviewer turns run through the NPM Codex CLI's codex app-server --stdio, but plugin-created threads use thread/start { ephemeral: true }. They remain available for the current App Server process, do not receive Desktop names, and do not appear in thread/list. Failure recovery and clarification answers require the original process and handle. A lost original thread reports an error; neither path reconstructs a replacement Planner. Structured conversion and authority alignment continue to use codex exec --ephemeral or an equivalent private App Server turn.
Planner and Reviewer runs use DSH's subagents.start() on legacy hosts; on activation-only hosts such as 0.2.1-rc.3, this plugin owns the native one-shot mirror lifecycle. Each gets a native child Session (origin: "subagent") while the NPM Codex App Server executes the task in an ephemeral thread. The child Session receives native assistant stream frames and a final message. The browser opens DSH's built-in subagentchat right-sidebar resource for that child, so Codex appears as a child agent conversation. Workflow status and final review metadata use the codexWorkflow projection on legacy hosts or plugin-owned SQLite state on activation-only hosts. Private reasoning text and raw tool output are not forwarded. The plugin never opens or focuses Codex Desktop.
Concurrent SDK callers share one App Server initialization, with independent cancellation of each caller's wait. Cancelling one observer preserves the original process for observers still waiting; only the last withdrawal abandons initialization and cleans its original process. A later independent request waits for that cleanup before initializing again. Permanent plugin stop prevents another bootstrap. These initialization rules do not restore a lost ephemeral Planner or authorize a replacement workflow.
Each ordinary control RPC releases its pending entry, timer and abort listener once on response, error, timeout, cancellation or stop. Stdin stream errors belong to the original App Server child; a late error after stop cannot crash the host or invalidate a successor. Ordinary write backpressure keeps the request pending. Local write completion and stream cleanup do not confirm that a remote model turn has stopped.
Persistent-source conversion forks also retain their original thread/fork creation reply after caller abort or timeout. Repeating the same conversion waits for that reply and uses the original fork; changed conversion input and simultaneous consumers are rejected. No model starts before creation is confirmed. An abandoned public fork call releases only its own late subscription; a failed unsubscribe keeps its cleanup barrier until an actual ACK, with concurrent cleanup retries sharing one flight. Healthy explicit public fork calls remain independent. Default ephemeral Planner/Reviewer conversions run directly on their original threads and do not need a fork. These are process-local resource rules, not recovery after process loss. See the fork creation research.
Subscription cleanup requires a valid thread/unsubscribe response status: unsubscribed, notSubscribed or notLoaded. Missing, unknown or null responses preserve the original local owner and usage. An abandoned fork stays guarded until a valid original acknowledgement, so malformed replies cannot authorize another fork or source model turn. Subscription release does not prove that an active turn stopped. See the response validation research.
Completed persistent-source conversions retain their output separately when fork cleanup fails. The first successful result remains valid; a same-context retry cleans the original fork and returns a protected cached copy without repeating the model or its callbacks. Changed conversion inputs, owners or a lost original process cannot borrow that cache. Cleanup observers share one flight, and withdrawing one observer leaves the others intact. Pending resources prevent idle shutdown; explicit source unsubscribe may finish its own fork cleanup. External CLI handoff reports unconfirmed cleanup instead of closing an active or uncertain server. This process-local cache cannot reconstruct lost ephemeral threads. See the completed-task recovery research.
An accepted model start has a separate lifetime from its caller's wait. If turn/start or review/start has been submitted but its reply has not arrived, caller timeout or cancellation retains the original reply route, child, thread owner and stage. Status reports start_receipt_pending with model continuation unavailable. For DSH-led planning and foreground review, wait for the reply or use codex_workflow_resume only to inspect the retained operation. A genuine late reply lets resume reattach the original turn with fresh callbacks and no replacement model request. Bridge reviews use their original submission's background retry for the same adoption; they do not use foreground resume or require resubmitting unchanged material. start_receipt_invalid keeps an unusable reply guarded until original cleanup is confirmed. Cancel/stop release ownership only after actual completion, exact interruption ACK or verified exit of the captured original process instances. These rules never reconstruct a lost ephemeral thread.
Outstanding fork creation also retains its captured original process tree. SDK stop confirms those instances have exited, including a backend surviving launcher exit; signal delivery alone is insufficient. Safe termination uses the opened original Windows process handle with birth validation, or Linux pidfds through an existing Python 3.9+ runtime. Missing evidence or unavailable safe APIs keep cleanup unconfirmed. A later stop retries only that same cleanup without bootstrapping another server or model. Successful repeated stops retain their shared settled promise. See the process lifetime research.
Recoverable idle shutdown also confirms the captured original process tree before reporting external CLI handoff complete or allowing a successor. A launcher exit alone is insufficient. Failed retirement remains owned for cleanup of those same instances; a later explicit start first awaits that cleanup. Cancelling one caller only withdraws its wait, and permanent stop keeps its stopped latch through cleanup retries. These process rules do not recreate a lost ephemeral Planner or Reviewer. See the idle retirement research.
Rejected initialization keeps the same retirement responsibility. The original handshake error remains primary; auxiliary cleanup failure preserves its original process barrier. A later explicit startup waits for the rejected attempt's cleanup and settlement, then starts independently. It cannot rejoin the old failed initializer or bootstrap over an unknown lost-launcher tree. A genuine spawn failure without an OS PID does not invent a process record. See the initializer retirement research.
Permanent SDK stop also retains ordinary App Server backend ownership when no fork was created. It shares one original retirement census and close with initializer/idle cleanup, settles pending observers, and confirms captured original instances exited before returning success. Denied cleanup permits only another stop of those same instances; missing evidence remains unconfirmed and is never backfilled after launcher exit. The stopped client cannot bootstrap a successor. See the permanent-stop research.
Successful initialization records the original process identities before admitting work. An unexpected launcher exit retains that ownership: cleanup discovers later descendants only through surviving originals with matching births, confirms captured instances exited, then permits an explicitly requested successor. Unknown original identities or denied cleanup block this path; a reused PID cannot lend its descendants to the old cleanup. Identity collection is part of initialization and cannot be interrupted by the idle timer. A fresh metadata process still cannot restore an old ephemeral Planner/Reviewer. See the unexpected-exit research.
Closing or failing the original stdout invalidates RPC availability even if its OS process is still alive. Original observers receive the first output failure, pending model receipts and unconfirmed turns retain cleanup ownership, and further model/metadata calls cannot silently bootstrap another server. An ordinary close allows a separate 100ms exit-observation window so a real process exit keeps its exit code and stderr; explicit read errors are handled directly. Retired stream errors cannot invalidate a successor. Physical retirement still requires the original process-tree proof. See the output lifetime research.
On Windows, kill(pid, 0) can report EPERM even after an old PID disappears. For complete saved birth identities, a successful identity query that omits that PID triggers a separate bounded .NET process-absence check. Only a validated absence receipt confirms it; EPERM, omitted rows, denied handles and failed helpers alone remain unknown. Descendant refresh uses the same check and never invents missing original identities or signals a process during inspection. See the absence-proof research.
Original stdin and stdout failures share one first-cause transport guard. An initialize ACK does not publish readiness if stdin fails while original process identities are still being collected. Shared startup observers receive that same physical failure; cancelling one observer keeps its own cancellation reason without hiding the failure from another. After readiness, broken stdin invalidates model/RPC availability while retaining its process and exact active-turn cleanup responsibility. A later output error cannot replace the first input cause, and retired stream events cannot poison a successor. See the initialization transport research.
Node spawn can return a ChildProcess without stdio on EMFILE/ENFILE. The transport keeps that original child and skips readline construction until a real output exists; the SDK installs error/lifecycle listeners and waits for the original failure receipt before any RPC. That wait uses the existing control-RPC budget and caller cancellation. No-PID failures do not invent cleanup records; an actual child with incomplete stdio is retired using its original process evidence. A late failed receipt cannot invalidate an independent successor. See the missing-stdio research.
stderr is a diagnostic channel. A Readable error there is consumed while healthy stdin/stdout RPCs, initialization and the original thread remain usable. If that current process later exits, its diagnostic notes stderr diagnostic stream unavailable; the private stream-error contents are not appended. A retired stderr error cannot add that marker to a successor. Original process-tree retirement remains required. See the diagnostic-stream research.
Each original child decodes stderr incrementally as UTF-8, preserving Chinese characters and emoji split across OS chunks. Its diagnostic flushes observed bytes after its stderr ends or the original exit drain reaches its 100ms bound. The existing 16,384 UTF-16-unit tail budget remains; clipping drops a partial leading surrogate rather than showing half an emoji. Truly incomplete source bytes use Node's replacement-character semantics. Late retired data still cannot enter successor errors. See the UTF-8 diagnostic research.
The CLI audit dispatcher separately enforces its maxOutputBytes retention limit (default 64 KiB per stream): stdout overflow rejects the run, while stderr keeps only the newest complete UTF-8 code points within the limit. Both streams check final decoder output, including replacement characters for genuinely incomplete source bytes. This bounds retained diagnostics, not total process memory or OS pipe buffering; it preserves the existing CLI thread and error classification paths.
CLI stderr Readable errors are consumed separately from model output. Healthy stdout results still complete on the original process; an actual failed exit keeps its original classification and adds a fixed diagnostic-stream-unavailable note outside retained source bytes. Raw stream error messages cannot change model retry classification, and retired stderr errors cannot annotate a later process's failure. This does not repair a broken pipe or restore lost diagnostics.
CLI stdin/stdout errors affect prompt delivery or model output. The dispatcher consumes these events, preserves the first original error or prior cancellation, terminates that original child, and waits for its close and any admitted thread identity binding before rejecting. Synchronous prompt-write failures follow the same cleanup path. Output received after a pending failure cannot bind a new thread identity; retired stream errors are consumed without affecting a later independent process. This handles explicit stream errors, not silent pipe stalls or proof that all descendants have exited.
Caller cancellation remains observed while an already closed CLI process's original thread identity is still being saved. The dispatcher waits for that admitted binding to settle, then reports the first pending error or original cancellation and removes its abort listener. Process close does not end the binding's ownership; cancellation does not skip persistence or start a replacement. Model timers still stop at process close, so this is not a new hard deadline for a binding callback that never settles.
When a CLI caller waits for another run on the same Reviewer thread, its AbortSignal can cancel that wait before the owner completes. The queued caller creates no child and cannot kill the owner's process, cancel its binding or abort another waiter. Its own listener is removed on cancellation or admission; unrelated threads remain independent. This is caller-signal cancellation, not a new queue scheduler, fairness guarantee or rewrite of workflow/submission cancellation.
Known CLI thread IDs reserve admission synchronously within their dispatcher before waiting. At most one caller may start a CLI process on that thread; the reservation lasts through original process close and admitted identity binding. Cancelled reservations remain ordered behind their predecessor until it releases, so later callers cannot bypass a live owner. Synchronous spawn failure also releases the reservation, and different thread IDs remain independent. This does not serialize whole business review rounds, unknown new thread creation or other dispatcher processes.
CLI cancel(workflowId), cancelSubmission(workflowId, submissionId) and dispatcher stop find calls awaiting admission before they own a child. Each pending call has its own cancellation source combined with the caller's AbortSignal; the first reason wins. Matching queued calls reject without killing another owner or peer. Original active children retain their existing cancellation and binding cleanup; cancelled pending calls cannot start later.
Cancellation also remains registered throughout each CLI transient retry operation, including backoff after its previous child has exited. Matching workflow/submission cancellation aborts that wait and prevents another model process; unrelated submissions remain independent. Dispatcher stop waits for owned retry operations to unwind, and overlapping cancellation paths terminate each original child once. New explicit calls can reuse the same identity after cancellation. See the retry cancellation research.
After a captured original process tree is signaled, its exit-confirmation polling grace uses monotonic elapsed time. System clock changes cannot extend or shorten that grace. Cleanup still needs an independent original-instance exit proof; unavailable evidence remains unconfirmed. Native process queries and termination keep their separate bounds, so the polling grace is not a deadline for the entire teardown. See the cleanup clock research.
Before idle or permanent retirement closes its original native child, an unavailable process census permits one more read only while that same child remains live and owned by the SDK. Both attempts retain the census's existing 4-second bound. Repeated unavailability or a retired owner keeps the original cleanup unconfirmed; it never backfills missing old evidence after closure or starts a replacement backend. See the retirement census investigation.
Failed original retirement, fork or lost-process cleanup appends a compact census summary and exposes frozen cleanupDiagnostics: fixed observation codes, elapsed milliseconds, a numeric exit code and whitelisted errno/signals. Lost-process refresh distinguishes incomplete original/current identities, unavailable capture, a birth-domain mismatch and conflicting descendant identity. refreshed means the evidence was refreshed; independent whole-tree exit confirmation is still required. query_terminated does not alone prove timeout or exit. Raw errors, commands, environment, stdout and stderr are excluded. Diagnostics cannot authorize continuation or change the original cleanup barrier. See the census diagnostic record and lost-process refresh record.
Fork creation and conversion cleanup failures carry the same bounded census summary. The original fork barrier, child identity and no-replacement rule remain unchanged; only the diagnostic evidence is shared.
Lost-process cleanup retains its original startup census observations as a frozen prefix, followed by only the current refresh attempt. Repeated cleanup does not accumulate prior attempts. If the original launcher retires before startup capture returns, owner_retired records that observation while the returned process evidence remains PID-only; captured late identities are discarded. The same prefix appears on that startup retirement barrier. Cancellation alone is not evidence of owner retirement. See the startup census record.
An ephemeral Reviewer whose DSH binding callback temporarily fails remains retained on the same thread. The next request retries the binding callback before starting a model turn, so a transient local persistence error cannot spend a model request or create a replacement Reviewer. Durable/non-ephemeral binding failures keep their terminal cleanup behavior. See the Reviewer binding recovery record.
If cancellation, lease loss, stop, or original-thread loss makes that retention ineligible while the binding callback is still pending, the claimed Reviewer is released immediately. This prevents a leaked subscription or App Server pin when no recoverable record can be published.
Complete public CLI calls retain the same cancellation ownership across readable-output capture and result callbacks. An admitted callback finishes before the call or dispatcher stop settles; cancellation then blocks the next model stage and successful result delivery. Cached input also enters this scope before capture, so a pre-aborted request performs no capture or model work. Callback failures keep their original cause. See the pipeline cancellation research.
When an admitted CLI operation passes its cancelled signal to the next stage after dispatcher stop, it reports that signal's original reason. The retained signal must belong to the same workflow/submission still registered in this dispatcher. New stopped requests and displaced identities retain the stopped admission error; no cancelled handoff starts a model. Non-Error caller reasons retain the existing codex CLI audit aborted fallback. See the first-cause handoff research.
For redirected Windows verification logs, set $env:DSH_WORKFLOW_TEST_REPORTER = "tap" before pnpm verify or pnpm release:check. TAP includes ASCII summary lines such as # tests 1448 and # fail 0, which avoid console damage to the spec reporter's Unicode symbols. The setting accepts spec or tap; unset or empty uses Node's default. Explicit --test-reporter flags, including multiple reporters and their destinations, take precedence. Test selection, concurrency and exit codes are unchanged. Always inspect the actual exit code and completed summary; reporter output can vary across Node versions and is not a replacement for process completion evidence.
Shared Reviewer test fixtures accept the test's t.signal for their phase waits and register t.after cleanup. Cancellation leaves a held wait with the original reason; cleanup joins one disposal and stops the owned manager before waiting for its callbacks. The existing case limits remain unchanged, and a timed-out test remains cancelled. This prevents a failed local fixture from keeping a verification worker alive; it does not classify real provider failures or change workflow recovery. See the test cancellation research.
Manager lease heartbeats contain exceptions from SQLite renewal. A failed renewal leaves ownership unconfirmed, so that heartbeat stops and in