feat(bg): add a scriptable wait command for background sessions
Summary
I would like maintainer direction on a scriptable local background-session wait command:
openclaude wait <id-or-name>
openclaude wait <id-or-name> --timeout <duration>
openclaude wait <id-or-name> --jsonThe command would block on one local background session, emit no progress output, and return a stable machine-readable result when the session reaches a terminal state or the wait itself cannot continue safely.
This is a new public automation contract. I have not implemented it.
Current behavior
Verified on main at 421f45992c440420775ff28e3a8a060869195034.
The local CLI fast path recognizes ps, logs, attach, and kill. There is no wait handler, parser, help entry, or documented exit-code contract. A first argument of wait falls through to ordinary startup instead of synchronizing with the local background registry.
Automation therefore has to poll openclaude ps, parse presentation output, and separately recover the child result if it needs an exact exit code or signal.
PR #2133 provides the required foundation. The registry now records natural exit, natural failure, explicit kill, and conservative stale outcomes. It preserves exact safe-integer child exit codes and bounded signals. Its precedence is:
- explicit killed fact;
- natural exited or failed fact;
- liveness-derived stale state.
No existing issue, discussion, pull request, or recent commit found in the overlap search proposes this wait command. Issue #2145 covers terminal-aware logs -f and explicitly leaves shell synchronization to a future wait command.
User impact
Polling ps is awkward in shell scripts and CI jobs. It also asks callers to recreate registry policy, selector behavior, terminal precedence, and timeout handling. A dedicated command would make this safe and portable:
openclaude --bg --name nightly-index "rebuild the index"
openclaude wait nightly-index --timeout 30m --jsonThe wait command should report the selected session's outcome. It should not print logs, prompts, commands, or other session content.
Proposed v1
Support:
openclaude wait <id-or-name>
openclaude wait <id-or-name> --timeout <duration>
openclaude wait <id-or-name> --jsonArgument rules:
waitmust be the first CLI argument, matching the existing local background commands.- Flags may appear before or after the selector.
- Exactly one selector is required unless
--helpor-his used. - Accept
--timeout 30sand--timeout=30s. - Accept positive integer durations with
ms,s,m, orhsuffixes. - Cap durations at 2,147,483,647 milliseconds, matching the existing timer bound.
- Accept
--before a selector that starts with a dash. - Reject zero, negative, fractional, duplicate, overflowed, unknown, or extra arguments with shell code 2.
--helpprints wait-specific help to stdout and returns zero.- No default timeout. Without
--timeout, the command waits while the state remains running or temporarily unknown.
Keep log viewing separate. --follow does not belong in wait; openclaude logs <id-or-name> -f remains the log command.
Exit-code contract
Recommendation: use command-owned shell codes and expose the exact child result in human output and JSON.
| Wait result | Shell code |
|---|---|
| Child exited naturally with code 0 | 0 |
| Child exited nonzero, ended by signal, was explicitly killed, or became stale | 1 |
| Invalid arguments, unsupported build, missing selector, unknown selector, or ambiguous selector | 2 |
| Final timeout-boundary process observation is unknown or unreadable | 3 |
| Relevant metadata is missing after selection, unreadable, malformed, or replaced | 4 |
| Timeout while the final observation is running | 124 |
| Waiting client receives SIGINT | 130 |
| Waiting client receives SIGTERM | 143 |
This deliberately differs from the current sibling background commands, whose shared failure helper returns 1 for usage and selector errors. wait needs to distinguish observed child failure from caller error. Changing ps, logs, attach, or kill is outside this proposal.
Alternatives considered
- Mirror the child's exit code where the shell can represent it and reserve some values for wait failures. This is familiar, but child codes collide with timeout, usage, signal, and observation failures. POSIX shells also truncate values outside 0 through 255. Selective remapping makes the rule hard to use correctly.
- Use command-owned typed codes and put exact child facts in output. This is the recommendation. It is stable across platforms and keeps registry failures distinct from child failures.
- Return zero for child success, one for every other failure, and 124 for timeout. This is simple, but it conflates a failed child with an ambiguous selector or corrupt registry unless every caller parses output.
Selector and metadata rules
Resolve the selector once, then pin the exact session ID and PID. A reused name or a new ID-prefix collision must not retarget an active waiter.
Preserve the current selection order:
- exact ID;
- one live session with the exact name;
- one unambiguous ID prefix;
- one terminal session with the exact name.
wait needs a stricter read path than today's display commands. The current registry readers intentionally collapse missing, unreadable, malformed, and invalid documents to null. That is suitable for tolerant listing, but it cannot support this exit-code contract.
For wait:
- a missing exact-ID file can still allow name or prefix matching;
- an unreadable or malformed exact-ID file returns metadata error 4;
- an unreadable entry that could change name or prefix matching returns 4 with no pinned ID;
- a missing natural or killed fact file means that fact does not exist;
- an unreadable or malformed terminal fact returns 4 rather than being treated as absent;
- after pinning, a missing metadata file or a different ID or PID returns 4.
Keep these strict readers private to wait. Do not change the tolerant behavior of ps, logs, attach, or kill in this work.
Polling and consistency
Use read-only targeted polling rather than a watcher or repeated full-registry refresh.
- Observe immediately before creating a timer. An already-terminal session returns on the first observation.
- Poll every 500 milliseconds with serialized abortable timeouts. Do not allow overlapping callbacks.
- Start the deadline from a monotonic clock after argument validation. Use wall time only for persisted and displayed timestamps.
- Open and read each pinned metadata or terminal-fact path once per read, then validate the whole document. Existing writers publish metadata by replacement and terminal facts by immutable link, so an observation sees a complete old or new file rather than combining versions.
- Inspect only the pinned session. Do not scan or rewrite unrelated sessions on each poll.
- Waiting must not write running, unknown, stale, or terminal status. It must not change logs, name reservations, or process state.
- For nonterminal metadata, run the existing PID ownership policy through an asynchronous, abortable process lookup.
- Re-read the pinned metadata and both terminal facts after the process lookup. A killed or natural fact that appears during lookup wins over derived stale, unknown, or running state.
- Treat PID absence or identity mismatch as stale only after that final fact read remains empty.
- Treat unknown process identity as retryable before the deadline. A transient
ps, PowerShell, permission, or lookup timeout must not end a previously healthy wait. - At the deadline, perform one final complete observation. Terminal wins. Running returns 124. Unknown returns 3. Metadata failure returns 4.
- If an observation begins before the deadline and finishes after it, classify that completed result with the same table and do not start another poll.
The final read is the observation's linearization point. A terminal fact published before it is included. A fact published afterward belongs to a later invocation.
Polling alternatives considered
- Targeted 500 ms polling. This is the recommendation. It uses current registry policy, stays deterministic in tests, and needs no dependency.
- Repeated full-registry refresh. This re-reads and may rewrite unrelated sessions. It also repeats synchronous process lookups today, so it is too expensive and has unwanted side effects for one waiter.
- Filesystem watchers or a background event daemon. These add cross-platform delivery and lifecycle complexity and still need a consistency read after every event. They are too large for v1.
Waiting-client signals and cleanup
Install SIGINT and SIGTERM listeners immediately after argument validation and before selector or metadata I/O. Help exits before installing them.
The first waiting-client signal wins. It aborts pending sleep and process lookup, produces one result, and removes both listeners in finally. A recorded client signal wins over a selector, terminal, observation, or timeout result that completes afterward.
SIGINT returns 130. SIGTERM returns 143. Neither signal is forwarded to the background session, and neither changes session metadata.
The public npm launcher currently performs a synchronous heap relaunch. Directly signaling the launcher PID can therefore miss the inner waiter. The narrow preferred implementation is to keep wait in the process the user launched by bypassing that heap relaunch for this bounded local command. An installed-launcher integration test should signal the launcher PID and prove that no inner waiter remains.
Cleanup must leave no referenced timer, process listener, process-inspection child, open handle, or unresolved promise.
Output contract
Human mode prints nothing while waiting. Terminal results go to stdout:
Background session <id> exited with code 0.
Background session <id> failed with exit code <code>.
Background session <id> failed after <signal>.
Background session <id> was killed.
Background session <id> became stale without an observable child outcome.Usage, selector, observation, timeout, and client-interruption messages use one concise stderr line. They may include the pinned background ID, but not raw metadata paths, commands, prompts, providers, models, or log paths.
JSON mode writes exactly one NDJSON-safe, newline-terminated object to stdout for every handled result. It writes no human prose to stderr. Unexpected internal failures remain outside this schema and may use stderr with code 1.
Schema version 1:
{
"version": 1,
"id": "bg-...",
"status": "failed",
"exitCode": 2,
"signal": null,
"terminalReason": "exit_code",
"finishedAt": "2026-08-22T18:00:00.000Z",
"waitResult": "terminal",
"error": null,
"waitSignal": null
}All keys are always present. Nullable fields use null.
| Result | id | status | exitCode | signal | terminalReason | finishedAt | waitResult | error | waitSignal |
|---|---|---|---|---|---|---|---|---|---|
| Natural exit zero | pinned | exited | 0 | null | exit_code | recorded | terminal | null | null |
| Natural nonzero exit | pinned | failed | recorded | null | exit_code | recorded | terminal | null | null |
| Child signal | pinned | failed | null | recorded | signal | recorded | terminal | null | null |
| Explicit kill | pinned | killed | null | null | explicit_kill | effective recorded value | terminal | null | null |
| Stale | pinned | stale | null | null | null | null | terminal | null | null |
| Timeout | pinned | running | null | null | null | null | timeout | timeout | null |
| Process unreadable at deadline | pinned | unknown | null | null | null | null | observation_error | process_unreadable | null |
| Metadata error before pinning | null | null | null | null | null | null | observation_error | typed metadata code | null |
| Metadata error after pinning | pinned | null | null | null | null | null | observation_error | typed metadata code | null |
| Selector error | null | null | null | null | null | null | selector_error | typed selector code | null |
| Client SIGINT | pinned or null | null | null | null | null | null | interrupted | interrupted | SIGINT |
| Client SIGTERM | pinned or null | null | null | null | null | null | interrupted | interrupted | SIGTERM |
Metadata error codes are metadata_missing, metadata_unreadable, metadata_malformed, and metadata_replaced. Selector error codes are invalid_arguments, not_found, ambiguous, and unsupported.
JSON never contains session names, logs, prompts, commands, paths, provider data, model data, or credentials.
Minimal implementation after approval
- Add a small wait parser, loop, renderer, and handler beside the current local background handlers.
- Add a wait-specific typed selector resolver and targeted read-only observer in the registry.
- Add the smallest asynchronous, abortable process-inspection helper needed for repeated observation. If issue #2145 lands first with a compatible exact-session helper, reuse it. Do not couple wait to log I/O.
- Register
waitin the local fast path. Recognize it even whenBG_SESSIONSis disabled so the CLI returnsunsupportedinstead of interpreting wait as prompt text. - Preserve signal delivery at the public npm launcher boundary.
- Document the command, exit codes, JSON schema, and feature-gate behavior in the README.
This does not need a polling framework, event bus, daemon, or new dependency.
Compatibility and security
- The command reads the local background registry only. It makes no provider or network request.
- It reuses #2133 status and PID ownership policy. It does not invent a second terminal classifier.
- It never converts unknown, malformed, or unreadable state into success.
- It pins ID and PID to prevent name reuse or metadata replacement from retargeting a waiter.
- It performs no destructive action and forwards no signal to the child.
- Human and JSON output exclude command, prompt, path, log, provider, model, and credential fields.
- Exact child exit codes remain available in JSON even when they exceed the shell's portable range.
- Version 1 field meanings and exit codes remain stable. An incompatible JSON change requires a new schema version.
Deterministic acceptance criteria
The implementation should include behavior-level tests for:
- CLI fast-path dispatch and disabled-feature
unsupportedbehavior; - exit zero and exact nonzero safe-integer child codes, including values above 255;
- child signal, explicit kill, and stale outcomes;
- already-terminal sessions with no timer;
- one transient unknown observation followed by running or terminal recovery;
- unknown on the final timeout observation returning 3;
- running on the final timeout observation returning 124;
- terminal publication before the final deadline read winning over timeout;
- natural and killed facts appearing during liveness lookup winning over stale;
- exact ID, live name, ID prefix, terminal name, name reuse, and later prefix collision;
- unknown and ambiguous selectors;
- malformed exact metadata, unreadable matching entries, malformed old metadata, malformed terminal facts, missing post-pin metadata, and changed post-pin PID;
- invalid, duplicate, overflowed, unknown, and extra arguments;
- both timeout syntaxes, timeout plus JSON,
--, and help; - SIGINT and SIGTERM before pinning, during first and later metadata reads, during process lookup, between observation and sleep, and during sleep;
- direct PID signaling of the installed npm launcher;
- the complete JSON object for every result row, with no human stderr in JSON mode;
- exact human terminal lines and no progress output;
- no registry write and no unrelated session inspection;
- no timer, signal listener, child process, handle, environment, test-root, or promise leak.
The first regression should prove that current main does not dispatch wait through the local background fast path. It should fail for that missing behavior, not for a proposed private helper.
Suggested checks for an approved implementation:
bun test src/cli/bg.test.ts src/cli/bgRegistry.test.ts src/entrypoints/cli.test.ts src/utils/genericProcessUtils.test.ts scripts/openclaude-bin-wait.test.ts
bun run build
bun run smoke
bun run typecheck
bun run typecheck:type-tests
bun run check
bun run doctor:runtime
bun run security:pr-scan -- --base upstream/main --head HEAD
git diff --check upstream/main...HEADRun the generic process utility test only if that utility changes.
Dependencies and rollout
PR #2133 is merged, so there is no hard code dependency.
Issue #2145 is a soft sequencing preference. If its approved implementation creates a small, tested, exact-session observer that is read-only and abortable, wait should reuse it. Neither change should grow merely to share code, and wait must not depend on log I/O.
Ship the command only after approval and focused review. Include the handler, README contract, JSON schema version 1, exit-code table, and installed-launcher behavior in the same release. The release number is a maintainer scheduling decision.
The open build already enables BG_SESSIONS. Builds that disable it should still recognize wait and return code 2 with unsupported, including JSON mode. They must not fall through to provider startup. Disabling the feature can roll back availability while preserving fail-closed command recognition.
Non-goals
- remote-session waiting;
- a
--followflag or automatic log output; - attach mode or interactive input;
- automatic retry of failed work;
- multi-session wait;
- filesystem watchers, an event daemon, or a new dependency;
- metadata format or retention changes;
- terminal precedence, PID ownership, kill, launch, or name-policy changes;
- changes to
logs -f; - changes to exit codes for existing background commands.
Maintainer direction requested
Do maintainers approve this v1 command, the command-owned exit codes, read-only targeted polling, strict wait-specific metadata reads, and version 1 JSON schema? If so, I will prepare a focused test-driven implementation from current main within this scope.
Source: Gitlawb/openclaude