@byok-sdk/client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +11 -0
  3. package/dist/adapters/claude/claude-adapter.d.ts +155 -0
  4. package/dist/adapters/claude/events.d.ts +91 -0
  5. package/dist/adapters/claude/permission-mapping.d.ts +142 -0
  6. package/dist/adapters/claude/process-client.d.ts +103 -0
  7. package/dist/adapters/claude/resolve-approval-mcp-bin.d.ts +45 -0
  8. package/dist/adapters/claude/resolve-bin.d.ts +27 -0
  9. package/dist/adapters/codex/codex-adapter.d.ts +97 -0
  10. package/dist/adapters/codex/events.d.ts +41 -0
  11. package/dist/adapters/codex/permission-mapping.d.ts +107 -0
  12. package/dist/adapters/codex/process-runner.d.ts +86 -0
  13. package/dist/adapters/codex/resolve-bin.d.ts +25 -0
  14. package/dist/adapters/pi/events.d.ts +58 -0
  15. package/dist/adapters/pi/permission-mapping.d.ts +46 -0
  16. package/dist/adapters/pi/pi-adapter.d.ts +29 -0
  17. package/dist/adapters/pi/resolve-bin.d.ts +44 -0
  18. package/dist/adapters/pi/rpc-client.d.ts +101 -0
  19. package/dist/bin/approval-mcp-server.d.ts +69 -0
  20. package/dist/bin/audit-log.d.ts +212 -0
  21. package/dist/bin/byok-agent.d.ts +2 -0
  22. package/dist/bin/byok-agent.js +12347 -0
  23. package/dist/bin/byok-agent.js.map +1 -0
  24. package/dist/bin/byok-approval-mcp.d.ts +2 -0
  25. package/dist/bin/byok-approval-mcp.js +498 -0
  26. package/dist/bin/byok-approval-mcp.js.map +1 -0
  27. package/dist/bin/commands/approvals.d.ts +31 -0
  28. package/dist/bin/commands/approve-reject.d.ts +29 -0
  29. package/dist/bin/commands/doctor.d.ts +15 -0
  30. package/dist/bin/commands/pair.d.ts +7 -0
  31. package/dist/bin/commands/runtimes.d.ts +7 -0
  32. package/dist/bin/commands/service.d.ts +54 -0
  33. package/dist/bin/commands/start.d.ts +21 -0
  34. package/dist/bin/commands/status.d.ts +25 -0
  35. package/dist/bin/commands/support-bundle.d.ts +7 -0
  36. package/dist/bin/commands/tasks.d.ts +27 -0
  37. package/dist/bin/commands/unpair.d.ts +124 -0
  38. package/dist/bin/commands/workspaces.d.ts +16 -0
  39. package/dist/bin/config.d.ts +41 -0
  40. package/dist/bin/control-client.d.ts +50 -0
  41. package/dist/bin/format.d.ts +67 -0
  42. package/dist/bin/runtime-probe.d.ts +34 -0
  43. package/dist/bin/tasks-view.d.ts +49 -0
  44. package/dist/daemon/approvals.d.ts +87 -0
  45. package/dist/daemon/auth-manager.d.ts +52 -0
  46. package/dist/daemon/blob-client.d.ts +22 -0
  47. package/dist/daemon/connection-manager.d.ts +502 -0
  48. package/dist/daemon/control-protocol.d.ts +299 -0
  49. package/dist/daemon/control-server.d.ts +61 -0
  50. package/dist/daemon/create-daemon.d.ts +364 -0
  51. package/dist/daemon/cursor-store.d.ts +30 -0
  52. package/dist/daemon/daemon-owner.d.ts +24 -0
  53. package/dist/daemon/deterministic-jitter.d.ts +20 -0
  54. package/dist/daemon/device-keys.d.ts +45 -0
  55. package/dist/daemon/device-proof-signer.d.ts +41 -0
  56. package/dist/daemon/environment.d.ts +96 -0
  57. package/dist/daemon/git-workspace-store.d.ts +49 -0
  58. package/dist/daemon/git-workspace.d.ts +80 -0
  59. package/dist/daemon/http-client.d.ts +8 -0
  60. package/dist/daemon/journal/journal.d.ts +342 -0
  61. package/dist/daemon/journal/sqlite-journal.d.ts +173 -0
  62. package/dist/daemon/journal/sqlite-support.d.ts +96 -0
  63. package/dist/daemon/journal/storage-policy.d.ts +368 -0
  64. package/dist/daemon/long-poll-transport.d.ts +142 -0
  65. package/dist/daemon/observer.d.ts +345 -0
  66. package/dist/daemon/operational-health.d.ts +87 -0
  67. package/dist/daemon/policy.d.ts +16 -0
  68. package/dist/daemon/progress-batcher.d.ts +31 -0
  69. package/dist/daemon/runtime-capabilities.d.ts +36 -0
  70. package/dist/daemon/session-workspace-store.d.ts +75 -0
  71. package/dist/daemon/store.d.ts +54 -0
  72. package/dist/daemon/task-runner.d.ts +862 -0
  73. package/dist/daemon/truth-memory-client.d.ts +94 -0
  74. package/dist/daemon/url.d.ts +60 -0
  75. package/dist/daemon/ws-transport.d.ts +110 -0
  76. package/dist/diagnostics/diagnostics.d.ts +114 -0
  77. package/dist/diagnostics/support-bundle.d.ts +81 -0
  78. package/dist/index.d.ts +42 -0
  79. package/dist/index.js +10056 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/lifecycle/create-service-lifecycle.d.ts +59 -0
  82. package/dist/lifecycle/exec-runner.d.ts +141 -0
  83. package/dist/lifecycle/launchd.d.ts +55 -0
  84. package/dist/lifecycle/service-types.d.ts +177 -0
  85. package/dist/lifecycle/systemd.d.ts +46 -0
  86. package/dist/lifecycle/winsw.d.ts +64 -0
  87. package/dist/types.d.ts +178 -0
  88. package/dist/util/async-queue.d.ts +45 -0
  89. package/dist/util/atomic-write.d.ts +86 -0
  90. package/dist/util/secure-dir.d.ts +124 -0
  91. package/package.json +66 -0
