@peakflo/agent-harness 0.0.0-stage → 0.2.2-rc.1

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 (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -2
  3. package/dist/adapters/acp-adapter.d.ts +177 -0
  4. package/dist/adapters/acp-adapter.js +1732 -0
  5. package/dist/adapters/claude-code-adapter.d.ts +171 -0
  6. package/dist/adapters/claude-code-adapter.js +1918 -0
  7. package/dist/adapters/codex-adapter.d.ts +67 -0
  8. package/dist/adapters/codex-adapter.js +681 -0
  9. package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
  10. package/dist/adapters/codex-app-server-adapter.js +1309 -0
  11. package/dist/adapters/coding-agent-adapter.d.ts +289 -0
  12. package/dist/adapters/coding-agent-adapter.js +31 -0
  13. package/dist/adapters/index.d.ts +17 -0
  14. package/dist/adapters/index.js +35 -0
  15. package/dist/adapters/model-gateway.d.ts +84 -0
  16. package/dist/adapters/model-gateway.js +270 -0
  17. package/dist/adapters/opencode-adapter.d.ts +403 -0
  18. package/dist/adapters/opencode-adapter.js +2710 -0
  19. package/dist/adapters/opencode-config.d.ts +41 -0
  20. package/dist/adapters/opencode-config.js +147 -0
  21. package/dist/adapters/opencode-server-policy.d.ts +82 -0
  22. package/dist/adapters/opencode-server-policy.js +148 -0
  23. package/dist/adapters/pi-adapter.d.ts +168 -0
  24. package/dist/adapters/pi-adapter.js +1468 -0
  25. package/dist/capabilities.d.ts +58 -0
  26. package/dist/capabilities.js +161 -0
  27. package/dist/cli.d.ts +2 -0
  28. package/dist/cli.js +231 -0
  29. package/dist/contract.d.ts +273 -0
  30. package/dist/contract.js +357 -0
  31. package/dist/control-plane.d.ts +178 -0
  32. package/dist/control-plane.js +431 -0
  33. package/dist/credential-store.d.ts +110 -0
  34. package/dist/credential-store.js +181 -0
  35. package/dist/enrol/browser-enrol.d.ts +85 -0
  36. package/dist/enrol/browser-enrol.js +387 -0
  37. package/dist/github-command-guards.d.ts +20 -0
  38. package/dist/github-command-guards.js +330 -0
  39. package/dist/github-proxy-certificates.d.ts +25 -0
  40. package/dist/github-proxy-certificates.js +145 -0
  41. package/dist/github-proxy.d.ts +37 -0
  42. package/dist/github-proxy.js +476 -0
  43. package/dist/github-workspace.d.ts +55 -0
  44. package/dist/github-workspace.js +537 -0
  45. package/dist/harness.d.ts +333 -0
  46. package/dist/harness.js +1414 -0
  47. package/dist/health.d.ts +45 -0
  48. package/dist/health.js +116 -0
  49. package/dist/index.d.ts +53 -0
  50. package/dist/index.js +123 -0
  51. package/dist/model-probe.d.ts +96 -0
  52. package/dist/model-probe.js +195 -0
  53. package/dist/options.d.ts +15 -0
  54. package/dist/options.js +212 -0
  55. package/dist/ports-default.d.ts +212 -0
  56. package/dist/ports-default.js +333 -0
  57. package/dist/ports.d.ts +436 -0
  58. package/dist/ports.js +27 -0
  59. package/dist/runtime/attachments.d.ts +92 -0
  60. package/dist/runtime/attachments.js +377 -0
  61. package/dist/runtime/child-stream-guards.d.ts +45 -0
  62. package/dist/runtime/child-stream-guards.js +111 -0
  63. package/dist/runtime/conversation.d.ts +87 -0
  64. package/dist/runtime/conversation.js +156 -0
  65. package/dist/runtime/lease-renewal.d.ts +169 -0
  66. package/dist/runtime/lease-renewal.js +433 -0
  67. package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
  68. package/dist/runtime/mcp-auth-proxy.js +300 -0
  69. package/dist/runtime/permission-mode.d.ts +116 -0
  70. package/dist/runtime/permission-mode.js +146 -0
  71. package/dist/runtime/projection.d.ts +71 -0
  72. package/dist/runtime/projection.js +211 -0
  73. package/dist/runtime/session-runner.d.ts +520 -0
  74. package/dist/runtime/session-runner.js +2122 -0
  75. package/dist/shared/reasoning-effort.d.ts +11 -0
  76. package/dist/shared/reasoning-effort.js +20 -0
  77. package/dist/store/conversation-store.d.ts +100 -0
  78. package/dist/store/conversation-store.js +305 -0
  79. package/dist/store/sqlite-store.d.ts +120 -0
  80. package/dist/store/sqlite-store.js +346 -0
  81. package/dist/types.d.ts +952 -0
  82. package/dist/types.js +55 -0
  83. package/dist/version.d.ts +32 -0
  84. package/dist/version.js +77 -0
  85. package/package.json +53 -4
@@ -0,0 +1,952 @@
1
+ /**
2
+ * THE RUNNER WIRE CONTRACT — the single meeting point between the control
3
+ * plane (Track A, workflow-builder) and the harness (Track B, extracted from
4
+ * 20x).
5
+ *
6
+ * This file is the contract. `packages/agentSandbox/test/control-plane-stub.mjs`
7
+ * is the SAME contract in executable form. The two are kept in lockstep on
8
+ * purpose: prose drifts, a running stub does not.
9
+ *
10
+ * FOUR PROPERTIES THAT ARE NOT NEGOTIABLE
11
+ *
12
+ * 1. VERSIONED. Every request carries `protocolVersion`. A harness below the
13
+ * control plane's floor is refused, and the refusal is FINAL — the harness
14
+ * exits so the host is recycled onto a supported version. See
15
+ * `./contract.ts`.
16
+ *
17
+ * 2. OUTBOUND ONLY. Nothing here requires the harness to accept an inbound
18
+ * connection. Work is delivered by a bounded long-poll (`GET .../lease`),
19
+ * never by the control plane dialling in. That single property is what lets
20
+ * a customer VM behind NAT work with no tunnel and no listening port.
21
+ * Anything that needs to reach a running harness — abort, an answer to a
22
+ * question — travels back on the NEXT poll, which is why
23
+ * `RunnerHeartbeatResponse.sessionCommands` exists.
24
+ *
25
+ * 3. RUNTIME NEUTRAL. A Daytona sandbox, a customer VM and the 20x desktop are
26
+ * the same client. There is no route, field or token that only one of them
27
+ * can use. `POST /api/coding-agents/sessions/:id/ingest` in particular is
28
+ * identical for all three; that is the whole payoff of the extraction.
29
+ *
30
+ * 4. TOKENS TRAVEL IN THE ENVIRONMENT OR IN HEADERS, NEVER IN ARGV.
31
+ * `/proc/<pid>/cmdline` is world-readable; `/proc/<pid>/environ` is readable
32
+ * only by the process owner. Every credential below is either an
33
+ * `Authorization: Bearer` header or an environment variable handed to a
34
+ * child process. Do not "improve" this by adding a `--token` flag.
35
+ */
36
+ export interface HarnessOptions {
37
+ /** workflow-api base url, e.g. https://stage-api.peakflo.ai */
38
+ controlPlaneUrl: string;
39
+ /**
40
+ * Enrolment token. Single-use and short-lived. Injected as a sandbox env
41
+ * var at creation for managed sandboxes, or pasted by an operator for a
42
+ * self-hosted install. Exchanged ONCE at the register route.
43
+ */
44
+ enrolmentToken: string;
45
+ /** Loopback port for the health check. Never bound to 0.0.0.0. */
46
+ healthPort: number;
47
+ /** Preferred loopback port for the sentinel-authenticated GitHub TLS proxy. */
48
+ githubProxyPort?: number;
49
+ /**
50
+ * Commit and push dirty repository worktrees after each turn.
51
+ *
52
+ * Defaults on for the managed sandbox runtime. Operators can disable it,
53
+ * but the checkout still stays on disk until the session is released.
54
+ */
55
+ autoCommitDirtyWorktree?: boolean;
56
+ /** Milliseconds between heartbeats. The control plane may override this. */
57
+ heartbeatIntervalMs: number;
58
+ /**
59
+ * True when pf-bootstrap could not reach the private registry and started
60
+ * the warm fallback copy instead. Reported at registration so the control
61
+ * plane can see a degraded fleet.
62
+ */
63
+ degraded: boolean;
64
+ degradedReason?: string;
65
+ /**
66
+ * Root under which every session workspace is created, one directory each.
67
+ *
68
+ * `/var/pf/workspaces` in the sandbox image, owned `pfharness` mode 0700.
69
+ * Decision 2 dropped the unix-user-per-session design, so this path IS the
70
+ * filesystem separation between two sessions of the same tenant — everything
71
+ * stronger is enforced by the harness and the phase-3 MCP gateway.
72
+ */
73
+ workspaceRoot: string;
74
+ /**
75
+ * Which hosting mode this is. ADVISORY: it changes routing and what an
76
+ * operator sees, never a permission. The control plane already knows the
77
+ * truth from how the enrolment token was minted, so a runner that lied here
78
+ * would gain nothing.
79
+ */
80
+ runtime: RunnerRuntime;
81
+ /**
82
+ * How many sessions this host will run at once.
83
+ *
84
+ * DEFAULTS TO 1, and that default is a security position: cross-session
85
+ * isolation on a shared customer VM is the customer's duty, and 1 is the only
86
+ * value that needs no such assumption.
87
+ */
88
+ maxConcurrentSessions: number;
89
+ /** Free-form routing labels, e.g. ['eu-only', 'has-vpn']. */
90
+ labels?: string[];
91
+ }
92
+ /**
93
+ * Every request body in this contract carries the protocol version, so the
94
+ * control plane can gate on it without depending on a header surviving a
95
+ * proxy. The header `x-pf-protocol-version` is sent as well and must agree.
96
+ */
97
+ export interface ProtocolEnvelope {
98
+ protocolVersion: number;
99
+ }
100
+ /**
101
+ * Anything that survives a JSON round trip. Used for `payload`, which is
102
+ * stored as JSONB and must therefore be exactly this and nothing wider.
103
+ */
104
+ export type JsonValue = string | number | boolean | null | JsonValue[] | {
105
+ [key: string]: JsonValue;
106
+ };
107
+ /**
108
+ * The error body every non-2xx response uses. `retryable` is advisory; the
109
+ * authority on whether to retry is the HTTP status (see `FINAL_HTTP_STATUSES`
110
+ * in `./contract.ts`), because a status survives a proxy rewriting a body.
111
+ */
112
+ export interface ControlPlaneErrorBody {
113
+ error: string;
114
+ /** Stable machine-readable reason. See `CONTRACT_ERROR_CODES`. */
115
+ code?: string;
116
+ retryable?: boolean;
117
+ /** Present on a protocol refusal, so an operator can see the floor. */
118
+ minProtocolVersion?: number;
119
+ /** Present on a harness-version refusal. */
120
+ minHarnessVersion?: string;
121
+ }
122
+ /**
123
+ * Called by `pf-bootstrap`, BEFORE any harness code exists on the host, with
124
+ * the enrolment token as the bearer. It is the only route in this contract
125
+ * whose caller is not the harness.
126
+ *
127
+ * The enrolment token is valid for one BOOT WINDOW, not one HTTP call:
128
+ * pf-bootstrap presents it here, then hands it to the harness, which presents
129
+ * it again at the register route. A control plane that wants a strictly
130
+ * single-use token returns a FRESH one in `harnessEnrolmentToken`;
131
+ * pf-bootstrap already prefers that field when it is present.
132
+ */
133
+ export interface BootstrapRequest {
134
+ /**
135
+ * OPTIONAL here, unlike every other request in this contract, and the one
136
+ * deliberate exception to the envelope rule.
137
+ *
138
+ * `pf-bootstrap` ships inside the Daytona snapshot and predates the runner
139
+ * protocol, so the deployed copies do not send it. Making it required would
140
+ * declare a contract that the only real client already violates. The control
141
+ * plane must therefore treat an absent value here as "unknown, pre-protocol
142
+ * bootstrap" and still answer — refusing would brick every existing sandbox
143
+ * image on the first deploy.
144
+ *
145
+ * This is safe precisely because bootstrap grants nothing: it hands out a
146
+ * pinned version, a read-only registry token and a URL. The gate that
147
+ * matters runs at REGISTER, where the envelope is required and enforced.
148
+ */
149
+ protocolVersion?: number;
150
+ /** Best-effort machine identity. Advisory — never an authorisation input. */
151
+ hostname?: string;
152
+ platform?: string;
153
+ arch?: string;
154
+ nodeVersion?: string;
155
+ }
156
+ export interface BootstrapResponse {
157
+ /** EXACT version. Never a semver range — the boot must be reproducible. */
158
+ harnessVersion: string;
159
+ /** Private registry base url. Public npm is refused in pf-bootstrap. */
160
+ registryUrl: string;
161
+ /**
162
+ * Short-lived registry READ token. Written to a 0600 .npmrc owned by the
163
+ * harness user, then deleted. Must contain no control characters —
164
+ * pf-bootstrap refuses it otherwise, because the value is written straight
165
+ * into an .npmrc and a newline would inject further npm directives.
166
+ */
167
+ registryToken: string;
168
+ /** sha512 of the tarball, verified before anything is executed. */
169
+ integritySha512: string;
170
+ /** Must be reachable FROM THE HOST, not from the control plane's own host. */
171
+ controlPlaneUrl: string;
172
+ /** Optional fresh enrolment token, preferred by pf-bootstrap when present. */
173
+ harnessEnrolmentToken?: string;
174
+ /** The floor, so a doomed boot can be abandoned before fetching anything. */
175
+ minProtocolVersion?: number;
176
+ }
177
+ /**
178
+ * What this host can actually do. Reported at registration and refreshed on
179
+ * every heartbeat, because a host is upgraded in place: `docker` can appear
180
+ * and a backend can be installed without the runner ever restarting.
181
+ */
182
+ export interface RunnerCapabilities {
183
+ /** `process.platform` — 'linux' | 'darwin' | 'win32' | ... */
184
+ os: string;
185
+ /** `process.arch` — 'x64' | 'arm64' | ... */
186
+ arch: string;
187
+ harnessVersion: string;
188
+ /**
189
+ * The sandbox TOOLS layer (gh, @peculiar/x509, ...) currently installed,
190
+ * synced by pf-bootstrap independently of the harness. Absent on
191
+ * self-hosted runners and desktop, which never had this layer, and on a
192
+ * sandbox that booted before it existed.
193
+ */
194
+ sandboxToolsVersion?: string;
195
+ nodeVersion: string;
196
+ hostname: string;
197
+ /** Coding-agent backends found on this host, with the version detected. */
198
+ backends: RunnerBackend[];
199
+ /**
200
+ * Flat union of every backend's reported models, deduplicated by id.
201
+ *
202
+ * Redundant with `backends[].models`, but the control-plane model picker
203
+ * reads this form because it does not have to walk the backend list. Absent
204
+ * or empty means "the host could not enumerate models" — NEVER a guess.
205
+ */
206
+ models?: RunnerModel[];
207
+ /** A repository-backed session is impossible without git. */
208
+ git: boolean;
209
+ /** Some backends shell out to Docker. Absent is not an error. */
210
+ docker: boolean;
211
+ /**
212
+ * How many sessions this host will run at once. DEFAULTS TO 1 on a
213
+ * self-hosted runner: cross-session isolation on a shared customer VM is the
214
+ * customer's duty, and 1 is the only value that needs no such assumption.
215
+ */
216
+ maxConcurrentSessions: number;
217
+ /** Free-form routing labels, e.g. ['eu-only', 'has-vpn']. */
218
+ labels?: string[];
219
+ }
220
+ export interface RunnerBackend {
221
+ /** 'opencode' | 'claude-code' | 'codex' | 'acp' | ... — an open vocabulary. */
222
+ name: string;
223
+ version: string | null;
224
+ /**
225
+ * Models this backend can drive, when the host can enumerate them.
226
+ *
227
+ * Absent means the host could not say — NEVER an empty-but-meaningful list.
228
+ * A model list is advisory, not a validation gate: the id a lease names is
229
+ * what is actually called, and even a model the harness never heard of is
230
+ * sent through verbatim.
231
+ */
232
+ models?: RunnerModel[];
233
+ }
234
+ /** One model a harness can drive, in the shape the control-plane UI renders. */
235
+ export interface RunnerModel {
236
+ /** Full model reference, e.g. 'peakflo/gpt-5.6-sol' or 'gpt-4.1'. */
237
+ id: string;
238
+ name: string;
239
+ }
240
+ export interface RunnerRegisterRequest extends ProtocolEnvelope {
241
+ capabilities: RunnerCapabilities;
242
+ /**
243
+ * Which of the three hosting modes this is. Advisory: it changes ROUTING and
244
+ * what an operator sees, never a permission. The control plane already knows
245
+ * the truth from how the enrolment token was minted.
246
+ */
247
+ runtime: RunnerRuntime;
248
+ /** True when the warm fallback harness was booted instead of the pinned one. */
249
+ degraded: boolean;
250
+ degradedReason?: string;
251
+ }
252
+ export type RunnerRuntime = 'sandbox' | 'self-hosted' | 'desktop';
253
+ export interface RunnerRegisterResponse {
254
+ /**
255
+ * The version gate. `false` is FINAL — never retried. The harness exits
256
+ * non-zero so the host is recycled onto a supported version. It must EXIT
257
+ * and not merely throw: a listening health socket keeps the Node event loop
258
+ * alive for ever, so a harness that only threw would hang and the gate would
259
+ * never take effect.
260
+ */
261
+ accepted: boolean;
262
+ /** Why, in words an operator can act on. Required when accepted is false. */
263
+ reason?: string;
264
+ code?: string;
265
+ minProtocolVersion?: number;
266
+ minHarnessVersion?: string;
267
+ /** Absent when accepted is false. */
268
+ runnerId?: string;
269
+ tenantId?: string;
270
+ /**
271
+ * Long-lived runner credential. Replaces the enrolment token, which burns
272
+ * here. MEMORY ONLY — never written to disk, never handed to a session
273
+ * process, never in argv.
274
+ */
275
+ runnerCredential?: string;
276
+ /** ISO 8601. Rotate before this using the rotate route. */
277
+ credentialExpiresAt?: string;
278
+ /** ISO 8601. Rotate at this point, comfortably before expiry. */
279
+ rotateAfter?: string;
280
+ /** The control plane owns the cadence. */
281
+ heartbeatIntervalMs?: number;
282
+ /** Seconds the harness should ask the lease route to hold a poll open. */
283
+ leaseWaitSeconds?: number;
284
+ }
285
+ export type RunnerStatus = 'starting' | 'ready' | 'busy' | 'draining';
286
+ export interface RunnerHeartbeatRequest extends ProtocolEnvelope {
287
+ status: RunnerStatus;
288
+ uptimeMs: number;
289
+ activeSessions: number;
290
+ /** Session ids currently held, so the control plane can spot a split brain. */
291
+ activeSessionIds?: string[];
292
+ degraded: boolean;
293
+ /**
294
+ * Capability REFRESH. A host is upgraded in place, so the control plane must
295
+ * never treat the registration-time snapshot as permanent.
296
+ */
297
+ capabilities: RunnerCapabilities;
298
+ }
299
+ export interface RunnerHeartbeatResponse {
300
+ ok: boolean;
301
+ /** Exact harness pin currently selected by the control plane. */
302
+ harnessVersion?: string;
303
+ /**
304
+ * Exact sandbox-tools pin, when the control plane has the tools layer
305
+ * turned on. Additive and independent of harnessVersion — either can
306
+ * change without the other.
307
+ */
308
+ sandboxToolsVersion?: string;
309
+ /**
310
+ * 'drain' asks the harness to finish its work and take no more.
311
+ * 'restart' asks it to exit so the host is recycled.
312
+ */
313
+ action?: 'none' | 'drain' | 'restart';
314
+ heartbeatIntervalMs?: number;
315
+ /**
316
+ * The outbound-only answer to "how does anything reach a running session?".
317
+ * The control plane cannot dial in, so an abort or a user's answer to a
318
+ * question rides back on the next heartbeat. Commands are idempotent and may
319
+ * be repeated until the harness acknowledges them by their effect.
320
+ */
321
+ sessionCommands?: SessionCommand[];
322
+ }
323
+ export interface SessionCommand {
324
+ sessionId: string;
325
+ /**
326
+ * 'abort' stops the turn; 'answer' resolves a pending question part;
327
+ * 'release-workspace' removes a checkout after the session ends.
328
+ */
329
+ command: 'abort' | 'answer' | 'release-workspace';
330
+ /** Set when command is 'answer' — the partId of the question being answered. */
331
+ partId?: string;
332
+ /** Set when command is 'answer' — the user's reply. */
333
+ answer?: string;
334
+ /**
335
+ * Set when command is 'answer' AND the user picked a NAMED OPTION rather
336
+ * than typing free text.
337
+ *
338
+ * ADDITIVE AND OPTIONAL, so it does not break protocol version 1: a harness
339
+ * built before this field existed ignores an unknown key and continues to use
340
+ * `answer`, which still carries the human-readable text. A harness that reads
341
+ * it can branch on the exact option instead of matching a display label by
342
+ * string — labels are ambiguous, translatable and repeatable between
343
+ * questions, and ids are none of those things.
344
+ */
345
+ optionId?: string;
346
+ /** De-duplication key. The same commandId must be applied at most once. */
347
+ commandId: string;
348
+ }
349
+ /**
350
+ * The bounded long-poll that replaces a socket.
351
+ *
352
+ * 204 No Content no work; poll again immediately
353
+ * 200 a lease, in the body below
354
+ *
355
+ * `wait` is in SECONDS and defaults to 25. 25 matches the existing
356
+ * `boundSseStream` heartbeat, and it is chosen against Cloud Run: a request
357
+ * slot is billed for the whole life of a connection and capped at 3600s, which
358
+ * is what rules out one persistent WebSocket per runner.
359
+ */
360
+ export interface LeaseResponse {
361
+ /**
362
+ * Exact harness pin currently selected by the control plane.
363
+ *
364
+ * This is additive. A runner that already received a live lease finishes it
365
+ * before it acts on a changed pin. Idle runners also receive the pin on the
366
+ * heartbeat response, because a 204 lease response has no body by contract.
367
+ */
368
+ harnessVersion?: string;
369
+ /**
370
+ * Exact sandbox-tools pin (gh, @peculiar/x509, ...), independent of
371
+ * harnessVersion. Same additivity rule: idle runners get it on the
372
+ * heartbeat response too, since a 204 lease has no body.
373
+ */
374
+ sandboxToolsVersion?: string;
375
+ session: LeasedSession;
376
+ leaseId: string;
377
+ /**
378
+ * ISO 8601 visibility timeout. Renew before this or the watchdog requeues
379
+ * the session onto another runner. Every successful ingest also extends it,
380
+ * so a runner that is producing output rarely needs to call renew at all.
381
+ */
382
+ leaseExpiresAt: string;
383
+ /**
384
+ * Session-scoped MCP token. Bound to runnerId AND sessionId, short TTL,
385
+ * revoked when the session ends. It carries NO tool list — the allowlist is
386
+ * re-read from the database on every call so revocation is immediate.
387
+ *
388
+ * NO VENDOR CREDENTIAL EVER LEAVES THE CONTROL PLANE. This token buys access
389
+ * to the gateway, and the gateway injects the real credentials server-side.
390
+ */
391
+ mcpToken: string;
392
+ /**
393
+ * Per-session, budget-capped model token. NOT the tenant's model key — a
394
+ * customer VM is outside our security perimeter and must never hold one.
395
+ */
396
+ modelToken: string;
397
+ /**
398
+ * ISO 8601 expiry of `modelToken`, when the control plane minted one with a
399
+ * known lifetime (a `self-hosted`/`desktop` per-turn key; absent for a
400
+ * sandbox's longer-lived per-SANDBOX key, which is not refreshed this way).
401
+ * Echoed back on `/renew` as `modelTokenExpiresAt` so the control plane can
402
+ * re-mint it near expiry, on the same cadence the GitHub credential already
403
+ * uses — a fixed 1h key TTL would otherwise cut a healthy turn on these
404
+ * runtimes on its own, independent of `maxTurnDurationMs`.
405
+ */
406
+ modelTokenExpiresAt?: string;
407
+ /** In-memory only. It is never passed through a process environment. */
408
+ githubCredential?: LeasedGitHubCredential;
409
+ }
410
+ export interface LeasedSession {
411
+ sessionId: string;
412
+ tenantId: string;
413
+ /** One lease is one TURN. A new turn on the same session is a new lease. */
414
+ turnId: string;
415
+ runtime: RunnerRuntime;
416
+ agent: LeasedAgent;
417
+ input: LeasedTurnInput;
418
+ /**
419
+ * The harness keeps a local SQLite store so it survives its own crash. When
420
+ * this is non-null the harness already holds transcript up to this rev and
421
+ * need not replay it. Null means start cold.
422
+ */
423
+ resumeFromRev: number | null;
424
+ /**
425
+ * The backend's own handle for this conversation, when the control plane has
426
+ * one recorded.
427
+ *
428
+ * AN OPAQUE CHECKPOINT. The control plane never interprets it; it stores what
429
+ * the last runner reported and hands it back, so a runner that still has that
430
+ * backend session alive can RESUME the conversation instead of rebuilding it
431
+ * from a log. Null means no backend session is known and the runner must
432
+ * replay history into a fresh one.
433
+ */
434
+ backendSessionId?: string | null;
435
+ /**
436
+ * Which backend minted `backendSessionId`.
437
+ *
438
+ * Without this the cross-backend safety check below could only ever run on a
439
+ * runner that happened to have run the earlier turn itself — that is, never
440
+ * in the case it exists for.
441
+ */
442
+ backendName?: string | null;
443
+ /**
444
+ * WHICH BACKEND INSTANCE minted `backendSessionId` — see `TurnMetadata`.
445
+ *
446
+ * A runner resumes only when this equals its OWN backend instance id. Any
447
+ * other value, including absent, means the handle belongs to somebody else's
448
+ * opencode state directory and is not a session at all from here.
449
+ */
450
+ backendOwnerId?: string | null;
451
+ workspace: LeasedWorkspace;
452
+ limits: LeasedLimits;
453
+ }
454
+ export interface LeasedAgent {
455
+ agentId: string;
456
+ name: string;
457
+ /** 'opencode' first. An open vocabulary, not a union. */
458
+ backend: string;
459
+ systemPrompt: string | null;
460
+ model: LeasedModel;
461
+ /**
462
+ * The real permission mode, honoured for real. Phase 0 recorded G1: the
463
+ * 20x claude-code adapter hardcodes 'bypassPermissions'. This field exists so
464
+ * that stops being possible.
465
+ */
466
+ permissionMode: 'default' | 'plan' | 'acceptEdits' | 'bypassPermissions';
467
+ /** Always the skill's PUBLISHED content, never its draft. */
468
+ skills: LeasedSkill[];
469
+ mcpServers: LeasedMcpServer[];
470
+ }
471
+ export interface LeasedModel {
472
+ provider: string;
473
+ model: string;
474
+ /**
475
+ * The model gateway base url. The harness talks to this with `modelToken`,
476
+ * never directly to a vendor with a tenant key.
477
+ */
478
+ gatewayUrl: string;
479
+ }
480
+ export interface LeasedSkill {
481
+ skillId: string;
482
+ name: string;
483
+ /** From skills.published_content. A draft must never reach a runner. */
484
+ content: string;
485
+ }
486
+ export interface LeasedMcpServer {
487
+ serverId: string;
488
+ name: string;
489
+ /**
490
+ * Always 'http'. A 'local' (stdio) server is refused for a cloud sandbox and
491
+ * is never expressible here, because a stdio server would mean a credential
492
+ * on the host.
493
+ */
494
+ transport: 'http';
495
+ /** /api/mcp/agent/:sessionId/:serverId — authenticated with `mcpToken`. */
496
+ url: string;
497
+ /**
498
+ * ADVISORY ONLY, for generating AGENTS.md and for what the UI shows.
499
+ * THE GATEWAY IS AUTHORITATIVE: `tools/list` returns only allowed tools and
500
+ * `tools/call` refuses anything outside the allowlist before dispatch. A
501
+ * harness must never be trusted to enforce this — that is exactly defect G2.
502
+ * `null` means "every tool the server exposes".
503
+ */
504
+ allowedTools: string[] | null;
505
+ }
506
+ export interface LeasedTurnInput {
507
+ /** 'message' is a new user turn; 'resume' continues an interrupted one. */
508
+ kind: 'message' | 'resume';
509
+ role: 'user';
510
+ content: string;
511
+ attachments?: LeasedAttachment[];
512
+ /**
513
+ * Attachments the control plane could NOT turn into a `LeasedAttachment`
514
+ * at lease time — a local-storage `local://` URL the harness cannot fetch,
515
+ * or a signing failure. Without this the harness has no way to know these
516
+ * ever existed: they are absent from `attachments`, so its own download
517
+ * loop never runs for them and never adds them to its OWN failure list
518
+ * either. `session-runner.ts` merges these into that same list before
519
+ * building the prompt note, so the model (and the user, via the
520
+ * transcript) is told a file it was named is not available, instead of
521
+ * the turn silently running as if it had never been attached at all.
522
+ */
523
+ droppedAttachments?: Array<{
524
+ name: string;
525
+ reason: string;
526
+ }>;
527
+ }
528
+ export interface LeasedAttachment {
529
+ name: string;
530
+ mimeType: string;
531
+ /** Pre-signed, short-lived. The harness fetches it; it is not inlined. */
532
+ url: string;
533
+ sizeBytes?: number;
534
+ }
535
+ export interface LeasedWorkspace {
536
+ /**
537
+ * 'scratch' is a bare working directory and is the default — the product
538
+ * requirement is an agent with MCP access, not filesystem access.
539
+ * 'repository' is an explicit per-agent opt-in and only ever on a runner
540
+ * the customer chose.
541
+ */
542
+ mode: 'scratch' | 'repository';
543
+ repositoryUrl?: string;
544
+ ref?: string;
545
+ repositories?: LeasedGitHubRepository[];
546
+ /** Names requested by the session, retained even when the server refuses them. */
547
+ requestedRepositories?: string[];
548
+ /** A server-side ruleset or installation refusal. The harness fails loudly. */
549
+ refusalReason?: string;
550
+ }
551
+ export interface LeasedGitHubRepository {
552
+ id: number;
553
+ fullName: string;
554
+ defaultBranch: string;
555
+ cloneUrl: string;
556
+ }
557
+ export interface LeasedGitHubCredential {
558
+ token: string;
559
+ expiresAt: string;
560
+ repositories: LeasedGitHubRepository[];
561
+ }
562
+ export interface LeasedLimits {
563
+ /**
564
+ * Last-line wall-clock ceiling on the turn, or `null` for NO ceiling.
565
+ *
566
+ * THIS IS NOT THE PRIMARY GUARD ANY MORE. A runaway model is caught by
567
+ * `maxTurnIdleMs` below — the wall clock stayed the enforcement instrument
568
+ * for too long, and a real coding turn routinely runs 30-90 minutes, so a
569
+ * fixed cap cut healthy turns as often as it caught broken ones.
570
+ *
571
+ * `null` is the default: no operator has opted into a ceiling, so none is
572
+ * enforced. Set only when `CODING_AGENT_MAX_TURN_MINUTES` is configured
573
+ * (services/coding-agents/config.ts), in which case it is a real backstop —
574
+ * the watchdog requeues a session whose lease outlives it, but a healthy
575
+ * runner should never reach it, because the idle guard below stops a hung
576
+ * turn first.
577
+ */
578
+ maxTurnDurationMs: number | null;
579
+ /**
580
+ * No progress — a new message part, a tool call start/end, or streamed
581
+ * text — for this long ends the turn: the backend is hung, not merely slow.
582
+ * A single tool call that legitimately runs long (a big test suite, a slow
583
+ * build) produces nothing until it COMPLETES, so this has to be generous
584
+ * enough to outlast one, not just a network hiccup. Approval wait time is
585
+ * excluded, exactly like `maxTurnDurationMs`. Defaults to 20 minutes in the
586
+ * runner when absent; configurable via `CODING_AGENT_MAX_TURN_IDLE_MINUTES`.
587
+ */
588
+ maxTurnIdleMs?: number;
589
+ maxOutputTokens?: number;
590
+ /** Per-session spend ceiling in USD. */
591
+ spendCapUsd?: number;
592
+ }
593
+ export interface LeaseRenewRequest extends ProtocolEnvelope {
594
+ /** Advisory progress note, so an operator can see a slow turn is alive. */
595
+ status?: RunnerStatus;
596
+ /** The server returns a replacement only when this enters its safety window. */
597
+ githubTokenExpiresAt?: string;
598
+ /** Same idea, for the per-turn LiteLLM key. See `LeaseResponse.modelTokenExpiresAt`. */
599
+ modelTokenExpiresAt?: string;
600
+ }
601
+ export interface LeaseRenewResponse {
602
+ /**
603
+ * False when the lease has already expired and been requeued elsewhere. The
604
+ * harness must then ABANDON the turn and stop ingesting for it: another
605
+ * runner owns the session now, and two writers would interleave a transcript.
606
+ */
607
+ renewed: boolean;
608
+ leaseExpiresAt?: string;
609
+ reason?: string;
610
+ githubCredential?: LeasedGitHubCredential;
611
+ /**
612
+ * A replacement per-turn model token, present only when
613
+ * `modelTokenExpiresAt` on the request was inside the refresh margin AND
614
+ * this runner is not a sandbox (whose key is not refreshed this way).
615
+ */
616
+ modelToken?: string;
617
+ /** Expiry of the token above, when one was minted. */
618
+ modelTokenExpiresAt?: string;
619
+ /**
620
+ * Commands queued for this runner, delivered on the renewal.
621
+ *
622
+ * WHY HERE AND NOT ONLY ON THE HEARTBEAT. The heartbeat is 30 s. That is fine
623
+ * for an abort and far too slow for a PERMISSION PROMPT, where the user
624
+ * clicks Allow and then watches an unchanged screen. Renew is the only
625
+ * control-plane call that runs for the whole duration of a turn — which is
626
+ * exactly the window an approval lives in — and while a prompt is open the
627
+ * harness tightens it to `APPROVAL_RENEW_INTERVAL_MS`.
628
+ *
629
+ * ADDITIVE AND OPTIONAL, so protocol v1 is unaffected: a harness built before
630
+ * this field existed ignores an unknown key and keeps collecting commands on
631
+ * the heartbeat alone, exactly as it did.
632
+ *
633
+ * Only present when the lease was actually renewed. A runner that has lost
634
+ * its lease must not be handed a command for a turn another runner now owns.
635
+ */
636
+ sessionCommands?: SessionCommand[];
637
+ }
638
+ /**
639
+ * THE RUNTIME-NEUTRAL ROUTE. A Daytona sandbox, a customer VM and the 20x
640
+ * desktop all call exactly this, with exactly this body. Nothing in it names a
641
+ * hosting mode. If a future change makes one runtime special here, the change
642
+ * is wrong.
643
+ */
644
+ export interface IngestRequest extends ProtocolEnvelope {
645
+ /** Proves the caller still owns the session. Parts without a live lease are refused. */
646
+ leaseId: string;
647
+ parts: TranscriptPartInput[];
648
+ /**
649
+ * The EXACT MODEL-FACING messages this turn produced, appended to the
650
+ * session's durable conversation log.
651
+ *
652
+ * Sent BESIDE `parts`, never derived from them. `parts` is a UI projection —
653
+ * ephemeral markers dropped, tool calls flattened for display, streaming
654
+ * deltas overwritten in place. That shape is right for a screen and lossy for
655
+ * a prompt, and replaying it at a model is guessing at what the model was
656
+ * told. `seq` is absent here for the same reason it is absent from
657
+ * `TranscriptPartInput`: the server allocates the ordering.
658
+ */
659
+ modelMessages?: ModelMessageInput[];
660
+ /** Durable step metadata. Closes G7 — the ephemeral parts carry none. */
661
+ turn?: TurnMetadata;
662
+ /** True on the last ingest of the turn. Releases the lease. */
663
+ final?: boolean;
664
+ }
665
+ /** One model-facing message on the wire. Order is allocated server-side. */
666
+ export interface ModelMessageInput {
667
+ role: 'user' | 'assistant';
668
+ content: string;
669
+ }
670
+ /**
671
+ * What `GET /api/runners/sessions/:sessionId/history` answers.
672
+ *
673
+ * THE RUNNER'S HISTORY READ PATH. Before this existed the harness could only
674
+ * ever WRITE the conversation out; there was no way to read it back, which is
675
+ * why a runner that had not personally run the earlier turns had no way to know
676
+ * what they were.
677
+ */
678
+ export interface SessionHistoryResponse {
679
+ sessionId: string;
680
+ /** The backend checkpoint, when the control plane holds one. */
681
+ backendSessionId: string | null;
682
+ /** Which backend INSTANCE minted it — see `TurnMetadata.backendOwnerId`. */
683
+ backendOwnerId?: string | null;
684
+ /** The whole model-facing log, ordered by `seq` ascending. */
685
+ messages: Array<{
686
+ seq: number;
687
+ role: 'user' | 'assistant';
688
+ content: string;
689
+ }>;
690
+ }
691
+ export interface IngestResponse {
692
+ /** How many parts were written or updated. */
693
+ accepted: number;
694
+ /**
695
+ * Parts the control plane declined, with a reason each. A rejection is NOT
696
+ * an error: an ephemeral part is dropped on purpose and the harness should
697
+ * not retry it.
698
+ */
699
+ rejected: IngestRejection[];
700
+ /**
701
+ * The session's highest rev after this call. This is the client cursor —
702
+ * the browser passes it back as `sinceRev`.
703
+ */
704
+ maxRev: number;
705
+ /**
706
+ * Piggy-backed lease renewal. A runner that is producing output therefore
707
+ * never needs a separate renew call, which removes a whole class of
708
+ * "the lease expired while it was working" bug.
709
+ */
710
+ leaseExpiresAt: string;
711
+ }
712
+ export interface IngestRejection {
713
+ partId: string;
714
+ reason: IngestRejectionReason;
715
+ detail?: string;
716
+ }
717
+ /**
718
+ * PER-PART rejection reasons. Every one of these is reachable, and the stub
719
+ * emits each.
720
+ *
721
+ * Ownership is deliberately NOT in this list: a lease that does not own the
722
+ * session fails the WHOLE request with 409, because a batch half-written by a
723
+ * runner that no longer owns the session is worse than one not written at all.
724
+ */
725
+ export type IngestRejectionReason =
726
+ /** partType is one of the three ephemeral types. Dropped on purpose. */
727
+ 'ephemeral'
728
+ /** No content and no payload. Dropped on purpose. */
729
+ | 'empty'
730
+ /** Missing partId, bad role, unparseable createdAt, ... */
731
+ | 'malformed'
732
+ /**
733
+ * Over `MAX_INGEST_PART_BYTES`. Rejected individually, so one enormous tool
734
+ * result cannot take the rest of the batch down with it.
735
+ */
736
+ | 'too-large';
737
+ /**
738
+ * THE TRANSCRIPT PART — the shape is not negotiable.
739
+ *
740
+ * It mirrors the 20x projection field for field (20x/src/main/database.ts:1225)
741
+ * so that a cloud session can be written straight into the desktop's local
742
+ * `transcript_parts` and THE 20x TRANSCRIPT UI NEEDS NO CHANGE. Adding a field
743
+ * here without adding it there breaks that, which is the entire reason cloud
744
+ * agents in 20x are cheap.
745
+ *
746
+ * This is the READ shape. `seq` and `rev` are allocated by the control plane
747
+ * and appear on the way out; see `TranscriptPartInput` for what goes in.
748
+ */
749
+ export interface TranscriptPart {
750
+ partId: string;
751
+ /**
752
+ * SERVER-ASSIGNED. New parts take MAX(seq)+1; an existing part KEEPS its seq
753
+ * and only its content is replaced. That is precisely how token streaming
754
+ * works — a growing part must not jump to the end of the transcript on every
755
+ * delta.
756
+ */
757
+ seq: number;
758
+ role: 'user' | 'assistant' | 'system';
759
+ content: string;
760
+ /**
761
+ * A PLAIN STRING, deliberately not a union.
762
+ *
763
+ * A new part type must be addable by the harness without a control-plane
764
+ * deploy, and an unknown type must render as text rather than crash a reader.
765
+ * `PERSISTED_PART_TYPES` in ./contract.ts documents the vocabulary in use;
766
+ * it is not a closed set.
767
+ *
768
+ * The three EPHEMERAL types — 'step-start', 'step-finish', 'system-status' —
769
+ * must NEVER be persisted or ingested. They are UI-only transitions with no
770
+ * durable meaning; `TurnMetadata` is where their information actually lives.
771
+ */
772
+ partType: string;
773
+ /** Tool name for a tool part. */
774
+ tool: string | null;
775
+ /**
776
+ * Structured metadata: tool arguments, results, question options, ...
777
+ *
778
+ * Typed `JsonValue | null` rather than `unknown | null`, which TypeScript
779
+ * collapses straight back to `unknown` — the `| null` would convey nothing
780
+ * and the COALESCE distinction below would have no type-level meaning at all.
781
+ */
782
+ payload: JsonValue | null;
783
+ /** ISO 8601. NEVER overwritten on conflict — the first write wins. */
784
+ createdAt: string;
785
+ /**
786
+ * SERVER-ASSIGNED, globally monotonic, bumped on UPDATES as well as inserts.
787
+ * Allocated from a dedicated sequence, never from SELECT MAX(rev), which is
788
+ * unsafe under Postgres concurrency.
789
+ */
790
+ rev: number;
791
+ }
792
+ /**
793
+ * What the harness sends. `seq` and `rev` are absent by construction, so a
794
+ * runner cannot fabricate an ordering and cannot replay an old rev over a
795
+ * newer one.
796
+ */
797
+ export interface TranscriptPartInput {
798
+ /** Idempotency key. The upsert is on (sessionId, partId). */
799
+ partId: string;
800
+ role: 'user' | 'assistant' | 'system';
801
+ content: string;
802
+ partType: string;
803
+ /**
804
+ * `undefined` means "do not change" and is COALESCEd against the stored
805
+ * value, so a partial update can never erase metadata a fuller earlier
806
+ * update established. `null` means "explicitly clear".
807
+ *
808
+ * That distinction is the whole of upsert invariant 3, so both fields are
809
+ * typed to carry it: `?` for absent, an explicit `| null` for cleared.
810
+ */
811
+ tool?: string | null;
812
+ payload?: JsonValue | null;
813
+ /** ISO 8601. Honoured on INSERT only; a later update never moves it. */
814
+ createdAt: string;
815
+ }
816
+ /**
817
+ * Durable step metadata. The ephemeral parts that carry duration and token
818
+ * counts in 20x are never persisted, so without this the cost panel has no
819
+ * source. That is defect G7.
820
+ */
821
+ export interface TurnMetadata {
822
+ turnId: string;
823
+ startedAt: string;
824
+ finishedAt?: string;
825
+ model?: string;
826
+ inputTokens?: number;
827
+ outputTokens?: number;
828
+ cacheReadTokens?: number;
829
+ cacheWriteTokens?: number;
830
+ costUsd?: number;
831
+ /** 'completed' | 'aborted' | 'error' | 'turn_time_limit' | 'no_progress' */
832
+ stopReason?: string;
833
+ error?: string;
834
+ /** Checkout outcome for each repository requested by this session. */
835
+ repositories?: Array<{
836
+ fullName: string;
837
+ status: 'checkedOut' | 'refused' | 'failed';
838
+ reason?: string;
839
+ }>;
840
+ /**
841
+ * The backend session this turn ran on, reported so the NEXT turn can resume
842
+ * it instead of starting the model cold.
843
+ *
844
+ * Carried on the turn metadata rather than on a route of its own precisely
845
+ * because the final ingest is already the one message that is guaranteed to
846
+ * be sent — it is what releases the lease, and it is sent even when the turn
847
+ * failed. A checkpoint that travelled on a separate call would be lost in
848
+ * exactly the cases where losing it hurts most.
849
+ */
850
+ backendSessionId?: string | null;
851
+ /**
852
+ * WHICH backend minted `backendSessionId`.
853
+ *
854
+ * Sent because a checkpoint is only meaningful to the backend that created
855
+ * it — an OpenCode session id handed to Claude Code is not a session, it is a
856
+ * string. Without this the control plane stores a handle it cannot describe,
857
+ * and a runner receiving it on a cold start has no way to know whether
858
+ * resuming it is even sensible.
859
+ */
860
+ backendName?: string | null;
861
+ /**
862
+ * WHICH BACKEND INSTANCE minted `backendSessionId`.
863
+ *
864
+ * `backendName` says "opencode". It does not say WHICH opencode, and that is
865
+ * the difference between a checkpoint that works and one that has never once
866
+ * worked.
867
+ *
868
+ * An opencode session id is a key into ONE opencode state directory. Two
869
+ * runners on two machines both run "opencode", so `backendName` matches, and
870
+ * the second is then handed a `ses_...` its own server never issued. It
871
+ * answers "Session not found", the runner logs "could not resume", and the
872
+ * whole conversation is replayed and re-billed. Measured on the owner's
873
+ * machine: a conversation leased alternately to a laptop and to a
874
+ * cloud-sandbox runner did this on EVERY turn from the moment the second
875
+ * runner joined, and never once succeeded.
876
+ *
877
+ * Absent means "the runner did not say", which is treated as "not mine" —
878
+ * the fail-safe direction, because replaying once is recoverable and
879
+ * resuming someone else's handle is not.
880
+ */
881
+ backendOwnerId?: string | null;
882
+ }
883
+ /**
884
+ * Authenticated with the CURRENT runner credential. The old credential stays
885
+ * valid for a short grace window, so a rotation that races a request in flight
886
+ * does not knock the runner offline.
887
+ */
888
+ export interface CredentialRotateRequest extends ProtocolEnvelope {
889
+ harnessVersion: string;
890
+ }
891
+ export interface CredentialRotateResponse {
892
+ runnerCredential: string;
893
+ credentialExpiresAt: string;
894
+ rotateAfter: string;
895
+ /** How long the previous credential keeps working. */
896
+ previousCredentialValidForMs: number;
897
+ }
898
+ /**
899
+ * A polite goodbye. IDEMPOTENT: deregistering an already-deregistered runner
900
+ * answers 200, because the alternative is a shutdown path that can fail.
901
+ *
902
+ * The control plane must not depend on this ever arriving — a runner is killed
903
+ * far more often than it is asked to leave. Liveness is the 30s heartbeat:
904
+ * offline after three missed beats, in-flight leases requeued.
905
+ */
906
+ export interface DeregisterRequest extends ProtocolEnvelope {
907
+ reason?: string;
908
+ /** True when sessions were still running. The control plane requeues them. */
909
+ hadActiveSessions?: boolean;
910
+ }
911
+ export interface DeregisterResponse {
912
+ ok: boolean;
913
+ /** How many in-flight leases were requeued as a result. */
914
+ requeuedLeases?: number;
915
+ }
916
+ /**
917
+ * Served on 127.0.0.1 only, for a container probe. It is not an inbound
918
+ * control channel and it carries NO token: property 2 above still holds.
919
+ */
920
+ export interface HealthReport {
921
+ /**
922
+ * Deliberately NARROWER than `RunnerStatus`: it omits 'busy'. A probe asks
923
+ * "may this host be used at all", and a host mid-turn is still healthy.
924
+ * Occupancy is `activeSessions`, and it is the control plane's business, not
925
+ * a container probe's.
926
+ */
927
+ status: 'starting' | 'ready' | 'draining';
928
+ harnessVersion: string;
929
+ nodeVersion: string;
930
+ piVersion: string | null;
931
+ registered: boolean;
932
+ tenantId: string | null;
933
+ /**
934
+ * @deprecated The Phase 0 name for the runner id.
935
+ *
936
+ * STILL POPULATED, with the same value as `runnerId`. A deployed container
937
+ * probe reads this field, and a rename that breaks a probe turns a healthy
938
+ * host into an unhealthy one on the next deploy. It goes when nothing reads
939
+ * it — which is a separate, observable change, not a side effect of this one.
940
+ */
941
+ sandboxId: string | null;
942
+ degraded: boolean;
943
+ degradedReason?: string;
944
+ uptimeMs: number;
945
+ activeSessions: number;
946
+ lastHeartbeatAt: string | null;
947
+ lastHeartbeatError: string | null;
948
+ /** The protocol revision this build speaks. */
949
+ protocolVersion?: number;
950
+ /** THE CANONICAL NAME. Always populated once registered. */
951
+ runnerId?: string | null;
952
+ }