@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,345 @@
1
+ import { type AgentEvent, type BlobRef, type Envelope, type RuntimeInfo, type TaskState } from '@byok-sdk/protocol';
2
+ import type { ConnectionState } from './ws-transport';
3
+ /**
4
+ * M3-2a: local observability for the daemon — the seam a CLI (M3-2b) drives a
5
+ * live task feed, a task list, and approve/reject/unpair from, all LOCALLY
6
+ * against a running daemon (no SaaS-side polling required).
7
+ *
8
+ * Sourcing, without editing `task-runner.ts`: `TaskRunner` never calls
9
+ * anything in this file directly and has no notion it exists. Every
10
+ * task-lifecycle transition it makes is already observable from OUTSIDE the
11
+ * class through two seams `create-daemon.ts` itself owns and constructs:
12
+ *
13
+ * - `TaskRunnerDeps.send` (the callback `TaskRunner` calls for every
14
+ * `task.claim` / `task.started` / `task.progress` / `task.artifact` /
15
+ * `task.await_approval` / `task.complete` / `task.fail` / `task.decline` /
16
+ * `task.cancelled` it ever emits) — `create-daemon.ts` already builds this
17
+ * closure itself (`send: (envelope) => connection?.send(envelope)`); this
18
+ * module just gets a chance to look at the same envelope before it goes
19
+ * out. See `handleOutboundEnvelope`.
20
+ * - `ConnectionManagerOptions.onEnvelope`/`onStateChange` — likewise already
21
+ * `create-daemon.ts`'s own closures (`(envelope) =>
22
+ * runner?.handleEnvelope(envelope)`, `(state) => { connectionState = state;
23
+ * }`). `onEnvelope` additionally exposes the raw INBOUND `task.offer` — the
24
+ * one event with no corresponding outbound envelope of its own — see
25
+ * `handleInboundEnvelope`.
26
+ *
27
+ * Neither seam required adding anything to `TaskRunnerDeps`/`TaskRunner`
28
+ * itself: both were already plain functions `create-daemon.ts` constructs,
29
+ * so wrapping them (call this module first, then the real behavior,
30
+ * unchanged) is the entire integration.
31
+ */
32
+ /** Every local event kind this daemon can emit — see {@link DaemonEvent}. */
33
+ export type DaemonEventKind = DaemonEvent['kind'];
34
+ export type DaemonEvent = {
35
+ kind: 'offered';
36
+ ts: string;
37
+ taskId: string;
38
+ runtime?: string;
39
+ } | {
40
+ kind: 'claimed';
41
+ ts: string;
42
+ taskId: string;
43
+ claimedRuntime?: string;
44
+ } | {
45
+ kind: 'started';
46
+ ts: string;
47
+ taskId: string;
48
+ }
49
+ /** One per normalized `AgentEvent` (not one per `task.progress` batch) — matches "a live task feed" better than re-exposing the wire's own batching. */
50
+ | {
51
+ kind: 'progress';
52
+ ts: string;
53
+ taskId: string;
54
+ event: AgentEvent;
55
+ } | {
56
+ kind: 'artifact';
57
+ ts: string;
58
+ taskId: string;
59
+ name: string;
60
+ contentType: string;
61
+ inline?: string;
62
+ blobRef?: BlobRef;
63
+ }
64
+ /**
65
+ * Finding F4: `approvalId` is populated whenever `TaskRunner` dispatched
66
+ * this approval THROUGH the normal `requestApproval`/`onApprovalDispatched`
67
+ * path (see `noteApprovalDispatched`) — `undefined` only for a
68
+ * hypothetical caller of `handleOutboundEnvelope` that never went through
69
+ * that hook (there is no other producer of `task.await_approval` today).
70
+ * This is the one piece of information an operator needs to actually
71
+ * call `approve`/`reject` (or the `approvals` CLI command) against this
72
+ * specific pending decision — before this fix, nothing surfaced it
73
+ * outside the control socket's `approvals.list`/`approvals.request`
74
+ * internals.
75
+ */
76
+ | {
77
+ kind: 'awaiting-approval';
78
+ ts: string;
79
+ taskId: string;
80
+ summary: string;
81
+ approvalId?: string;
82
+ } | {
83
+ kind: 'completed';
84
+ ts: string;
85
+ taskId: string;
86
+ summary: string;
87
+ sessionRef: string;
88
+ }
89
+ /**
90
+ * Covers BOTH a post-claim `task.fail` and a pre-claim `task.decline` —
91
+ * `preClaim` distinguishes the two. This mirrors the protocol's own
92
+ * `Offered -> Failed` convention (docs/protocol.md "Declined vs. Failed";
93
+ * `TASK_TRANSITIONS`, `@byok-sdk/protocol`): a decline and a failure are the
94
+ * same outcome from a dispatcher's point of view, so this module doesn't
95
+ * invent a parallel `declined` kind the wire model itself doesn't have.
96
+ */
97
+ | {
98
+ kind: 'failed';
99
+ ts: string;
100
+ taskId: string;
101
+ reason: string;
102
+ retryable: boolean;
103
+ preClaim?: boolean;
104
+ } | {
105
+ kind: 'cancelled';
106
+ ts: string;
107
+ taskId: string;
108
+ reason?: string;
109
+ } | {
110
+ kind: 'connection';
111
+ ts: string;
112
+ state: ConnectionState;
113
+ } | {
114
+ kind: 'paired';
115
+ ts: string;
116
+ deviceId: string;
117
+ } | {
118
+ kind: 'unpaired';
119
+ ts: string;
120
+ } | {
121
+ kind: 'runtimes-detected';
122
+ ts: string;
123
+ runtimes: RuntimeInfo[];
124
+ }
125
+ /** M4 Phase 2: the control socket's `shutdown` RPC was invoked — emitted once, before this daemon starts tearing itself down, so it lands in the audit log via the exact same subscribe->append plumbing every other event already uses (see `bin/commands/start.ts`). Informational only — do NOT gate any teardown decision on this event; see `shutdown-complete`. */
126
+ | {
127
+ kind: 'shutdown-requested';
128
+ ts: string;
129
+ reason: string;
130
+ }
131
+ /**
132
+ * M4 Phase 2: emitted once, AFTER the control-socket-driven shutdown
133
+ * sequence has fully finished — active tasks reported failed over the
134
+ * (at that point still-open) connection, then the connection/control
135
+ * socket actually closed (see `create-daemon.ts`'s `performControlShutdown`,
136
+ * which calls this last). This is the event `bin/commands/start.ts` must
137
+ * wait for before treating the daemon as done: reacting to
138
+ * `shutdown-requested` instead would race `daemon.stop()` against the
139
+ * still-in-flight `task.fail` send and silently drop it (confirmed via a
140
+ * real regression — see `daemon-control-socket.test.ts`).
141
+ *
142
+ * Finding F5(b): `undeliveredOutboxCount`, when defined, is the honest
143
+ * post-drain read of `ConnectionManager.outboxLength()` — 0 means the
144
+ * bounded outbox drain (see `ConnectionManager.stop`) genuinely finished
145
+ * before the connection closed; a positive number means that many
146
+ * envelopes (almost certainly including a `task.fail`
147
+ * `shutdownActiveTasks` just enqueued) never actually left the outbox.
148
+ * `undefined` only for a hypothetical caller of `noteShutdownComplete`
149
+ * that didn't pass one — `create-daemon.ts`'s own `performControlShutdown`
150
+ * (the only real producer of this event) always does.
151
+ */
152
+ | {
153
+ kind: 'shutdown-complete';
154
+ ts: string;
155
+ reason: string;
156
+ undeliveredOutboxCount?: number;
157
+ }
158
+ /**
159
+ * M4 Phase 3 hardening: a wire `task.approve`/`task.reject` (or this
160
+ * device's own redelivered copy of one) arrived for an out-of-band
161
+ * approval a DIFFERENT, faster path (a racing local `approvals.resolve`
162
+ * over the control socket, or this exact decision arriving twice) had
163
+ * already resolved — `TaskRunner.handleApprove`/`handleReject`
164
+ * (`task-runner.ts`) emit this instead of failing the task a second time.
165
+ * Audit-only: never gates any teardown/task-state decision, purely a
166
+ * record that a stale message was seen and correctly ignored — see
167
+ * `NoPendingApprovalError`'s own doc comment (`task-runner.ts`) for the
168
+ * full race this closes.
169
+ */
170
+ | {
171
+ kind: 'stale-approval-decision';
172
+ ts: string;
173
+ taskId: string;
174
+ decision: 'approve' | 'reject';
175
+ reason?: string;
176
+ } | {
177
+ kind: 'git-workspace';
178
+ ts: string;
179
+ taskId: string;
180
+ workspaceId: string;
181
+ phase: string;
182
+ headChanged?: boolean;
183
+ commitsSinceBaseline?: number;
184
+ dirty?: {
185
+ staged: number;
186
+ unstaged: number;
187
+ untracked: number;
188
+ conflicted: number;
189
+ };
190
+ errorCategory?: string;
191
+ };
192
+ export type DaemonEventListener = (event: DaemonEvent) => void;
193
+ export type Unsubscribe = () => void;
194
+ /** `daemon.tasks()`'s per-task view — current local state + whatever summary/outcome was last reported for it. */
195
+ export interface DaemonTaskInfo {
196
+ taskId: string;
197
+ state: TaskState;
198
+ runtime?: string;
199
+ /** Actual runtime selected for this task, as reported by the outbound `task.claim` payload. Distinct from `runtime`, which remains the requested offer runtime. */
200
+ claimedRuntime?: string;
201
+ /** Last known human-readable text for this task's current state: an await-approval summary, a complete summary, or a fail/cancel reason — whichever was most recently reported. */
202
+ summary?: string;
203
+ /** Only set once this task has actually reached `task.complete`. */
204
+ sessionRef?: string;
205
+ /** `true` only when `state === 'Failed'` resulted from a pre-claim `task.decline` rather than a post-claim `task.fail` — see the `DaemonEvent` `failed` variant's doc comment. */
206
+ declined?: boolean;
207
+ updatedAt: string;
208
+ }
209
+ /**
210
+ * M3-B parity (see `task-runner.ts`'s `MAX_TRACKED_TASK_IDS` doc comment,
211
+ * same rationale): this daemon is meant to run as a long-lived background
212
+ * service, so a per-task registry that only ever grows is a slow memory
213
+ * leak. Only TERMINAL entries (`Complete`/`Failed`/`Cancelled`) are ever
214
+ * evicted, oldest first — active entries are never removed here, the same
215
+ * way `TaskRunner.tasks` itself is bounded by real concurrency rather than
216
+ * an explicit cap.
217
+ */
218
+ export declare const MAX_TRACKED_TASKS = 2000;
219
+ /**
220
+ * Owns both halves of the local observability surface: the pub/sub
221
+ * (`subscribe`/`emit`) and the derived per-task registry (`tasks`). A plain
222
+ * `Set<listener>` with `subscribe` returning its own removal closure — not
223
+ * `node:events`' `EventEmitter` — keeps unsubscribe trivially leak-free
224
+ * (delete-by-reference, no string event names, no listener-count footguns)
225
+ * and keeps `DaemonEvent` a single typed union instead of a per-event-name
226
+ * overload table.
227
+ */
228
+ export declare class DaemonObserver {
229
+ private readonly listeners;
230
+ private readonly taskInfo;
231
+ /**
232
+ * Finding F4: `approvalId` for a taskId's NEXT `task.await_approval`,
233
+ * stashed by `noteApprovalDispatched` and consumed (read + deleted) the
234
+ * moment `handleOutboundEnvelope`'s `task.await_approval` case actually
235
+ * emits the corresponding `awaiting-approval` event — see that hook's own
236
+ * doc comment for why this is always populated first (both calls happen
237
+ * synchronously, in that order, from `TaskRunner.dispatchApproval`).
238
+ * Read-and-delete keeps this self-bounding: an entry never outlives the
239
+ * one event it was stashed for, so this can never leak across a
240
+ * long-lived daemon's lifetime the way an evict-on-schedule cache would
241
+ * need explicit bookkeeping to avoid.
242
+ */
243
+ private readonly pendingApprovalIdByTask;
244
+ subscribe(listener: DaemonEventListener): Unsubscribe;
245
+ /** Current locally-known tasks, in first-seen order. */
246
+ tasks(): DaemonTaskInfo[];
247
+ /**
248
+ * Feed a raw INBOUND (server -> daemon) envelope. Deliberately narrow: only
249
+ * `task.offer` produces a local event here — every other inbound type
250
+ * (`task.cancel`/`task.steer`/`task.approve`/`task.reject`) is a
251
+ * best-effort notification whose OWN observable effect already surfaces
252
+ * through the daemon's outbound envelopes (`task.cancelled`, `task.progress`
253
+ * resuming, `task.fail`, ...) — see `handleOutboundEnvelope`, which is
254
+ * where those are actually reported from.
255
+ */
256
+ handleInboundEnvelope(envelope: Envelope): void;
257
+ /**
258
+ * Feed a raw OUTBOUND (daemon -> server) envelope — this is where every
259
+ * task-lifecycle local event actually comes from: `TaskRunner` already
260
+ * calls `deps.send(...)` for each of these at exactly the moment its own
261
+ * state machine decides the transition happened.
262
+ */
263
+ handleOutboundEnvelope(envelope: Envelope): void;
264
+ noteConnectionState(state: ConnectionState): void;
265
+ notePaired(deviceId: string): void;
266
+ noteUnpaired(): void;
267
+ noteRuntimesDetected(runtimes: RuntimeInfo[]): void;
268
+ /** M4 Phase 2: see the `shutdown-requested` `DaemonEvent` variant's own doc comment. */
269
+ noteShutdownRequested(reason: string): void;
270
+ /** M4 Phase 2: see the `shutdown-complete` `DaemonEvent` variant's own doc comment (finding F5(b): `undeliveredOutboxCount`). */
271
+ noteShutdownComplete(reason: string, undeliveredOutboxCount?: number): void;
272
+ /** M4 Phase 3 hardening: see the `stale-approval-decision` `DaemonEvent` variant's own doc comment. */
273
+ noteStaleApprovalDecision(taskId: string, decision: 'approve' | 'reject', reason?: string): void;
274
+ noteGitWorkspace(event: {
275
+ taskId: string;
276
+ workspaceId: string;
277
+ phase: string;
278
+ headChanged?: boolean;
279
+ commitsSinceBaseline?: number;
280
+ dirty?: {
281
+ staged: number;
282
+ unstaged: number;
283
+ untracked: number;
284
+ conflicted: number;
285
+ };
286
+ errorCategory?: string;
287
+ }): void;
288
+ /**
289
+ * Finding F4: wired from `TaskRunnerDeps.onApprovalDispatched`, called
290
+ * synchronously by `TaskRunner.dispatchApproval` BEFORE its own
291
+ * `deps.send(createEnvelope('task.await_approval', ...))` — stashes
292
+ * `approvalId` so `handleOutboundEnvelope`'s `task.await_approval` case
293
+ * (triggered by that very `send` call) can attach it to the
294
+ * `awaiting-approval` `DaemonEvent` it emits. Never emits anything
295
+ * itself — purely a handoff, same as `upsertTask`'s bookkeeping role for
296
+ * other events.
297
+ */
298
+ noteApprovalDispatched(taskId: string, approvalId: string): void;
299
+ private upsertTask;
300
+ /**
301
+ * Finding P2/#11 (observer half): the "no terminal entry to evict" branch
302
+ * used to just give up and leave the registry unbounded for as long as
303
+ * every tracked task stayed nonterminal (many concurrent/stuck offers that
304
+ * never reach Complete/Failed/Cancelled — the exact case this cap exists
305
+ * for, since a normal quickly-resolving workload always has terminal
306
+ * entries to evict well before this). Falling back to "never evict" here
307
+ * defeats the whole point of `MAX_TRACKED_TASKS`. Fix: fall back to
308
+ * evicting the OLDEST entry regardless of state (same insertion-order
309
+ * idiom as everywhere else this file/`task-runner.ts` bound a collection),
310
+ * logged since it's a real, if rare, observability loss — this registry is
311
+ * a local READ-MODEL only (see the module doc comment), never consulted by
312
+ * `TaskRunner`'s own state machine, so evicting a still-active task's
313
+ * entry here can't affect that task's actual execution — it only means
314
+ * `tasks()`/a CLI's task list can no longer show it until it reports
315
+ * another transition (which re-inserts it via `upsertTask`).
316
+ */
317
+ private evictIfNeeded;
318
+ /**
319
+ * Listener errors are caught here so a broken subscriber (e.g. a CLI's
320
+ * rendering bug) can never propagate back into the real send/onEnvelope
321
+ * path this module wraps — see this file's own module doc comment.
322
+ *
323
+ * Finding #6: `DaemonEventListener` is typed `(event: DaemonEvent) =>
324
+ * void`, but TypeScript's structural typing does not stop a caller from
325
+ * subscribing an `async` function (or anything else returning a promise)
326
+ * where a void-returning callback is expected — nothing here ever
327
+ * validates that at runtime. The `try`/`catch` below only ever catches a
328
+ * SYNCHRONOUS throw; an async listener doesn't throw synchronously, it
329
+ * RETURNS an already-rejected (or later-rejecting) promise, which sails
330
+ * straight past that catch. Left unhandled, that promise's rejection
331
+ * becomes an `unhandledRejection` on the process — which, depending on
332
+ * the host's Node version/flags, can crash the entire daemon over one
333
+ * subscriber's bug (e.g. a CLI's own progress renderer awaiting something
334
+ * that throws). Fix: treat the listener's return value as "possibly a
335
+ * promise" regardless of its declared type, and attach a `.catch` so a
336
+ * later rejection is caught and logged exactly like a synchronous throw,
337
+ * never left to become unhandled. This call stays synchronous itself
338
+ * (never `await`s a listener) — `emit` is invoked from the send/cursor
339
+ * path (`handleOutboundEnvelope`/`handleInboundEnvelope`, wired in
340
+ * directly from `create-daemon.ts`'s `send`/`onEnvelope` closures) and
341
+ * must never make a subscriber's own async work a precondition for
342
+ * cursor advancement or the next envelope being processed.
343
+ */
344
+ private emit;
345
+ }
@@ -0,0 +1,87 @@
1
+ import { promises as fs } from 'node:fs';
2
+ export type OperationalHealthState = 'healthy' | 'degraded' | 'recovering';
3
+ export type OperationalFailureSource = 'reconnect' | 'upload' | 'maintenance' | 'lifecycle';
4
+ export interface OperationalCrashRecord {
5
+ detectedAt: string;
6
+ previousRunStartedAt: string;
7
+ }
8
+ export declare const OPERATIONAL_HEALTH_FILENAME = "operational-health.json";
9
+ export declare const MAX_OPERATIONAL_HEALTH_FILE_BYTES: number;
10
+ export interface OpenOperationalHealthFile {
11
+ handle: Awaited<ReturnType<typeof fs.open>>;
12
+ stat: import('node:fs').BigIntStats;
13
+ }
14
+ /**
15
+ * Bind the pathname to a regular-file handle before any bytes are read.
16
+ * POSIX additionally gets O_NOFOLLOW/O_NONBLOCK. On Windows those flags are
17
+ * unavailable, so the pre-open lstat plus handle/path identity checks are the
18
+ * fail-closed authority: a static reparse point is rejected before open, and
19
+ * a raced replacement cannot match the already-observed inode/file state.
20
+ */
21
+ export declare function openOperationalHealthFile(storeDir: string): Promise<OpenOperationalHealthFile | undefined>;
22
+ export type OperationalHealthFileInspection = {
23
+ status: 'missing';
24
+ } | {
25
+ status: 'valid';
26
+ sizeBytes: number;
27
+ state: OperationalHealthState;
28
+ failureCount: number;
29
+ crashCount: number;
30
+ currentRunStartedAt?: string;
31
+ lastCrashAt?: string;
32
+ } | {
33
+ status: 'corrupt';
34
+ sizeBytes: number;
35
+ reason: string;
36
+ } | {
37
+ status: 'unavailable';
38
+ sizeBytes?: number;
39
+ reason: string;
40
+ };
41
+ export type OperationalHealthSnapshot = {
42
+ availability: 'available';
43
+ state: OperationalHealthState;
44
+ failureCount: number;
45
+ windowMs: number;
46
+ failureThreshold: number;
47
+ crashCount: number;
48
+ lastCrashAt?: string;
49
+ currentRunStartedAt?: string;
50
+ } | {
51
+ availability: 'unavailable';
52
+ reason: string;
53
+ };
54
+ export interface OperationalHealthOptions {
55
+ windowMs?: number;
56
+ failureThreshold?: number;
57
+ maxFailures?: number;
58
+ maxCrashes?: number;
59
+ clock?: () => Date;
60
+ runId?: () => string;
61
+ pid?: number;
62
+ }
63
+ export declare class OperationalHealthTracker {
64
+ #private;
65
+ constructor(storeDir: string, options?: OperationalHealthOptions);
66
+ startRun(): Promise<OperationalHealthSnapshot>;
67
+ recordFailure(source: OperationalFailureSource): Promise<void>;
68
+ recordSuccess(_source: OperationalFailureSource): Promise<void>;
69
+ markCleanStop(): Promise<void>;
70
+ snapshot(): OperationalHealthSnapshot;
71
+ }
72
+ /**
73
+ * Read-only S7-b inspection seam. Unlike `OperationalHealthTracker.startRun`,
74
+ * this never writes a run marker, prunes history, or repairs malformed bytes;
75
+ * doctor can therefore report on an offline daemon without changing the very
76
+ * evidence it is inspecting.
77
+ */
78
+ declare function inspectOperationalHealthHandle(handle: Awaited<ReturnType<typeof fs.open>>, expected?: import('node:fs').BigIntStats): Promise<Exclude<OperationalHealthFileInspection, {
79
+ status: 'missing';
80
+ }>>;
81
+ /**
82
+ * Open a pathname-bound regular-file handle before inspecting. POSIX uses
83
+ * no-follow/non-blocking flags; Windows uses the pre-open and post-open
84
+ * pathname/handle identity checks in openOperationalHealthFile.
85
+ */
86
+ export declare function inspectOperationalHealthFile(storeDir: string): Promise<OperationalHealthFileInspection>;
87
+ export { inspectOperationalHealthHandle };
@@ -0,0 +1,16 @@
1
+ import type { PermissionPolicy } from '@byok-sdk/protocol';
2
+ export interface PolicyDecision {
3
+ ok: boolean;
4
+ /** The merged, effective policy. Only meaningful when `ok` is true. */
5
+ policy: PermissionPolicy;
6
+ /** Present when `ok` is false. */
7
+ reason?: string;
8
+ }
9
+ /**
10
+ * Merge an offered task policy against the daemon operator's configured
11
+ * ceiling, fail-closed. This is a runtime-agnostic safety gate independent
12
+ * of whether the eventually-chosen adapter can even express the result — see
13
+ * each adapter's own permission mapping for that second, adapter-specific
14
+ * gate (e.g. `mapPermissionPolicyToPiArgs`).
15
+ */
16
+ export declare function computeEffectivePolicy(offered: PermissionPolicy, ceiling: PermissionPolicy | undefined): PolicyDecision;
@@ -0,0 +1,31 @@
1
+ import type { AgentEvent } from '@byok-sdk/protocol';
2
+ export type ProgressEmitter = (seq: number, events: AgentEvent[]) => void;
3
+ export interface ProgressBatcherOptions {
4
+ /** Flush immediately once this many events are buffered. Default 10. */
5
+ maxBatchSize?: number;
6
+ /** Otherwise flush at most this often (ms) while events are pending. Default 250 (~4/sec). */
7
+ flushIntervalMs?: number;
8
+ }
9
+ /**
10
+ * Coalesces a task's `AgentEvent`s into seq-ordered `task.progress` batches:
11
+ * flush immediately at `maxBatchSize` events, otherwise at most every
12
+ * `flushIntervalMs` while anything is buffered. One instance per task —
13
+ * `seq` is a per-task monotonic counter starting at 1.
14
+ */
15
+ export declare class ProgressBatcher {
16
+ private readonly emit;
17
+ private buffer;
18
+ private seq;
19
+ private timer;
20
+ private readonly maxBatchSize;
21
+ private readonly flushIntervalMs;
22
+ constructor(emit: ProgressEmitter, options?: ProgressBatcherOptions);
23
+ push(event: AgentEvent): void;
24
+ /** M4 Phase 4 (part B.3, observability): events buffered right now, not yet flushed as a `task.progress` batch — a cheap per-task queue-depth watermark for the daemon's control-socket `status` result (see `task-runner.ts`'s `getQueueWatermarks`). */
25
+ get pendingCount(): number;
26
+ flush(): void;
27
+ /** Stop the pending flush timer without flushing (used on teardown). */
28
+ stop(): void;
29
+ private ensureTimer;
30
+ private clearTimer;
31
+ }
@@ -0,0 +1,36 @@
1
+ import type { RuntimeCapabilities as ProtocolRuntimeCapabilities } from '@byok-sdk/protocol';
2
+ import type { RuntimeCapabilities } from '../types';
3
+ /**
4
+ * Maps a `RuntimeAdapter`'s own internal `capabilities()` result
5
+ * (`../types.ts`'s `RuntimeCapabilities` — `{steer, resume,
6
+ * approvalInteractive, permissionModes}`, always-required fields) onto the
7
+ * wire's `RuntimeCapabilities` shape (`@byok-sdk/protocol` — the same field names,
8
+ * but all-optional).
9
+ *
10
+ * Every field, `approvalInteractive` included, is a pure passthrough of the
11
+ * adapter's own self-report: the adapter is the single source of truth for
12
+ * what its runtime can do, and this function does no interpretation of its
13
+ * own. Previously `approvalInteractive` was hardcoded `false` at the sole
14
+ * caller, which had gone stale — as of M4 Phase 3 claude genuinely does
15
+ * support interactive approval, via `--permission-prompt-tool` routing
16
+ * `ClaudeSession.resolveApproval` into the local out-of-process MCP approval
17
+ * channel (`bin/byok-approval-mcp.ts`) under `policy.mode: 'confirm'` (see
18
+ * docs/protocol.md §5.1/§11.2) — so the wire advertised a capability claim no
19
+ * adapter owned. pi and codex report `false` for the same honest reason they
20
+ * always did: neither has any notion of pausing for approval
21
+ * (`resolveApproval()` throws unconditionally for both — see `../types.ts`'s
22
+ * `Session.resolveApproval` doc comment).
23
+ *
24
+ * This lives in its own module, rather than beside either of its two callers,
25
+ * because those two callers sit on opposite sides of an import edge:
26
+ * `create-daemon.ts` imports `task-runner.ts`, so exporting the mapper from
27
+ * `create-daemon.ts` would make `task-runner.ts` import back into it and close
28
+ * a cycle. A leaf module with no local imports beyond `../types` is the one
29
+ * place both can reach.
30
+ *
31
+ * The connection-level `interactive-approval` flag (`CAPABILITY_FLAGS`,
32
+ * `@byok-sdk/protocol`) is a separate, connection-scoped signal and is NOT derived
33
+ * from this: `create-daemon.ts`'s `computeCapabilities` keeps its own
34
+ * semantics.
35
+ */
36
+ export declare function toRuntimeInfoCapabilities(caps: RuntimeCapabilities): ProtocolRuntimeCapabilities;
@@ -0,0 +1,75 @@
1
+ /** What's recoverable for a given `sessionRef` — see the class doc comment. */
2
+ export interface SessionWorkspaceRecord {
3
+ workspaceDir: string;
4
+ /**
5
+ * The underlying runtime's own resumable session identifier.
6
+ */
7
+ runtimeSessionId: string;
8
+ /** Additive workspace kind; missing legacy values remain plain. */
9
+ workspaceKind?: 'plain' | 'git';
10
+ /** Opaque private Git workspace ledger identifier. */
11
+ gitWorkspaceId?: string;
12
+ }
13
+ /**
14
+ * Persists `sessionRef -> {workspaceDir, runtimeSessionId}` across daemon
15
+ * restarts (finding #3 from the 2026-07-16 live GLM run): a `task.offer`
16
+ * carrying a `sessionRef` this device has previously reported (via a prior
17
+ * task's `task.complete.sessionRef`) reuses that exact workspace directory
18
+ * as the new task's cwd — which is what lets a runtime adapter's own resume
19
+ * mechanism (e.g. pi's `--session <id>`, scoped to the cwd/project a session
20
+ * was created under — see pi-adapter.ts) actually find the session again.
21
+ * An unknown or absent `sessionRef` is simply not in this map, and
22
+ * `task-runner.ts` treats that identically to "no sessionRef was ever
23
+ * offered" — fresh workspace, fresh session.
24
+ *
25
+ * One JSON file under `storeDir`, mirroring `DeviceStore`/`CursorStore`'s
26
+ * own persistence style: always read/write straight through to disk, no
27
+ * in-memory cache that could go stale or need invalidating across multiple
28
+ * `TaskRunner`/daemon instances sharing the same `storeDir` (exactly the
29
+ * "map persisted across daemon restart" requirement).
30
+ *
31
+ * Two correctness properties `task-runner.ts` depends on, neither of which
32
+ * a bare `fs.writeFile` + independent `fs.readFile` calls actually gives
33
+ * you:
34
+ *
35
+ * 1. **No torn reads.** `record()` is deliberately fire-and-forgotten from
36
+ * `handleOffer` (see its call site) — it must never block `task.started`
37
+ * on a disk write. But a bare `fs.writeFile` is not atomic: it truncates
38
+ * the file before writing the new bytes, so a `get()` racing an
39
+ * in-flight `record()` on another task's offer can `JSON.parse` a
40
+ * half-written file, fail, and silently fall back to `{}` (see `load()`)
41
+ * — resolving what should have been a resume to a fresh workspace.
42
+ * `save()` below instead writes to a private temp file in the same
43
+ * directory and `fs.rename`s it onto the real path, which POSIX
44
+ * guarantees is atomic when both paths share a filesystem (true here —
45
+ * same directory): any concurrent reader sees either the fully-old or
46
+ * fully-new bytes, never a partial write.
47
+ * 2. **No lost updates.** Two `record()` calls for two different
48
+ * `sessionRef`s that overlap (both `load()` the same on-disk snapshot,
49
+ * each mutate their own key into their own in-memory copy, then `save()`
50
+ * back to back) would otherwise let the second `save()` overwrite the
51
+ * first's key with a snapshot that never saw it — each individual
52
+ * `save()` being atomic does not prevent this. `enqueue()` below chains
53
+ * every `get()`/`record()` through one serial promise queue per
54
+ * instance, so no two load-modify-save cycles (or a read and a
55
+ * concurrent write) ever interleave.
56
+ */
57
+ export declare class SessionWorkspaceStore {
58
+ private readonly filePath;
59
+ /**
60
+ * Serial "mutex" queue: every `get()`/`record()` chains its work off this
61
+ * promise and replaces it, so operations on this instance always run
62
+ * one-at-a-time, in call order — never interleaved. The queue's own tail
63
+ * is never allowed to reject (a failed task must not wedge every
64
+ * subsequent caller behind a rejected promise); the failure still
65
+ * propagates to that call's own caller via the returned promise.
66
+ */
67
+ private queue;
68
+ constructor(storeDir: string);
69
+ get(sessionRef: string): Promise<SessionWorkspaceRecord | undefined>;
70
+ record(sessionRef: string, entry: SessionWorkspaceRecord): Promise<void>;
71
+ /** See the class doc comment's "no lost updates" property. */
72
+ private enqueue;
73
+ private load;
74
+ private save;
75
+ }
@@ -0,0 +1,54 @@
1
+ import { type EnsureSecureDirOptions } from '../util/secure-dir';
2
+ export interface DeviceRecord {
3
+ deviceId: string;
4
+ /** Current access token (JWT), renewed via challenge/token without re-pairing (protocol §6.2). */
5
+ accessToken: string;
6
+ /** ISO-8601 expiry for `accessToken` (our best knowledge of it — see auth-manager.ts for how this is derived after `/byok/pair`, which reports no explicit expiry itself). */
7
+ expiresAt: string;
8
+ /** Ed25519 private key, PKCS8 PEM. Never leaves this device. Stored as a 0600 file; OS keychain integration is deferred (tracked as a future roadmap item, not promised for any specific milestone). */
9
+ devicePrivateKeyPem: string;
10
+ /** Ed25519 public key, base64url — re-sent verbatim on a post-revocation re-pair (protocol §6.3). */
11
+ devicePublicKey: string;
12
+ }
13
+ /**
14
+ * Persists the device identity issued by `pair()` — deviceId, current
15
+ * access token + its expiry, and the device's own Ed25519 keypair. This is
16
+ * the ONLY credential material the daemon itself ever holds — never a
17
+ * runtime's own credentials (see the credential-isolation rule on
18
+ * `RuntimeAdapter`). Stored 0600 under `storeDir` (default
19
+ * `~/.byok/<productId>/`); OS keychain storage is deferred (tracked as a
20
+ * future roadmap item, not promised for any specific milestone).
21
+ */
22
+ export declare class DeviceStore {
23
+ private readonly secureDirOptions?;
24
+ private readonly filePath;
25
+ /**
26
+ * `secureDirOptions` is a test-only DI seam (mirrors `EnsureSecureDirOptions`'s
27
+ * own `run`/`platform` overrides) — every real caller omits it, getting
28
+ * real `ensureSecureDir(storeDir)` behavior unchanged. It exists so
29
+ * finding R4's fail-closed contract ("on win32, an `icacls` failure makes
30
+ * `save()` — and thus `AuthManager.pair()` — reject with a clear typed
31
+ * `SecureDirHardeningError` instead of silently persisting an
32
+ * ACL-unprotected credential") is verifiable from a real `darwin`/`linux`
33
+ * CI/dev machine, not just asserted.
34
+ */
35
+ constructor(storeDir: string, secureDirOptions?: EnsureSecureDirOptions | undefined);
36
+ static defaultDir(productId: string): string;
37
+ /**
38
+ * Resolve the one store pathname every daemon/CLI component must share.
39
+ * A configured relative path is anchored once at process entry rather than
40
+ * being reinterpreted after a diagnostics operation temporarily changes
41
+ * cwd to pin a quarantine directory inode.
42
+ */
43
+ static resolveDir(productId: string, configured?: string): string;
44
+ load(): Promise<DeviceRecord | undefined>;
45
+ /**
46
+ * Read and remove the exact bounded, no-follow device record under the
47
+ * caller's mutation lease. The hard-link guard keeps the inspected inode
48
+ * identifiable until the synchronous pathname check and unlink complete.
49
+ */
50
+ remove(): Promise<DeviceRecord | undefined>;
51
+ save(record: DeviceRecord): Promise<void>;
52
+ clear(): Promise<void>;
53
+ private openBounded;
54
+ }