@@ -0,0 +1,862 @@
1
+ import { type Envelope, type PermissionPolicy, type RuntimeId, type TaskOfferPayload } from '@byok-sdk/protocol';
2
+ import { type RuntimeAdapter } from '../types';
3
+ import { type ApprovalDecision, type ApprovalOrigin, type ApprovalRegistry } from './approvals';
4
+ import type { BlobResolver } from './blob-client';
5
+ import type { TaskQueueWatermark } from './control-protocol';
6
+ import { type ProgressBatcherOptions } from './progress-batcher';
7
+ import type { SessionWorkspaceStore } from './session-workspace-store';
8
+ import type { GitWorkspaceManager, GitWorkspaceObservation } from './git-workspace';
9
+ import type { GitWorkspaceStore, GitWorkspacePhase } from './git-workspace-store';
10
+ /**
11
+ * M4 Phase 3: default wait for `requestApproval` (see its own doc comment)
12
+ * before force-resolving an unanswered out-of-band approval as a fail-closed
13
+ * rejection — generous enough for a real human to actually notice and act on
14
+ * an approval prompt, short enough that a genuinely abandoned task doesn't
15
+ * tie up daemon/task bookkeeping forever. Overridable via
16
+ * `TaskRunnerDeps.approvalTimeoutMs` (ultimately `DaemonConfig`-configurable —
17
+ * see `create-daemon.ts`).
18
+ */
19
+ export declare const DEFAULT_APPROVAL_TIMEOUT_MS: number;
20
+ /**
21
+ * Finding F5(a) (cross-model adversarial review): bound on how long
22
+ * `shutdownTask` waits for a single task's OWN `session.interrupt()` before
23
+ * giving up on it specifically and reporting `task.fail` anyway. Without an
24
+ * INNER bound here, a hung `interrupt()` (a misbehaving runtime adapter
25
+ * whose promise never settles) meant `task.fail` for THAT task was never
26
+ * sent at all — not eventually, not ever — because the send was sequenced
27
+ * strictly AFTER the `await`. The OUTER deadline
28
+ * `create-daemon.ts`'s `performControlShutdown` races `shutdownActiveTasks`
29
+ * against (`SHUTDOWN_TASK_TEARDOWN_DEADLINE_MS`) does not help: racing at
30
+ * that layer only unblocks the CALLER to proceed to `stop()`/closing the
31
+ * connection — it does nothing to unstick THIS function's own
32
+ * still-suspended `await`, which just keeps running (harmlessly, since
33
+ * nothing awaits it anymore) in the background forever after, its
34
+ * `deps.send` line never reached. Deliberately shorter than the outer
35
+ * 10s deadline so one hung task's own interrupt can't itself consume the
36
+ * whole outer budget and starve however many OTHER tasks
37
+ * `shutdownActiveTasks` awaits concurrently via `Promise.all`. Overridable
38
+ * via `TaskRunnerDeps.shutdownInterruptTimeoutMs` (ultimately
39
+ * `DaemonOverrides.shutdown.taskInterruptTimeoutMs` — see `create-daemon.ts`).
40
+ */
41
+ export declare const DEFAULT_SHUTDOWN_INTERRUPT_TIMEOUT_MS = 5000;
42
+ /**
43
+ * M4 Phase 4 (fold-in from the P3 gate): bound on how many `requestApproval`
44
+ * calls may sit QUEUED (not yet dispatched — see that method's own doc
45
+ * comment) for the same task at once. Claude's parallel tool use can fire
46
+ * more than one concurrent approval request for the same taskId; this is a
47
+ * defensive ceiling on that fan-out, mirroring `approvals.ts`'s own
48
+ * `MAX_PENDING_APPROVALS` (a whole-daemon cap) one level down (a per-task
49
+ * cap) — not a realistic workload limit. A request arriving once a task's
50
+ * queue is already at this size is rejected fail-closed immediately, the
51
+ * same shape `requestApproval` already uses for an unknown/inactive taskId.
52
+ */
53
+ export declare const MAX_PENDING_APPROVALS_PER_TASK = 16;
54
+ /**
55
+ * M4 Phase 3 hardening (orchestrator-directed fix): thrown by the
56
+ * `ctx.approvalChannel.resolve` closure built in `handleOffer` below when
57
+ * this task has no CURRENTLY pending out-of-band approval to resolve.
58
+ * Distinguished from a plain `Error` specifically so `handleApprove`/
59
+ * `handleReject` can tell "a wire task.approve/task.reject arrived for an
60
+ * approval a DIFFERENT, faster path (a racing local `approvals.resolve`, or
61
+ * this exact decision arriving twice) already resolved" — a benign,
62
+ * expected race, audit-worthy but never task-state-affecting — apart from
63
+ * "the session's own resolveApproval() failed for some other, genuine
64
+ * reason" (an adapter-level problem, which still fails the task exactly as
65
+ * before). Only ever thrown for an adapter that actually wires up a real
66
+ * approval channel (claude, under `confirm` mode) — pi/codex's own
67
+ * `resolveApproval()` still throw their own unrelated, adapter-specific
68
+ * "not supported at all" errors, which are NOT instances of this class and
69
+ * therefore still fall through to the pre-existing fail-the-task behavior,
70
+ * unchanged.
71
+ */
72
+ export declare class NoPendingApprovalError extends Error {
73
+ readonly taskId: string;
74
+ constructor(taskId: string);
75
+ }
76
+ /**
77
+ * M3-B: cap for both `finishedTaskIds` and `pendingCancelled` below (each
78
+ * gains one entry per finished/cancelled task and was never pruned) — fine
79
+ * for the short-lived CLI invocations M0-M2 ran as, but M3 turns the daemon
80
+ * into a background service meant to stay up for weeks, so unbounded growth
81
+ * here is a real, if slow, memory leak. Each collection evicts its OLDEST
82
+ * (first-inserted) entry once over this cap — the same bounded-ring idiom
83
+ * `ConnectionHub`'s per-device dedup window already uses server-side
84
+ * (packages/server/src/hub.ts's `DEDUP_RING_CAPACITY`), just applied here to
85
+ * task ids. `Map`/`Set` iterate in insertion order (ECMA-262), so "oldest"
86
+ * always means "finished/cancelled longest ago" — neither collection is
87
+ * touched on a read, only on insert, so eviction order depends purely on
88
+ * insertion time. See `finishedTaskIds` and `pendingCancelled`'s own doc
89
+ * comments below for why a cap this size can't remove an entry either
90
+ * invariant still needs.
91
+ */
92
+ export declare const MAX_TRACKED_TASK_IDS = 2000;
93
+ /**
94
+ * M5 batch-3 (workstream 2): stable, documented reason PREFIX a `task.fail`
95
+ * carries when `payload.limits.maxDurationMs` (daemon-authoritative
96
+ * wall-clock enforcement — see `armMaxDurationTimer`) is exceeded. Only the
97
+ * prefix itself is the contract an embedder can match against
98
+ * (`reason.startsWith(...)`); everything after it is human-readable detail,
99
+ * not part of the stable shape.
100
+ */
101
+ export declare const MAX_DURATION_EXCEEDED_REASON_PREFIX = "resource limit exceeded: maxDurationMs";
102
+ /** M5 batch-3 (workstream 2): same contract as {@link MAX_DURATION_EXCEEDED_REASON_PREFIX}, for `DaemonConfig.maxTaskOutputBytes` — see `TaskRunner.pump`'s own per-event byte counting. */
103
+ export declare const MAX_OUTPUT_BYTES_EXCEEDED_REASON_PREFIX = "resource limit exceeded: maxTaskOutputBytes";
104
+ /**
105
+ * M5 batch-3 (workstream 2): default cap (64 MiB) on accumulated
106
+ * (approximate) agent-event output bytes this daemon tolerates for a single
107
+ * task before tearing it down as a resource-limit violation — see
108
+ * `TaskRunnerDeps.maxTaskOutputBytes` and `DaemonConfig.maxTaskOutputBytes`
109
+ * (`create-daemon.ts`) for the full contract, including the
110
+ * zero/negative-is-a-config-error / `Number.POSITIVE_INFINITY`-is-the-real-
111
+ * opt-out pin.
112
+ */
113
+ export declare const DEFAULT_MAX_TASK_OUTPUT_BYTES: number;
114
+ export interface TaskRunnerDeps {
115
+ adapters: RuntimeAdapter[];
116
+ runtimeAllowlist?: string[];
117
+ /**
118
+ * M5 batch-3 (workstream 1): auto-select priority order for `pickAdapter`'s
119
+ * no-explicit-runtime branch — see `DaemonConfig.runtimePreference`'s own
120
+ * doc comment (`create-daemon.ts`) for the full rationale behind this
121
+ * existing at all. Unset defaults to {@link DEFAULT_RUNTIME_PREFERENCE}
122
+ * (pi LAST, deliberately — product decision: pi is this SDK's fallback
123
+ * runtime, not its default). Independent of `runtimeAllowlist` above
124
+ * (which restricts WHICH runtimes are eligible at all) — this only orders
125
+ * the attempt sequence among whatever that allowlist, if set, already let
126
+ * through.
127
+ */
128
+ runtimePreference?: RuntimeId[];
129
+ /** M5: see `DaemonConfig.runtimeEnvironment`'s own doc comment (`create-daemon.ts`) — the per-device, per-runtime env-allowlist override `handleOffer` merges into `buildRuntimeEnv`'s `locallyAllowedNames`. */
130
+ runtimeEnvironment?: Record<string, {
131
+ allow?: string[];
132
+ }>;
133
+ permissionDefaults?: PermissionPolicy;
134
+ workspaceRoot: string;
135
+ deviceId: string;
136
+ send: (envelope: Envelope) => void;
137
+ blobClient: BlobResolver;
138
+ batcherOptions?: ProgressBatcherOptions;
139
+ /**
140
+ * Finding #3 (session/workspace continuity): persists `sessionRef ->
141
+ * workspaceDir` across daemon restarts so a `task.offer` naming a
142
+ * previously-reported `sessionRef` reuses that exact workspace instead of
143
+ * a fresh `workspaceRoot/<taskId>` — see `handleOffer` and
144
+ * `SessionWorkspaceStore`'s own doc comment.
145
+ */
146
+ sessionWorkspaces: SessionWorkspaceStore;
147
+ gitWorkspaceManager?: GitWorkspaceManager;
148
+ gitWorkspaceStore?: GitWorkspaceStore;
149
+ onGitWorkspaceEvent?: (event: {
150
+ taskId: string;
151
+ workspaceId: string;
152
+ phase: GitWorkspacePhase;
153
+ observation?: GitWorkspaceObservation;
154
+ errorCategory?: string;
155
+ }) => void;
156
+ /**
157
+ * M4 Phase 3: this daemon's control-socket identity + the shared registry
158
+ * backing the control socket's own `approvals.list`/`approvals.resolve`
159
+ * methods (`create-daemon.ts` constructs ONE `ApprovalRegistry` and passes
160
+ * the SAME instance here) — see `requestApproval`'s own doc comment for
161
+ * why `TaskRunner` needs a handle on all three. `storeDir`/`productId` are
162
+ * copied verbatim into every task's `TaskContext.approvalChannel`.
163
+ */
164
+ approvalRegistry: ApprovalRegistry;
165
+ storeDir: string;
166
+ productId: string;
167
+ /** Default `requestApproval` timeout — see {@link DEFAULT_APPROVAL_TIMEOUT_MS}. */
168
+ approvalTimeoutMs?: number;
169
+ /**
170
+ * M4 Phase 3 hardening: called by `handleApprove`/`handleReject` instead of
171
+ * failing the task when the referenced approval turns out to be stale
172
+ * (see {@link NoPendingApprovalError}) — an audit-only signal, never
173
+ * gating any task-state decision. `create-daemon.ts` wires this to
174
+ * `DaemonObserver.noteStaleApprovalDecision`, the same way every other
175
+ * locally-observable daemon event reaches the audit log/`tasks --follow`.
176
+ * Optional so a caller that doesn't care about this audit trail (e.g. a
177
+ * minimal test harness) isn't forced to supply one.
178
+ */
179
+ onStaleApprovalDecision?: (taskId: string, decision: ApprovalDecision, reason?: string) => void;
180
+ /**
181
+ * Finding F4 (cross-model adversarial review): operators had no way to
182
+ * ever learn a pending approval's `approvalId` short of reading raw
183
+ * audit-log JSON — `approve`/`reject` require one, but nothing surfaced
184
+ * it. Called synchronously from `dispatchApproval`, BEFORE `deps.send`'s
185
+ * own `task.await_approval` — `create-daemon.ts` wires this to
186
+ * `DaemonObserver.noteApprovalDispatched`, which stashes `approvalId`
187
+ * keyed by `taskId` so the observer's `task.await_approval` handling
188
+ * (triggered by that very `deps.send` call, synchronously, right after
189
+ * this) can attach it to the `awaiting-approval` `DaemonEvent` it emits
190
+ * (see `observer.ts`'s own doc comment). Optional so a minimal test
191
+ * harness that doesn't care about this audit-trail detail isn't forced
192
+ * to supply one — mirrors `onStaleApprovalDecision`'s own contract.
193
+ */
194
+ onApprovalDispatched?: (taskId: string, approvalId: string) => void;
195
+ /** Finding F5(a): overrides {@link DEFAULT_SHUTDOWN_INTERRUPT_TIMEOUT_MS} — see `teardownActiveTask`'s own doc comment. Also the grace window `teardownActiveTask`'s hard-kill escalation (`session.close()`) gets once `session.interrupt()` itself fails to settle in time — see that method's own doc comment for why the same window is reused for both steps. */
196
+ shutdownInterruptTimeoutMs?: number;
197
+ /**
198
+ * M5 batch-3 (workstream 2): overrides {@link DEFAULT_MAX_TASK_OUTPUT_BYTES}
199
+ * — see that constant's own doc comment and `DaemonConfig.maxTaskOutputBytes`
200
+ * (`create-daemon.ts`) for the full contract. Validated (rejecting
201
+ * zero/negative) at the `DaemonConfig` layer, not here — this seam trusts
202
+ * its caller, same as every other optional numeric override on this
203
+ * interface (`shutdownInterruptTimeoutMs`, `approvalTimeoutMs`).
204
+ */
205
+ maxTaskOutputBytes?: number;
206
+ /**
207
+ * M4 (additive-minor, `task.approval_resolved`): the negotiated
208
+ * `conn.ack.capabilities` of the CURRENTLY (or most recently) connected
209
+ * server — read fresh at call time (mirrors `getCursor`/`getToken`'s own
210
+ * "read fresh, not captured once" convention elsewhere in this codebase),
211
+ * since the capability is learned asynchronously, after this `TaskRunner`
212
+ * is already constructed (`create-daemon.ts`'s `start()` builds `deps`
213
+ * before `connection` exists). `create-daemon.ts` wires this to
214
+ * `ConnectionManager.getServerCapabilities`. Optional, and treated as "no
215
+ * capabilities" when absent, so a minimal test harness that doesn't care
216
+ * about this gate isn't forced to supply one — see `sendApprovalResolved`.
217
+ */
218
+ getServerCapabilities?: () => readonly string[];
219
+ /**
220
+ * S3b (L-002): a pre-claim veto on new offers, consulted once per offer
221
+ * immediately after the redelivery-dedup check and ahead of every other
222
+ * admission check in `handleOffer`.
223
+ *
224
+ * It exists for local storage pressure (architecture §12.7.2.1's hard
225
+ * watermark: "停止接收新的普通 task;仍允许 terminal/truth flush、删除、导出、
226
+ * doctor 与恢复操作"). Placing it here rather than deeper in `handleOffer`
227
+ * is what makes that split real: an offer never reaches adapter selection,
228
+ * workspace creation, or `task.claim`, so declining costs nothing on disk —
229
+ * while every path that FINISHES existing work runs through code this seam
230
+ * is not on, and keeps working.
231
+ *
232
+ * Synchronous, matching every other single-purpose callback on this
233
+ * interface. A decline is `retryable` by the guard's own decision — pressure
234
+ * is a property of THIS device at THIS moment, so a dispatcher re-routing
235
+ * the task elsewhere genuinely helps; a guard declining for a reason that
236
+ * will not change says so.
237
+ *
238
+ * Optional and absent by default: with no guard supplied, `handleOffer`
239
+ * behaves exactly as it did before this seam existed.
240
+ */
241
+ admissionGuard?: (offer: {
242
+ readonly taskId: string;
243
+ readonly payload: TaskOfferPayload;
244
+ }) => AdmissionGuardDecision;
245
+ }
246
+ /** See {@link TaskRunnerDeps.admissionGuard}. */
247
+ export type AdmissionGuardDecision = {
248
+ readonly admit: true;
249
+ } | {
250
+ readonly admit: false;
251
+ readonly reason: string;
252
+ readonly retryable: boolean;
253
+ };
254
+ /**
255
+ * Per-connection task orchestration: offer -> (decline | claim -> adapter
256
+ * session -> started) -> seq-ordered progress batches -> complete/fail/
257
+ * cancelled, plus approve/reject/cancel/steer handling.
258
+ *
259
+ * M1 rework (docs/protocol.md §3, §5, §10 — `packages/protocol` is frozen,
260
+ * not editable here): pre-claim rejections (unknown/disallowed runtime,
261
+ * policy exceeding this device's ceiling) now send `task.decline` and never
262
+ * claim at all — `TASK_TRANSITIONS.Offered` gained a direct `-> Failed` edge
263
+ * precisely so this no longer has to claim-then-fail. A successful claim is
264
+ * followed by `task.started` only once the adapter session has actually
265
+ * started (`task.claim` alone no longer implies `Running`). Cancellation
266
+ * reports the explicit `task.cancelled` message instead of the old
267
+ * `task.fail({reason:'cancelled'})` convention.
268
+ */
269
+ export declare class TaskRunner {
270
+ private readonly deps;
271
+ private readonly tasks;
272
+ /**
273
+ * Finding F4 (cancel lost during the offer-processing window): a
274
+ * `task.cancel` for a taskId that hasn't finished `handleOffer` yet (still
275
+ * awaiting adapter detection / instruction resolution / workspace setup /
276
+ * `adapter.start()`) has no `this.tasks` entry to land on — it used to be
277
+ * silently dropped, and the runtime session `handleOffer` was about to
278
+ * register would then run an unsupervised ("zombie") turn nobody asked
279
+ * for anymore. Recording the taskId here lets `handleOffer` consult it at
280
+ * the two points where it can still safely react (see its body): before
281
+ * claiming at all (decline instead of ever starting a session), and right
282
+ * after `adapter.start()` resolves but before this task is registered as
283
+ * active (tear the just-started session down immediately, before its
284
+ * event loop ever pumps a single event). Consumed (deleted) at whichever
285
+ * checkpoint handles it; a cancel for a taskId that's already active,
286
+ * already finished, or never offered at all leaves a harmless entry that
287
+ * nothing will ever consult.
288
+ *
289
+ * M3-B: that last sentence is exactly the unbounded-growth vector this
290
+ * needed closed for long-lived operation — a cancel for a taskId nobody
291
+ * ever claims (unknown, already active, or already finished) leaves a
292
+ * permanent entry with nothing left to consume it. Bounded to
293
+ * `MAX_TRACKED_TASK_IDS` via `setPendingCancelled` below, oldest evicted
294
+ * first: safe because every entry this field's correctness actually
295
+ * depends on is consumed (deleted) by one of `handleOffer`'s two
296
+ * checkpoints within that SAME task's own offer-processing window — one
297
+ * in-flight task's startup latency, nowhere near enough churn for eviction
298
+ * to remove an entry still inside its consuming window before it's read.
299
+ */
300
+ private readonly pendingCancelled;
301
+ /**
302
+ * Finding #5 (Codex counterexample): taskIds currently INSIDE `handleOffer`
303
+ * — from the moment it decides an offer is worth processing until it
304
+ * reaches one of its own resolution points (decline, fail, the
305
+ * checkpoint-2 cancel-teardown, or successful registration into
306
+ * `this.tasks`). Bounded eviction on `pendingCancelled` (below) must never
307
+ * remove an entry for a taskId in this set: doing so is exactly the bug —
308
+ * block task A in `adapter.start()`, deliver A's own `task.cancel` (so
309
+ * `pendingCancelled` gets an entry for A while A is still in-flight),
310
+ * then deliver `MAX_TRACKED_TASK_IDS` more cancels for unrelated taskIds
311
+ * nobody ever offered — under naive oldest-wins eviction, A's entry (the
312
+ * single oldest) gets evicted purely because of unrelated churn, so when
313
+ * `adapter.start()` finally resolves, checkpoint 2 finds no cancel marker
314
+ * and the already-cancelled task starts a real session. See
315
+ * `evictPendingCancelled` below for the fix, and
316
+ * `task-runner-bounded-collections.test.ts` for a test mirroring this
317
+ * exact scenario. Membership here is naturally tiny (bounded by this
318
+ * device's real concurrent-offer-processing count, nowhere near
319
+ * `MAX_TRACKED_TASK_IDS`), so scanning past it to find an evictable entry
320
+ * costs nothing.
321
+ */
322
+ private readonly inFlightOffers;
323
+ /**
324
+ * Finding P2 (Fix 2c): taskIds that have reached a terminal outcome
325
+ * (Complete/Failed/Cancelled) this session — populated in `finish()`.
326
+ * While `ConnectionManager`'s stalled-cursor long-poll re-pull is frozen
327
+ * behind an unrelated failing seq, it can legitimately redeliver an
328
+ * ALREADY-succeeded `task.offer` — the client's own cursor hasn't advanced
329
+ * past it yet (docs/protocol.md §9's "cursor advance timing" rule
330
+ * explicitly relies on redelivered handlers being idempotent for exactly
331
+ * this reason). `handleOffer` must treat a redelivered offer for a taskId
332
+ * that's already active (`this.tasks`) or already finished (this set) as
333
+ * a no-op — never a second `adapter.start()` call, which would orphan the
334
+ * first session.
335
+ *
336
+ * M3-B: unbounded otherwise — a long-lived daemon that's finished many
337
+ * thousands of tasks over its uptime would keep every single taskId
338
+ * forever. Bounded to `MAX_TRACKED_TASK_IDS` via `addFinishedTaskId`
339
+ * below, oldest evicted first. Safe for the redelivery-idempotency
340
+ * invariant above because the stalled-cursor scenario above redelivers
341
+ * this device's own recent backlog for one connection, not an arbitrary
342
+ * point in this daemon's whole history — this device would have to claim
343
+ * and finish `MAX_TRACKED_TASK_IDS` more tasks before a genuinely-still-
344
+ * pending redelivery for an older taskId even arrives, let alone gets
345
+ * processed, for eviction to ever remove an entry that redelivery still
346
+ * needed.
347
+ *
348
+ * Finding #5 (honesty follow-up): unlike `pendingCancelled`, plain
349
+ * oldest-first eviction IS correct here — every entry in this set is
350
+ * already fully resolved (finish() only adds a taskId after it reached a
351
+ * terminal outcome), so there is no "in-flight" entry an eviction could
352
+ * corrupt out from under a running `handleOffer()`. The assumption above
353
+ * is a HEURISTIC bound, not a proof: it holds as long as no single
354
+ * connection's genuinely-still-pending redelivery backlog ever exceeds
355
+ * `MAX_TRACKED_TASK_IDS` finished tasks, which is a real (if distant)
356
+ * possibility for an extremely long-stalled connection, not a
357
+ * mathematical impossibility. Should it ever be violated, the failure
358
+ * mode is strictly milder than `pendingCancelled`'s own pre-fix bug: a
359
+ * redelivered `task.offer` for an evicted, already-finished taskId would
360
+ * re-run `handleOffer` from scratch — at worst a duplicate
361
+ * claim/start/complete for a task that already succeeded once — never a
362
+ * task that should be dead starting a brand-new session against explicit
363
+ * cancellation intent.
364
+ */
365
+ private readonly finishedTaskIds;
366
+ /**
367
+ * M4 Phase 2 (daemon control socket `shutdown` RPC): set once by
368
+ * {@link stopAcceptingOffers}, checked at the very top of `handleOffer` —
369
+ * see that method's own doc comment for why offers must stop being
370
+ * claimed BEFORE currently-active tasks are reported failed in
371
+ * {@link shutdownActiveTasks}, not after. Irreversible for this
372
+ * `TaskRunner` instance; a fresh one is constructed on the daemon's next
373
+ * `start()`.
374
+ */
375
+ private stoppingOffers;
376
+ constructor(deps: TaskRunnerDeps);
377
+ get activeTaskCount(): number;
378
+ /** M5 batch-3 (workstream 2): effective `maxTaskOutputBytes` cap for this daemon — see {@link DEFAULT_MAX_TASK_OUTPUT_BYTES}'s own doc comment. */
379
+ private get maxTaskOutputBytes();
380
+ /**
381
+ * M4 Phase 4 (part B.3, observability): per-active-task queue watermarks
382
+ * for the control socket's `status` result — see
383
+ * `control-protocol.ts`'s `TaskQueueWatermark` doc comment for why this
384
+ * reflects the daemon's own progress-batcher backlog and in-flight
385
+ * approval count, not the adapter's own event-queue depth.
386
+ */
387
+ getQueueWatermarks(): TaskQueueWatermark[];
388
+ /** M4 Phase 2: stop claiming any FUTURE `task.offer` — see `stoppingOffers`'s own doc comment. Idempotent. */
389
+ stopAcceptingOffers(): void;
390
+ /**
391
+ * M4 Phase 2: best-effort shutdown of every currently ACTIVE task, for the
392
+ * control socket's `shutdown` RPC. Mirrors `handleCancel`'s best-effort
393
+ * `session.interrupt()` style (an interrupt failure is swallowed; the
394
+ * terminal message is sent either way) but reports `task.fail` rather than
395
+ * `task.cancelled` — these tasks aren't ending because the SERVER
396
+ * cancelled them, they're ending because this device is shutting down.
397
+ * `retryable: true` throughout: nothing about the task/policy itself was
398
+ * ever at fault, only this device's own availability right now.
399
+ *
400
+ * Snapshots `this.tasks` into a plain array up front rather than iterating
401
+ * the live `Map` — `finish()` (called per task below) deletes from that
402
+ * same map as each shutdown settles, and a snapshot avoids relying on
403
+ * "mutate while iterating" semantics being followed correctly here.
404
+ *
405
+ * Must be called AFTER {@link stopAcceptingOffers} and BEFORE the
406
+ * connection is closed: the caller (`create-daemon.ts`'s
407
+ * `performControlShutdown`) awaits this method to fully settle — every
408
+ * `task.fail` actually enqueued via `deps.send` — before it ever calls
409
+ * `stop()` (which closes the connection). Stopping offers first (rather
410
+ * than closing the connection first) is what prevents a new
411
+ * `task.offer` from being claimed in the window while these are being
412
+ * torn down.
413
+ *
414
+ * This ordering invariant is NOT just about `performControlShutdown`'s
415
+ * own internal statement order — it also depends on nothing ELSE
416
+ * closing the connection first. A real regression (gatekeeper-caught,
417
+ * fixed in `create-daemon.ts`/`bin/commands/start.ts`) had exactly that
418
+ * happen: `start.ts` used to wake up on the EARLIER `shutdown-requested`
419
+ * event (fired synchronously, before this method even calls
420
+ * `session.interrupt()`) and call `daemon.stop()` itself, racing ahead
421
+ * and closing the connection before this method's `task.fail` send ever
422
+ * reached the outbox drain. `start.ts` now waits for the LATER
423
+ * `shutdown-complete` event (emitted only after `performControlShutdown`'s
424
+ * own `stop()` call has already resolved), so it can no longer race
425
+ * ahead of this method — see `daemon-control-socket.test.ts`'s dedicated
426
+ * regression test for the exact scenario.
427
+ */
428
+ shutdownActiveTasks(reason: string): Promise<void>;
429
+ /**
430
+ * M5 batch-3 (workstream 2): the ONE shared per-task teardown sequence —
431
+ * "reuse the exact interrupt/teardown machinery `shutdownActiveTasks`
432
+ * uses, do not invent a second teardown path" applies to BOTH callers:
433
+ * graceful daemon shutdown ({@link shutdownTask}, `retryable: true`) and
434
+ * resource-limit enforcement ({@link failActiveTaskForResourceLimit},
435
+ * `retryable: false`, wall-clock `maxDurationMs` / output-cap
436
+ * `maxTaskOutputBytes`).
437
+ *
438
+ * Finding F5(a) (pre-existing, unchanged by this refactor):
439
+ * `session.interrupt()` is raced against `timeoutMs`
440
+ * ({@link DEFAULT_SHUTDOWN_INTERRUPT_TIMEOUT_MS}, overridable via
441
+ * `TaskRunnerDeps.shutdownInterruptTimeoutMs`) rather than awaited
442
+ * unconditionally, so a hung `interrupt()` (a misbehaving adapter) can
443
+ * never block `task.fail` from being sent at all.
444
+ *
445
+ * New in this batch — hard-kill escalation: when `interrupt()` does NOT
446
+ * settle within that same grace window, `session.close()` is tried next
447
+ * (ALSO raced against `timeoutMs`, for the identical reason: a hung
448
+ * `close()` must not be able to block this forever either — which matters
449
+ * far more here than it used to for the pre-existing graceful-shutdown-only
450
+ * caller, since THAT path is additionally bounded by an outer deadline
451
+ * (`SHUTDOWN_TASK_TEARDOWN_DEADLINE_MS`/`DaemonConfig.shutdownGraceMs`,
452
+ * `create-daemon.ts`), while resource-limit enforcement fires during
453
+ * ordinary operation with no such outer bound watching it). `close()` is
454
+ * every adapter's harder teardown primitive — an actual process-level kill
455
+ * (SIGTERM, or `taskkill /F` on Windows — see e.g.
456
+ * `ClaudeProcessClient.kill()`/`PiRpcClient.kill()`) as opposed to pi's own
457
+ * soft in-band `interrupt()` (an RPC `abort` message that leaves the
458
+ * process alive and resumable) — so escalating to it is the closest thing
459
+ * to a "hard kill" the `Session` interface exposes. `finish()` below calls
460
+ * `session.close()` again regardless (documented idempotent) — this isn't
461
+ * a substitute for that, only an earlier, bounded attempt at actually
462
+ * stopping a stuck runtime before this method gives up and reports failure
463
+ * anyway.
464
+ *
465
+ * Re-checks task identity (`this.tasks.get(...) === active`) immediately
466
+ * before sending `task.fail`: the interrupt/hard-kill race above has await
467
+ * points during which a DIFFERENT path (a racing `task.cancel`/
468
+ * `task.reject`, or the session completing normally on its own) may have
469
+ * already finished this exact task and sent its own terminal message.
470
+ * Sending a SECOND terminal message for an already-finished task would be
471
+ * a genuine protocol bug, not a benign race — mirrors `pump()`'s own
472
+ * identity-check guard for the same class of race.
473
+ */
474
+ private teardownActiveTask;
475
+ /** Graceful-shutdown caller of {@link teardownActiveTask} — see `shutdownActiveTasks`'s own doc comment. `retryable: true`: nothing about the task/policy itself was ever at fault, only this device's own availability right now. */
476
+ private shutdownTask;
477
+ /**
478
+ * M5 batch-3 (workstream 2): shared entry point for both resource-limit
479
+ * enforcers (wall-clock `maxDurationMs` — {@link armMaxDurationTimer} —
480
+ * and output-cap `maxTaskOutputBytes` — see `pump`). Looks the task up
481
+ * FRESH by id and no-ops if it's already gone — finished via any other
482
+ * path (normal completion, cancel, reject, daemon shutdown, or a
483
+ * DIFFERENT resource-limit trip already caught it first). `retryable:
484
+ * false` unconditionally: hitting a configured resource ceiling is never a
485
+ * transient/environmental failure a retry could fix — the same task under
486
+ * the same limits would just hit it again.
487
+ */
488
+ private failActiveTaskForResourceLimit;
489
+ /**
490
+ * M5 batch-3 (workstream 2): daemon-authoritative wall-clock enforcement
491
+ * for `payload.limits.maxDurationMs` — previously accepted and silently
492
+ * ignored (see `handleOffer`'s own doc comment on the `limits.maxTokens`
493
+ * gate for the historical context this superseded). Armed once, at the
494
+ * moment this task is registered as active (`handleOffer`, still inside
495
+ * the synchronous construct -> register -> arm -> pump handoff — arming a
496
+ * timer is synchronous, `setTimeout` never invokes its callback in the
497
+ * same tick, so this doesn't reopen the race that handoff's own doc
498
+ * comment guards against). Cleared unconditionally in `finish()` so every
499
+ * terminal outcome leaves no dangling timer and can never double-fail an
500
+ * already-finished task — the fresh `this.tasks.get` lookup in
501
+ * `failActiveTaskForResourceLimit`/`teardownActiveTask`'s own identity
502
+ * re-check is the second, belt-and-suspenders layer of that same guarantee
503
+ * for the rare case the timer's callback was already scheduled before
504
+ * `finish()` had a chance to clear it.
505
+ */
506
+ private armMaxDurationTimer;
507
+ handleEnvelope(envelope: Envelope): Promise<void>;
508
+ private handleOffer;
509
+ /** Protocol §7: an instruction too large to inline arrives as a `blobRef` — resolve it via the blob client rather than failing closed. */
510
+ private resolveInstruction;
511
+ private pump;
512
+ /**
513
+ * Protocol §7: an `artifact` `AgentEvent` only names a file the runtime
514
+ * wrote into the task workspace (`name`/`contentType` — it carries no
515
+ * content of its own); this reads it from disk and sends the actual
516
+ * `task.artifact` wire message — inline (base64) under 64KB, or via blob
517
+ * upload above that, with a sha-256 `contentHash`.
518
+ *
519
+ * Finding F7/N5: `name` is untrusted (it's whatever the runtime/agent
520
+ * reported — ultimately model-influenced) and used to be `path.join`'d
521
+ * onto `workspaceDir` with no check that the result stayed inside it, so
522
+ * `../../<anything>` (or an absolute `name`, which `path.resolve` accepts
523
+ * verbatim as the whole path) could read and exfiltrate an arbitrary file
524
+ * on the host as a task artifact. A later fix (`resolveArtifactPath`)
525
+ * closed the traversal case by realpath-checking containment, but still
526
+ * returned a path string that was reopened by pathname afterward — a
527
+ * check-then-use TOCTOU race letting the final component be swapped for
528
+ * an out-of-workspace symlink between the check and the read.
529
+ * `openArtifact` now opens the file (with `O_NOFOLLOW`) and verifies the
530
+ * resulting file descriptor directly; this reads from that same handle,
531
+ * never re-opening by pathname. Read/upload failures (including a
532
+ * rejected name or a blocked symlink swap) are also not silent: they
533
+ * surface as a loud `error` `AgentEvent` batched into `task.progress`,
534
+ * and are logged — the task itself can still reach `task.complete`
535
+ * normally, but the dropped artifact is now visible in the event stream
536
+ * rather than swallowed.
537
+ */
538
+ private sendArtifact;
539
+ /** Loud, non-silent artifact failure (finding F7): logged, and folded into this task's own progress stream as an `error` AgentEvent rather than swallowed — the task itself can still complete normally, but the omission is now visible. */
540
+ private reportArtifactError;
541
+ private handleCancel;
542
+ /** M3-B: bounded insert for `pendingCancelled` — see its class-level doc comment and `MAX_TRACKED_TASK_IDS`. Evicts the oldest SAFE-TO-EVICT entry once over cap — see `evictPendingCancelled` (finding #5: not simply "the oldest entry", which could be an in-flight offer's own cancel marker). */
543
+ private setPendingCancelled;
544
+ /**
545
+ * Finding #5 (Codex counterexample — see `inFlightOffers`'s class-level
546
+ * doc comment for the exact scenario): evicts the OLDEST entry that is
547
+ * NOT a taskId currently inside `handleOffer`'s in-flight window, rather
548
+ * than unconditionally the single oldest entry. `Map` iterates in
549
+ * insertion order, so this is "oldest entry that's safe to drop," which
550
+ * only differs from "the oldest entry, period" when that oldest entry
551
+ * happens to belong to a task still being processed — exactly the case
552
+ * that must never be evicted, since `handleOffer`'s own checkpoint 2
553
+ * still needs to observe it.
554
+ *
555
+ * `inFlightOffers` is naturally tiny (bounded by this device's real
556
+ * concurrent-offer-processing count — normally single digits, driven by
557
+ * how many `task.offer`s are simultaneously mid-`adapter.start()` — nowhere
558
+ * near `MAX_TRACKED_TASK_IDS`), so this scan is cheap in practice: it
559
+ * finds a safe entry at or near the front almost always. The only case
560
+ * where NO entry is safe to evict is every single tracked cancel
561
+ * belonging to a currently in-flight offer, which would require this
562
+ * device to have `MAX_TRACKED_TASK_IDS` offers mid-processing
563
+ * simultaneously — implausible, but handled without corrupting anything:
564
+ * this insert is simply allowed to leave the map one entry over cap
565
+ * rather than evict something still needed, and it shrinks back under cap
566
+ * as those in-flight offers resolve and their entries get CONSUMED
567
+ * (deleted by `handleOffer` itself) rather than evicted.
568
+ */
569
+ private evictPendingCancelled;
570
+ /**
571
+ * S0/H-006: an inbound `task.steer` is normally impossible for a runtime
572
+ * that cannot steer — the hub gates it at claim-time capability
573
+ * (`steer_unsupported_runtime`) and never sends the envelope. If one
574
+ * arrives anyway (a forged sender, a pre-gate server, a device whose
575
+ * adapter set changed), the session throws {@link SteerUnsupportedError},
576
+ * which is a PERMANENT property of that runtime, not a transient failure.
577
+ *
578
+ * Rethrowing it would hand it to `ConnectionManager.process()`
579
+ * (`connection-manager.ts` `stalledAtSeq`), which freezes the cursor at
580
+ * that seq and redelivers the same envelope forever — every retry
581
+ * guaranteed to fail identically, and every later envelope for every
582
+ * other task blocked behind it. So this is classified as a
583
+ * non-retryable protocol/authority error: record it and return normally,
584
+ * which acks the envelope and lets the cursor advance. Nothing is
585
+ * swallowed — the steer simply has no reachable success state, and the
586
+ * honest terminal action is to log it and move on.
587
+ *
588
+ * Every OTHER error stays transient and is rethrown untouched, preserving
589
+ * the existing stall/redelivery semantics exactly.
590
+ */
591
+ private handleSteer;
592
+ /**
593
+ * M4 Phase 3: the daemon-side half of the out-of-band approval channel
594
+ * (`types.ts`'s `ApprovalChannel`) — called from `create-daemon.ts`'s
595
+ * `approvals.request` control method, itself called by `byok-approval-mcp`
596
+ * (a claude-spawned MCP-server child process, NOT the adapter/session
597
+ * in-process — see `ApprovalChannel`'s own doc comment for the full why
598
+ * this seam exists at all rather than an `AgentEvent`).
599
+ *
600
+ * Deliberately independent of the dormant `needs_approval` `AgentEvent`
601
+ * path in `pump()` below (~line 611): empirically confirmed (M4 Phase 3
602
+ * STEP 0), claude's own stream-json output emits NOTHING while a
603
+ * permission-prompt-tool call is outstanding — the gap between a `tool_use`
604
+ * frame and its `tool_result` is invisible on the wire, indistinguishable
605
+ * from ordinary model "thinking" latency. `pump()`'s for-await loop over
606
+ * `active.session.events` therefore has no event to ever branch on for
607
+ * this case; the ONLY signal that a task is paused arrives out-of-band,
608
+ * over the control socket, which is exactly what this method is for. The
609
+ * `needs_approval` path stays dormant, untouched, for a hypothetical
610
+ * future adapter whose runtime DOES expose the pause on its own event
611
+ * stream.
612
+ *
613
+ * Sends `task.await_approval` (protocol §5), registers a fresh entry in
614
+ * `deps.approvalRegistry`, and races it against `deps.approvalTimeoutMs`
615
+ * (default {@link DEFAULT_APPROVAL_TIMEOUT_MS}) — an unanswered request
616
+ * force-resolves as a fail-closed rejection once the deadline passes. Both
617
+ * that timeout AND a real decision (server wire `task.approve`/
618
+ * `task.reject` via `handleApprove`/`handleReject` below, OR the local
619
+ * CLI's `approvals.resolve` in `control-server.ts`) converge on the exact
620
+ * same `ApprovalRegistry.resolve()` call — "first resolution wins, the
621
+ * loser is a clean already-resolved no-op" is `ApprovalRegistry`'s own
622
+ * existing guarantee, reused here rather than reimplemented.
623
+ *
624
+ * Fails closed immediately (no registry entry ever created) for a `taskId`
625
+ * that isn't currently active on this device — a stale/unknown/
626
+ * already-finished task has nothing to pause.
627
+ *
628
+ * M4 Phase 4 (fold-in from the P3 gate — concurrent-approval-overwrite
629
+ * fix): claude's parallel tool use can call this MORE THAN ONCE for the
630
+ * SAME task before the first call's approval is resolved — each parallel
631
+ * tool call is its own independent `byok-approval-mcp` `tools/call`
632
+ * request, and the MCP protocol lets several be in flight on one
633
+ * connection at once (see `byok-approval-mcp.ts`'s own doc comment on
634
+ * sharing one control-socket connection across them). Before this fix,
635
+ * `active.pendingApprovalId = approvalId` above was unconditional — a
636
+ * second concurrent call for the same task silently overwrote the first
637
+ * call's id, so only the LATEST request was ever wire-resolvable
638
+ * (`ctx.approvalChannel.resolve`, below, and any server `task.approve`/
639
+ * `task.reject`, both resolve by looking up `active.pendingApprovalId`);
640
+ * every earlier one could only ever time out.
641
+ *
642
+ * Fix: only ONE approval per task is ever actually DISPATCHED (registered
643
+ * in `approvalRegistry` + `task.await_approval` sent + its own timeout
644
+ * window running) at a time — see `dispatchApproval` below. A second
645
+ * (third, ...) concurrent call for a task that already has one dispatched
646
+ * queues (FIFO, `active.approvalQueue`) instead of overwriting anything,
647
+ * and is only dispatched — with its OWN fresh approvalId and its OWN
648
+ * timeout window starting at THAT dispatch, not at this call's arrival —
649
+ * once the currently-dispatched one resolves (see
650
+ * `dispatchNextQueuedApproval`). The MCP callers on the other end are
651
+ * already independently blocked, each awaiting its own `requestApproval`
652
+ * promise, so this added latency for a queued request is transparent to
653
+ * them: nothing here changes what claude itself observes beyond "the
654
+ * answer took a bit longer." Bounded by
655
+ * {@link MAX_PENDING_APPROVALS_PER_TASK}: a request arriving once this
656
+ * task's queue is already full is rejected fail-closed immediately,
657
+ * mirroring the unknown/inactive-taskId case above.
658
+ *
659
+ * C1 (cross-model review, P1): `onOrigin`, if supplied, is invoked
660
+ * synchronously — strictly BEFORE this method's own returned promise
661
+ * resolves — with the `ApprovalOrigin` (`'wire' | 'local'`) the eventual
662
+ * decision actually resolved through (see `ApprovalRegistry.resolve`'s own
663
+ * `origin` parameter). Purely additive/internal: every existing caller
664
+ * (`byok-approval-mcp.ts`, `create-daemon.ts`'s control socket, this file's
665
+ * own tests) omits it and observes exactly the same `{approved, reason}`
666
+ * resolution as before. `pump()`'s dormant `needs_approval` branch is the
667
+ * one caller that supplies it, to decide whether it still needs to
668
+ * forward the decision into `active.session.resolveApproval()` itself —
669
+ * see that branch's own doc comment for why origin can't simply ride
670
+ * along on the resolved value instead.
671
+ */
672
+ requestApproval(taskId: string, summary: string, onOrigin?: (origin: ApprovalOrigin) => void): Promise<{
673
+ approved: boolean;
674
+ reason?: string;
675
+ }>;
676
+ /**
677
+ * Actually dispatch one approval request for `active`'s task: register it
678
+ * in `deps.approvalRegistry`, send its `task.await_approval`, and start its
679
+ * own `deps.approvalTimeoutMs` window — see `requestApproval`'s own doc
680
+ * comment for why this is split out (only ever ONE dispatched per task at
681
+ * a time; everything else queues). Called either immediately
682
+ * (`requestApproval`, nothing else pending for this task) or from
683
+ * `dispatchNextQueuedApproval` once the previously-dispatched request for
684
+ * this same task resolves.
685
+ *
686
+ * C1: `onOrigin` — see `requestApproval`'s own doc comment — is forwarded
687
+ * verbatim from whichever caller dispatched this (directly, or via
688
+ * `QueuedApprovalRequest.onOrigin` once `dispatchNextQueuedApproval` pulls
689
+ * it off the queue) and invoked from the registered `onResolve` callback
690
+ * below, BEFORE `resolve(...)` — so it always fires strictly before this
691
+ * method's own returned promise settles.
692
+ */
693
+ private dispatchApproval;
694
+ /**
695
+ * M4 (additive-minor, `task.approval_resolved` — see `messages.ts`'s own
696
+ * doc comment on `TaskApprovalResolvedPayloadSchema` for the full wire
697
+ * rationale): report a LOCALLY-resolved approval to the server
698
+ * immediately, gated on the negotiated `approval_resolved` capability
699
+ * (`deps.getServerCapabilities` — an old server that never advertises it
700
+ * never receives this message; the daemon then falls back to the
701
+ * pre-existing implicit-resume inference, unconditionally, exactly as
702
+ * before this message existed — the N/N-1 compatibility path).
703
+ *
704
+ * Ordering (verified by `task-runner-approval-resolved.test.ts`): this is
705
+ * called, and therefore `deps.send` pushes this envelope onto the outbox,
706
+ * SYNCHRONOUSLY from the `onResolve` callback above — strictly BEFORE the
707
+ * `resolve(...)` call on the very next line that unblocks whatever was
708
+ * awaiting `requestApproval()`'s promise (`byok-approval-mcp`, ultimately
709
+ * the paused runtime turn). Any further progress from the resumed session
710
+ * can only be produced AFTER that unblock, which needs at least one more
711
+ * microtask/event-loop turn — so `task.approval_resolved` is always queued
712
+ * ahead of it with no extra bookkeeping needed here.
713
+ */
714
+ private sendApprovalResolved;
715
+ /**
716
+ * FIFO: once a task's currently-dispatched approval resolves (real
717
+ * decision or timeout), dispatch the next queued request for that SAME
718
+ * task, if any — see `requestApproval`'s own doc comment. A no-op when
719
+ * nothing is queued.
720
+ */
721
+ private dispatchNextQueuedApproval;
722
+ /**
723
+ * Acceptance finding 1 (dormant `needs_approval` branch bypassing the
724
+ * approval registry): resolves whatever `deps.approvalRegistry` entry
725
+ * `pendingId` names (if any — a caller passes `undefined` when nothing was
726
+ * pending to begin with), tagged `'wire'` — the same origin
727
+ * `ctx.approvalChannel.resolve` already uses for a server-sent
728
+ * `task.approve`/`task.reject` (see `ApprovalOrigin`'s own doc comment:
729
+ * `'wire'` is what keeps `sendApprovalResolved` from echoing
730
+ * `task.approval_resolved` back to a server that already knows this
731
+ * decision, since it sent it).
732
+ *
733
+ * Needed because `active.session.resolveApproval()` is adapter-defined:
734
+ * - A channel-based session (claude) already resolves this exact registry
735
+ * entry itself, via `ctx.approvalChannel.resolve` (`handleOffer` above)
736
+ * — by the time this runs, that entry is already gone, so this call
737
+ * throws `ApprovalNotFoundError`, swallowed below: the same
738
+ * first-resolution-wins race every other caller of `.resolve()` in this
739
+ * file already treats as benign (see e.g. `dispatchApproval`'s own
740
+ * timeout branch).
741
+ * - A stream-based session (the dormant `needs_approval` path in `pump()`,
742
+ * now dispatched via `requestApproval` exactly like a real out-of-band
743
+ * approval) resolves ONLY through its own in-process `resolveApproval()`
744
+ * call — nothing else ever touches `deps.approvalRegistry` for it, so
745
+ * without this call its registry entry and `active.pendingApprovalId`
746
+ * would otherwise linger until this approval's own timeout (or the task
747
+ * finishing) instead of clearing the moment the decision actually lands
748
+ * — which would leave any OTHER approval queued behind it
749
+ * (`active.approvalQueue`) stuck waiting for that same timeout.
750
+ *
751
+ * Called from `handleApprove`/`handleReject` AFTER `active.session
752
+ * .resolveApproval()` has already been given the decision — never before,
753
+ * since for the channel-based case that call is what actually resolves
754
+ * the registry entry `pendingId` names.
755
+ *
756
+ * CRITICAL follow-up to finding 1 above: `pendingId` is a required
757
+ * parameter — deliberately NOT read from `active.pendingApprovalId` inside
758
+ * this method (indeed, this method no longer takes `active` at all). For a
759
+ * channel-based session (claude), the `await active.session
760
+ * .resolveApproval()` in `handleApprove`/`handleReject` BELOW THIS CALL is
761
+ * exactly what synchronously drives `ctx.approvalChannel.resolve` ->
762
+ * `approvalRegistry.resolve(A)` -> A's own `onResolve`
763
+ * (`dispatchApproval` above) -> `dispatchNextQueuedApproval` — and that
764
+ * last step, still inside the SAME synchronous call and therefore still
765
+ * strictly BEFORE the caller's own `await` settles, dispatches the next
766
+ * queued approval (B) and reassigns `active.pendingApprovalId = B`. A
767
+ * caller that read `active.pendingApprovalId` only AFTER that `await`
768
+ * returned (as this method itself used to, before it took `pendingId` as a
769
+ * parameter) would therefore observe B, not A — resolving B (silently,
770
+ * with A's decision: an auto-approve or a force-reject of an approval no
771
+ * one ever actually decided) instead of the already-gone entry for A this
772
+ * call is actually meant to (harmlessly) no-op against. Callers now
773
+ * capture the target id BEFORE that await (`handleApprove`/`handleReject`
774
+ * below) so this can only ever be asked to resolve the id it was meant to
775
+ * all along. See `task-runner-approval.test.ts`'s channel-routing
776
+ * regression test for this exact interleaving reproduced end to end.
777
+ */
778
+ private clearPendingApproval;
779
+ /**
780
+ * Protocol §5 approval flow: the server's own state already moved
781
+ * `AwaitApproval -> Running` before this best-effort notification arrives
782
+ * (§4) — resuming the session is what makes `task.progress` continue.
783
+ *
784
+ * M4 Phase 3 hardening (orchestrator-directed fix): a wire `task.approve`
785
+ * can legitimately arrive AFTER a different, faster path (a racing local
786
+ * `approvals.resolve` over the control socket, or this exact message
787
+ * redelivered) already resolved the SAME approval — `ApprovalRegistry`'s
788
+ * own "first resolution wins" guarantee means `session.resolveApproval()`
789
+ * throws {@link NoPendingApprovalError} for that loser, not because
790
+ * anything is actually wrong. Before this fix, ANY thrown error here
791
+ * (stale or genuine) failed the whole task — for the stale case that
792
+ * meant a task the winning path had ALREADY correctly resumed (and which
793
+ * may go on to complete normally) got marked `Failed` anyway, purely
794
+ * because a second, now-meaningless notification arrived late. Stale is
795
+ * now an audit-only no-op; a genuine failure (the session itself
796
+ * couldn't resume for some real reason) still fails the task exactly as
797
+ * before.
798
+ */
799
+ private handleApprove;
800
+ /**
801
+ * Protocol §5 approval flow: the server's own state already moved
802
+ * `AwaitApproval -> Failed` before this best-effort notification arrives
803
+ * (§4) — the daemon's job is just to stop the session and prove it via
804
+ * `task.fail`.
805
+ *
806
+ * M4 Phase 3 hardening (orchestrator-directed fix): same race as
807
+ * `handleApprove` above, but the pre-fix bug here was worse — this method
808
+ * unconditionally interrupted the session and sent `task.fail` regardless
809
+ * of whether `resolveApproval` even threw, so a stale/late wire
810
+ * `task.reject` (the local CLI, or a racing wire approve, already
811
+ * resolved this exact approval a different way) would tear down and fail
812
+ * a task that was already correctly approved and possibly still running
813
+ * fine. Now: a {@link NoPendingApprovalError} short-circuits to an
814
+ * audit-only no-op BEFORE the interrupt/fail/finish sequence — nothing
815
+ * about this task's state is touched. Any OTHER outcome (success, or a
816
+ * genuine non-staleness error) falls through to the existing
817
+ * interrupt+`task.fail`+finish sequence unchanged: the server's own
818
+ * record already moved `AwaitApproval -> Failed` for a REAL reject
819
+ * (§4's "server state is authoritative on its own action" rule), so the
820
+ * daemon must still conform to that regardless of whether telling the
821
+ * session about it succeeded.
822
+ */
823
+ private handleReject;
824
+ /** Pre-claim, fail-closed rejection (protocol §3.2) — never claims first. */
825
+ private decline;
826
+ private fail;
827
+ private observeGit;
828
+ private updateGitPhaseBestEffort;
829
+ private finish;
830
+ /** M3-B: bounded insert for `finishedTaskIds` — see its class-level doc comment and `MAX_TRACKED_TASK_IDS`. Evicts the oldest (first-inserted) entry once over cap, same idiom as `ConnectionHub.checkAndRecordDuplicate` (packages/server/src/hub.ts). */
831
+ private addFinishedTaskId;
832
+ /** `reuseDir`, when set (a known sessionRef's recorded workspace), is used verbatim instead of a fresh `workspaceRoot/<taskId>` directory — `mkdir recursive` is idempotent either way, so ensuring-exists is safe to do unconditionally. */
833
+ private resolveWorkspaceDir;
834
+ /**
835
+ * M5 batch-3 (workstream 1): selects which adapter runs this offer, now
836
+ * gated on both PRESENCE (`adapter.detect()`, as before) and CAPABILITY
837
+ * (`adapterSupportsMode` — can this adapter even express `policyMode`?
838
+ * new in this batch) — pre-claim, in both the explicit-runtime and
839
+ * auto-select branches.
840
+ *
841
+ * Explicit-runtime branch (`requestedRuntime` set): semantics otherwise
842
+ * unchanged from before this batch — allowlist and known-adapter checks
843
+ * first, THEN the new capability check, THEN presence. A capability
844
+ * mismatch here is a permanent characteristic of naming THIS runtime with
845
+ * THIS policy (e.g. pi never supports `confirm`, on any device, by
846
+ * design — `pi/permission-mapping.ts`) — `retryable: false`, the same
847
+ * class as "not in allowlist"/"unknown runtime" above it, since retrying
848
+ * this exact (runtime, mode) pair anywhere changes nothing.
849
+ *
850
+ * Auto-select branch (`requestedRuntime` absent): candidates are ordered
851
+ * by `runtimePreference` (default {@link DEFAULT_RUNTIME_PREFERENCE}) —
852
+ * see `orderByPreference` — then walked in that order; a candidate that
853
+ * can't express `policyMode` is skipped (not detected at all — capability
854
+ * is checked first, cheaper than a real subprocess probe) and the walk
855
+ * continues down the preference order, exactly as "skip non-supporting
856
+ * adapters and continue down the order" describes. If NOTHING eligible
857
+ * supports the mode, `retryable: true` — unlike the explicit branch, this
858
+ * is device-specific (which runtimes happen to be installed here), so a
859
+ * different device's installed runtime set might satisfy it.
860
+ */
861
+ private pickAdapter;
862
+ }