Closed feature request

Let AI callers choose job-ID or streaming responses with approval and completion events

John Lauer · 2mo ago ·closed by John Lauer

Environment: Codex Astra in Adom Hydrogen, running from a Linux/WSL2 container through the relay to AdomLapper (Windows). Last observed UI versions in this session: Hydrogen 1.0.210 and Adom Bridge 2.1.43; these are session observations, not newly verified version readings. Live AB status confirms the Windows target is reachable.

Please let the calling AI choose immediate job-ID responses or an open event stream over the same operation lifecycle. Preserve today's request/response behavior when the option is omitted.

The concrete problem: a requested Hydrogen window resize through run_script returned errorCode: needs_approval and run_script needs shell approval, and only the USER can grant it (shell approval is human-only). The AI received this immediately, but had no automatic indication of a later approval change. The user had to tell it to retry. Long-running bridge work has the same need for progress and completion delivery. This request concerns operation delivery, not changing who can approve actions.

Suggested API shape (names illustrative):

{"verb":"…","args":{},"responseMode":"job","idempotencyKey":"caller-generated-key"}
{"verb":"…","args":{},"responseMode":"stream","streamTimeoutSeconds":60,"idempotencyKey":"caller-generated-key"}
  • Both return a stable jobId immediately. Job mode releases the call; stream mode continues emitting approval, execution and terminal events.
  • Events include waiting_for_approval, approved, denied, approval_expired, started, progress, completed, failed and cancelled. Include jobId, ordered event cursor, timestamp and final result/error. Preserve the originating caller/thread and target identity.
  • Stream heartbeat suggested every 15 seconds, default stream lifetime 60 seconds with a bounded caller override. Closing or timing out the stream detaches the observer; it does not cancel or resubmit the operation. Return the jobId and last cursor for recovery.
  • Provide status/result lookup, attach/reconnect after a cursor, and explicit cancellation. Replay retained events; disclose a retention gap with a current snapshot when a cursor has expired.
  • Preserve approval-gated requests as pending jobs. After real human approval, resume the same operation once, subject to current authorization and target validity. Use deduplication so retries/reconnects cannot submit the mutation twice; do not promise exactly-once external side effects across crashes when their outcome is unknown.
  • Pending approvals need a separate expiry; completed jobs/events need bounded retention and cleanup. Surface cancelled/expired/unknown-outcome states explicitly.
  • Deliver approval/completion notifications to the originating Hydrogen AI thread when it is detached. The relay, CLI and AI tool adapter must expose events incrementally instead of buffering the whole stream until EOF. A stream alone does not wake an idle AI conversation.

Acceptance examples:

  1. A stream-mode call waits for human approval and reports approval and completion without the user typing “try again.”
  2. A job-mode call returns immediately; the AI does other work and can later inspect or attach to that same job.
  3. A 60-second stream timeout/reconnect recovers missed events and the eventual result without rerunning the action.
  4. Denial, approval expiry, cancellation, relay loss and restart have explicit recoverable outcomes; unauthorized callers cannot read or resume another caller's jobs.
  5. Existing callers with no responseMode retain their current behavior.

Related but distinct: adom/adom-bridge#137 concerns mirroring approval prompts to Hydrogen; this proposal adds caller-selectable operation delivery and continuation.

2 Replies

John Lauer · 2mo ago

Good ask, and the right framing: delivery, not authorization. Here is what exists today, what is missing, and how I intend to build it.

Exists: needs_approval is a structured, immediate refusal that also SURFACES the prompt (toast, in-window stack, Hydrogen dialog, and since 2.1.44 every same-account mesh peer's stack); request_shell_approval re-raises it on demand; long bridge work already returns timed_out + stillRunning + statusVerb so a caller polls instead of re-issuing; the wsl_exec_async family is a real job model (jobId + wsl_job_status + a log file) for one verb class. What is missing is exactly what you describe: the pending-approval case ends the call, so nothing tells the AI the human later clicked Allow, and there is no uniform job/stream lifecycle across verbs.

Plan (a proper block, after the mesh P1 relay work that is in flight):

  1. A verb-agnostic job table in ab core: responseMode:"job" returns {jobId, state:"waiting_for_approval"|"running", cursor} at once; the same dispatcher runs the verb under that jobId. job_status {jobId}, job_events {jobId, sinceCursor} (replay from a bounded per-job ring, gap disclosed with a snapshot), job_cancel. Events: waiting_for_approval, approved, denied, approval_expired, started, progress (from the existing bridge progress blocks), completed, failed, cancelled; each carries jobId, cursor, ts, the originating caller/thread and the target box.
  2. Approval-gated jobs stay pending instead of returning needs_approval; a real human decision (any surface, including another mesh box) resumes the SAME operation once, under the authorization in force at that moment; idempotencyKey dedupes resubmits across reconnects. Pending approvals get their own expiry (the existing 5-min quiet window and the 180-s gate wait become explicit states).
  3. responseMode:"stream": the direct API and relay hold the request open and emit the same events incrementally with a 15-s heartbeat and a bounded lifetime (default 60 s, caller override capped); closing detaches, never cancels, and returns jobId + last cursor. The relay's send_and_wait already streams pull_file frames, so the transport can carry incremental events; the CLI grows a --follow that prints events as they arrive instead of buffering to EOF.
  4. Waking a detached AI thread is Hydrogen's half (a completion event has to reach the thread that made the call); ab will deliver notify-class events to the originating relay client so Hydrogen can route them.

Default behavior with no responseMode stays exactly as it is. I will post the event schema here before implementing so Codex/Hydrogen can react to the shapes. Leaving open as the tracking issue.

John Lauer · 2mo ago

Shipped in 2.1.50 (insiders), items 1-3 of the plan; item 4 is Hydrogen's.

Job mode. Any verb accepts args.responseMode:"job": the call returns {jobId, state:"running", cursor} immediately and the same dispatcher runs the verb under that job (a captured-egress context, so every existing handler runs unchanged). job_status {jobId} gives the snapshot (state, createdAt, ageSec, cursor, caller, result once terminal). job_events {jobId, sinceCursor, waitMs<=55000} replays the lifecycle from a bounded per-job ring (200 events; gap:true with a snapshot when your cursor rolled off) and long-polls for the next one. job_cancel {jobId} withdraws a parked job (its approval request is denied by the caller and every mirrored prompt dismissed); a verb already executing cannot be aborted mid-flight and the job ends when it returns. Events: created, started, waiting_for_approval, approved, denied, approval_expired, completed, failed, cancelled; each carries jobId, cursor, ts, state, and the terminal ones the verb's result/error. Jobs are kept 6 h, up to 500. Default behaviour with no responseMode is unchanged.

Approval-gated verbs resume. This was the concrete problem: under a job, the Tier-2 gate no longer ends the call with needs_approval. It surfaces the prompt exactly as today (toast, ab's in-window stack, Hydrogen's dialog when embedded, every same-account mesh peer), registers the job's own pending approval, and PARKS (waiting_for_approval). A real human decision on any of those surfaces resolves it once: approve (or a window/Auto grant, which drains every pending request) -> approved and the original operation runs under the authorization in force at that moment; deny -> denied; nothing within 10 minutes -> approval_expired. No AI path was added; the human-only grant rule is untouched.

Stream mode. responseMode:"stream" = the same job, FOLLOWED by the CLI: it prints each lifecycle event as one JSON line as it arrives (long-polling job_events over the relay), a heartbeat line every 15 s of silence, and the verb's result last (with jobId/jobState added). streamTimeoutSeconds (default 60, max 600) bounds the follow; when it lapses the CLI returns {detached:true, jobId, cursor} and the job keeps running (re-attach with job_events {jobId, sinceCursor}). The relay carries this as ordinary long polls, so no protocol change on the wire and it works from any container today.

Not in this release: idempotencyKey de-duplication across reconnects (a re-issued call is a new job; use the jobId you were given), progress events for verbs that do not already emit progress blocks, and item 4 (waking a detached AI thread) which needs Hydrogen to route a completion notify to the originating thread; ab's notify_user with a thread-scoped caller is the hook for that.

John Lauer closed this · 2mo ago

Log in to reply.