@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,178 @@
1
+ import { ControlPlaneErrorBody, CredentialRotateResponse, DeregisterResponse, IngestRequest, IngestResponse, LeaseRenewResponse, LeaseResponse, RunnerCapabilities, RunnerHeartbeatResponse, RunnerRegisterResponse, RunnerRuntime, RunnerStatus, SessionHistoryResponse } from './types';
2
+ export declare class ControlPlaneError extends Error {
3
+ readonly status: number;
4
+ /** The stable machine-readable code from the body, when the server sent one. */
5
+ readonly code?: string;
6
+ /** The floor an operator needs to see on a protocol refusal. */
7
+ readonly minProtocolVersion?: number;
8
+ readonly minHarnessVersion?: string;
9
+ constructor(message: string, status: number, body?: ControlPlaneErrorBody);
10
+ /**
11
+ * Is retrying this pointless?
12
+ *
13
+ * Delegates to the SHARED predicate rather than inlining a status list. The
14
+ * Phase 0 skeleton inlined [400, 401, 403, 404, 409] and so did not treat
15
+ * 426 Upgrade Required as final — which meant the RUNNING-FLEET version gate
16
+ * never took effect: a below-floor harness retried for ever instead of
17
+ * exiting. Import the constant; never retype the list.
18
+ */
19
+ get final(): boolean;
20
+ }
21
+ /**
22
+ * A 426 at heartbeat, lease or ingest. The control plane raised its floor under
23
+ * a runner that was already registered.
24
+ *
25
+ * A distinct class rather than a message match, so rewording an error string
26
+ * cannot quietly turn a fail-fast into a retry loop.
27
+ */
28
+ export declare class ProtocolRefusedError extends ControlPlaneError {
29
+ constructor(message: string, body?: ControlPlaneErrorBody);
30
+ }
31
+ export interface RegisterInput {
32
+ enrolmentToken: string;
33
+ capabilities: RunnerCapabilities;
34
+ runtime: RunnerRuntime;
35
+ degraded: boolean;
36
+ degradedReason?: string;
37
+ }
38
+ export interface HeartbeatInput {
39
+ status: RunnerStatus;
40
+ uptimeMs: number;
41
+ activeSessions: number;
42
+ activeSessionIds?: string[];
43
+ degraded: boolean;
44
+ capabilities: RunnerCapabilities;
45
+ }
46
+ export interface ControlPlaneClientOptions {
47
+ /** Injected in tests. Defaults to the global fetch. */
48
+ fetchImpl?: typeof fetch;
49
+ /** Injected in tests so back-off does not cost real wall-clock. */
50
+ sleepImpl?: (ms: number) => Promise<void>;
51
+ maxAttempts?: number;
52
+ baseDelayMs?: number;
53
+ }
54
+ export interface IsolatedLeaseRenewalConfig {
55
+ url: string;
56
+ runnerCredential: string;
57
+ }
58
+ /**
59
+ * THE OUTBOUND CONTROL-PLANE CLIENT.
60
+ *
61
+ * Every method here DIALS OUT. There is no server, no listener and no inverse
62
+ * channel, and there must never be one: the moment the control plane needs to
63
+ * dial a runner, a customer VM behind NAT stops working without a tunnel. Work
64
+ * arrives on `lease()`, a bounded long-poll; anything that must reach a RUNNING
65
+ * session — an abort, a user's answer — rides back on the next `heartbeat()`.
66
+ *
67
+ * The runner credential lives in this object and NOWHERE ELSE. It is never
68
+ * written to disk, never handed to a session process and never placed in argv.
69
+ */
70
+ export declare class ControlPlaneClient {
71
+ private readonly baseUrl;
72
+ private readonly fetchImpl;
73
+ private readonly sleepImpl;
74
+ private readonly maxAttempts;
75
+ private readonly baseDelayMs;
76
+ private runnerCredential;
77
+ private runnerId;
78
+ constructor(controlPlaneUrl: string, options?: ControlPlaneClientOptions);
79
+ /** True once a real, non-empty runner credential is held. */
80
+ isRegistered(): boolean;
81
+ /** Used only for a private IPC handoff to the baked sandbox supervisor. */
82
+ getRunnerCredential(): string | null;
83
+ getRunnerId(): string | null;
84
+ /**
85
+ * Give the lease-renewal worker the minimum information it needs.
86
+ *
87
+ * The worker owns only this one authenticated route. It cannot lease work,
88
+ * ingest a transcript, or call an integration. A custom fetch implementation
89
+ * means a test or embedded host owns transport, so those clients keep using
90
+ * the in-process renewer.
91
+ */
92
+ isolatedLeaseRenewalConfig(): IsolatedLeaseRenewalConfig | null;
93
+ /**
94
+ * Install a PERSISTED runner credential without going through enrolment.
95
+ *
96
+ * This is the reuse half of durable credential storage: a self-hosted or
97
+ * desktop harness that crashed and restarted loads its stored credential and
98
+ * installs it here, so `heartbeat`, `lease` and `ingest` authenticate exactly
99
+ * as they would after a fresh `register`. The credential is validated by the
100
+ * first authenticated call; a rejected one is dropped by the caller and
101
+ * enrolment is retried. See `credential-store.ts`.
102
+ */
103
+ useStoredCredential(runnerId: string, credential: string): void;
104
+ /** Drop the in-memory credential. Called on shutdown. */
105
+ forgetCredential(): void;
106
+ /**
107
+ * Exchange the enrolment token for a long-lived runner credential.
108
+ *
109
+ * Retries with exponential back-off, because a control plane that is briefly
110
+ * unreachable must not kill a host that has already booted. A FINAL status
111
+ * is never retried: a spent enrolment token would otherwise burn six attempts
112
+ * over ~31 seconds before failing with the same answer.
113
+ */
114
+ register(input: RegisterInput): Promise<RunnerRegisterResponse>;
115
+ /**
116
+ * Liveness plus capability refresh, and the ONLY inbound-shaped channel the
117
+ * contract has: `sessionCommands` on the response is how an abort or an
118
+ * answer to a question reaches a running session without anything dialling in.
119
+ */
120
+ heartbeat(input: HeartbeatInput): Promise<RunnerHeartbeatResponse>;
121
+ /**
122
+ * The bounded long-poll that replaces a socket.
123
+ *
124
+ * Returns null on 204 (no work) — poll again immediately. NOT retried here:
125
+ * the caller's loop IS the retry, and layering back-off inside a poll that is
126
+ * already bounded would only make a runner slower to pick work up.
127
+ *
128
+ * The client timeout is `wait + slack`, never the flat request timeout — a
129
+ * 25s poll under a 15s timeout would abort every single call.
130
+ */
131
+ lease(waitSeconds?: number): Promise<LeaseResponse | null>;
132
+ renewLease(leaseId: string, status?: RunnerStatus, githubTokenExpiresAt?: string, modelTokenExpiresAt?: string): Promise<LeaseRenewResponse>;
133
+ /**
134
+ * THE RUNTIME-NEUTRAL ROUTE. A Daytona sandbox, a customer VM and the 20x
135
+ * desktop all call exactly this.
136
+ *
137
+ * Retried with back-off, because losing transcript to a transient blip is a
138
+ * visible product defect — the user watches text stop arriving. Retrying is
139
+ * safe: the upsert is keyed on (sessionId, partId), so a replayed batch is
140
+ * idempotent.
141
+ */
142
+ ingest(sessionId: string, request: Omit<IngestRequest, 'protocolVersion'>): Promise<IngestResponse>;
143
+ /**
144
+ * Read the session's model-facing conversation back from the control plane.
145
+ *
146
+ * Called only when this runner does not already hold the conversation — see
147
+ * `SessionRunner.loadMemory`, which uses `resumeFromRev` to decide. NOT
148
+ * retried on the shared retry ladder: it is on the critical path of a turn
149
+ * the user is watching, and the caller already degrades gracefully to
150
+ * whatever it holds locally rather than failing the turn.
151
+ */
152
+ sessionHistory(sessionId: string, leaseId: string): Promise<SessionHistoryResponse>;
153
+ /**
154
+ * Authenticated with the CURRENT credential, which is why a harness must
155
+ * rotate ON SCHEDULE (`rotateAfter`) and must NOT try to rotate its way out
156
+ * of a 401. An expired credential cannot authenticate its own replacement,
157
+ * so a harness that tried would loop between two routes that can only fail.
158
+ */
159
+ rotateCredential(harnessVersion: string): Promise<CredentialRotateResponse>;
160
+ /**
161
+ * A polite goodbye, and the one route that must never block a shutdown. A
162
+ * failure here is logged by the caller and ignored: liveness is the 30s
163
+ * heartbeat, and the watchdog requeues in-flight leases regardless.
164
+ */
165
+ deregister(reason?: string, hadActiveSessions?: boolean): Promise<DeregisterResponse>;
166
+ private requireCredential;
167
+ private requireRunnerId;
168
+ /**
169
+ * Exponential back-off, capped by attempt count rather than by wall clock so
170
+ * that a caller can reason about the worst case: ~31s at the defaults.
171
+ *
172
+ * A FINAL status short-circuits immediately. That is the whole reason
173
+ * `FINAL_HTTP_STATUSES` is shared with the control plane instead of being
174
+ * retyped on each side.
175
+ */
176
+ private withRetry;
177
+ private send;
178
+ }
@@ -0,0 +1,431 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ControlPlaneClient = exports.ProtocolRefusedError = exports.ControlPlaneError = void 0;
4
+ const contract_1 = require("./contract");
5
+ /**
6
+ * Default request timeout. Deliberately NOT applied to the lease long-poll,
7
+ * which is bounded by its own `wait` and gets `wait + slack`.
8
+ */
9
+ const REQUEST_TIMEOUT_MS = 15_000;
10
+ /** Slack over the requested lease wait before the client gives up on its own. */
11
+ const LEASE_TIMEOUT_SLACK_MS = 10_000;
12
+ function safeParse(text) {
13
+ if (!text) {
14
+ return undefined;
15
+ }
16
+ try {
17
+ return JSON.parse(text);
18
+ }
19
+ catch {
20
+ // A non-JSON error body is perfectly possible from a proxy. The STATUS is
21
+ // what the retry decision rests on, so an unparseable body costs nothing.
22
+ return undefined;
23
+ }
24
+ }
25
+ class ControlPlaneError extends Error {
26
+ status;
27
+ /** The stable machine-readable code from the body, when the server sent one. */
28
+ code;
29
+ /** The floor an operator needs to see on a protocol refusal. */
30
+ minProtocolVersion;
31
+ minHarnessVersion;
32
+ constructor(message, status, body) {
33
+ super(message);
34
+ this.name = 'ControlPlaneError';
35
+ this.status = status;
36
+ this.code = body?.code;
37
+ this.minProtocolVersion = body?.minProtocolVersion;
38
+ this.minHarnessVersion = body?.minHarnessVersion;
39
+ }
40
+ /**
41
+ * Is retrying this pointless?
42
+ *
43
+ * Delegates to the SHARED predicate rather than inlining a status list. The
44
+ * Phase 0 skeleton inlined [400, 401, 403, 404, 409] and so did not treat
45
+ * 426 Upgrade Required as final — which meant the RUNNING-FLEET version gate
46
+ * never took effect: a below-floor harness retried for ever instead of
47
+ * exiting. Import the constant; never retype the list.
48
+ */
49
+ get final() {
50
+ return (0, contract_1.isFinalStatus)(this.status);
51
+ }
52
+ }
53
+ exports.ControlPlaneError = ControlPlaneError;
54
+ /**
55
+ * A 426 at heartbeat, lease or ingest. The control plane raised its floor under
56
+ * a runner that was already registered.
57
+ *
58
+ * A distinct class rather than a message match, so rewording an error string
59
+ * cannot quietly turn a fail-fast into a retry loop.
60
+ */
61
+ class ProtocolRefusedError extends ControlPlaneError {
62
+ constructor(message, body) {
63
+ super(message, 426, body);
64
+ this.name = 'ProtocolRefusedError';
65
+ }
66
+ }
67
+ exports.ProtocolRefusedError = ProtocolRefusedError;
68
+ function sleep(ms) {
69
+ return new Promise((resolve) => {
70
+ setTimeout(resolve, ms);
71
+ });
72
+ }
73
+ /**
74
+ * THE OUTBOUND CONTROL-PLANE CLIENT.
75
+ *
76
+ * Every method here DIALS OUT. There is no server, no listener and no inverse
77
+ * channel, and there must never be one: the moment the control plane needs to
78
+ * dial a runner, a customer VM behind NAT stops working without a tunnel. Work
79
+ * arrives on `lease()`, a bounded long-poll; anything that must reach a RUNNING
80
+ * session — an abort, a user's answer — rides back on the next `heartbeat()`.
81
+ *
82
+ * The runner credential lives in this object and NOWHERE ELSE. It is never
83
+ * written to disk, never handed to a session process and never placed in argv.
84
+ */
85
+ class ControlPlaneClient {
86
+ baseUrl;
87
+ fetchImpl;
88
+ sleepImpl;
89
+ maxAttempts;
90
+ baseDelayMs;
91
+ runnerCredential = null;
92
+ runnerId = null;
93
+ constructor(controlPlaneUrl, options = {}) {
94
+ this.baseUrl = controlPlaneUrl.replace(/\/+$/, '');
95
+ this.fetchImpl = options.fetchImpl ?? fetch;
96
+ this.sleepImpl = options.sleepImpl ?? sleep;
97
+ this.maxAttempts = options.maxAttempts ?? contract_1.DEFAULT_MAX_ATTEMPTS;
98
+ this.baseDelayMs = options.baseDelayMs ?? contract_1.DEFAULT_BASE_DELAY_MS;
99
+ }
100
+ /* ------------------------------------------------------------------ */
101
+ /* Identity */
102
+ /* ------------------------------------------------------------------ */
103
+ /** True once a real, non-empty runner credential is held. */
104
+ isRegistered() {
105
+ return (typeof this.runnerCredential === 'string' &&
106
+ this.runnerCredential.length > 0 &&
107
+ typeof this.runnerId === 'string' &&
108
+ this.runnerId.length > 0);
109
+ }
110
+ /** Used only for a private IPC handoff to the baked sandbox supervisor. */
111
+ getRunnerCredential() {
112
+ return this.runnerCredential;
113
+ }
114
+ getRunnerId() {
115
+ return this.runnerId;
116
+ }
117
+ /**
118
+ * Give the lease-renewal worker the minimum information it needs.
119
+ *
120
+ * The worker owns only this one authenticated route. It cannot lease work,
121
+ * ingest a transcript, or call an integration. A custom fetch implementation
122
+ * means a test or embedded host owns transport, so those clients keep using
123
+ * the in-process renewer.
124
+ */
125
+ isolatedLeaseRenewalConfig() {
126
+ if (this.fetchImpl !== fetch || !this.runnerId || !this.runnerCredential) {
127
+ return null;
128
+ }
129
+ return {
130
+ url: `${this.baseUrl}${contract_1.ROUTES.leaseRenew(this.runnerId, '__LEASE_ID__')}`,
131
+ runnerCredential: this.runnerCredential
132
+ };
133
+ }
134
+ /**
135
+ * Install a PERSISTED runner credential without going through enrolment.
136
+ *
137
+ * This is the reuse half of durable credential storage: a self-hosted or
138
+ * desktop harness that crashed and restarted loads its stored credential and
139
+ * installs it here, so `heartbeat`, `lease` and `ingest` authenticate exactly
140
+ * as they would after a fresh `register`. The credential is validated by the
141
+ * first authenticated call; a rejected one is dropped by the caller and
142
+ * enrolment is retried. See `credential-store.ts`.
143
+ */
144
+ useStoredCredential(runnerId, credential) {
145
+ this.runnerId = runnerId;
146
+ this.runnerCredential = credential;
147
+ }
148
+ /** Drop the in-memory credential. Called on shutdown. */
149
+ forgetCredential() {
150
+ this.runnerCredential = null;
151
+ }
152
+ /* ------------------------------------------------------------------ */
153
+ /* 1. register */
154
+ /* ------------------------------------------------------------------ */
155
+ /**
156
+ * Exchange the enrolment token for a long-lived runner credential.
157
+ *
158
+ * Retries with exponential back-off, because a control plane that is briefly
159
+ * unreachable must not kill a host that has already booted. A FINAL status
160
+ * is never retried: a spent enrolment token would otherwise burn six attempts
161
+ * over ~31 seconds before failing with the same answer.
162
+ */
163
+ async register(input) {
164
+ const response = await this.withRetry('register', () => this.send({
165
+ method: 'POST',
166
+ path: contract_1.ROUTES.register(),
167
+ token: input.enrolmentToken,
168
+ body: {
169
+ protocolVersion: contract_1.PROTOCOL_VERSION,
170
+ capabilities: input.capabilities,
171
+ runtime: input.runtime,
172
+ degraded: input.degraded,
173
+ degradedReason: input.degradedReason
174
+ }
175
+ }));
176
+ // THE VERSION GATE MUST FAIL CLOSED.
177
+ //
178
+ // Testing only `accepted === false` fails OPEN when a 200 omits the field.
179
+ // A successful registration always carries BOTH a credential and a runner
180
+ // id, so those are the real invariant: no credential means not accepted,
181
+ // whatever the response says about itself.
182
+ const credential = typeof response.runnerCredential === 'string' &&
183
+ response.runnerCredential.length > 0
184
+ ? response.runnerCredential
185
+ : null;
186
+ const id = typeof response.runnerId === 'string' && response.runnerId.length > 0
187
+ ? response.runnerId
188
+ : null;
189
+ if (response.accepted === false || !credential || !id) {
190
+ this.runnerCredential = null;
191
+ this.runnerId = null;
192
+ return response;
193
+ }
194
+ this.runnerCredential = credential;
195
+ this.runnerId = id;
196
+ return response;
197
+ }
198
+ /* ------------------------------------------------------------------ */
199
+ /* 2. heartbeat */
200
+ /* ------------------------------------------------------------------ */
201
+ /**
202
+ * Liveness plus capability refresh, and the ONLY inbound-shaped channel the
203
+ * contract has: `sessionCommands` on the response is how an abort or an
204
+ * answer to a question reaches a running session without anything dialling in.
205
+ */
206
+ async heartbeat(input) {
207
+ const runnerId = this.requireRunnerId();
208
+ const body = {
209
+ protocolVersion: contract_1.PROTOCOL_VERSION,
210
+ status: input.status,
211
+ uptimeMs: input.uptimeMs,
212
+ activeSessions: input.activeSessions,
213
+ activeSessionIds: input.activeSessionIds,
214
+ degraded: input.degraded,
215
+ capabilities: input.capabilities
216
+ };
217
+ return this.send({
218
+ method: 'POST',
219
+ path: contract_1.ROUTES.heartbeat(runnerId),
220
+ token: this.requireCredential(),
221
+ body
222
+ });
223
+ }
224
+ /* ------------------------------------------------------------------ */
225
+ /* 3. lease */
226
+ /* ------------------------------------------------------------------ */
227
+ /**
228
+ * The bounded long-poll that replaces a socket.
229
+ *
230
+ * Returns null on 204 (no work) — poll again immediately. NOT retried here:
231
+ * the caller's loop IS the retry, and layering back-off inside a poll that is
232
+ * already bounded would only make a runner slower to pick work up.
233
+ *
234
+ * The client timeout is `wait + slack`, never the flat request timeout — a
235
+ * 25s poll under a 15s timeout would abort every single call.
236
+ */
237
+ async lease(waitSeconds = contract_1.DEFAULT_LEASE_WAIT_SECONDS) {
238
+ const runnerId = this.requireRunnerId();
239
+ const path = contract_1.ROUTES.lease(runnerId, waitSeconds);
240
+ const result = await this.send({
241
+ method: 'GET',
242
+ path,
243
+ token: this.requireCredential(),
244
+ timeoutMs: waitSeconds * 1_000 + LEASE_TIMEOUT_SLACK_MS
245
+ });
246
+ return result;
247
+ }
248
+ /* ------------------------------------------------------------------ */
249
+ /* 4. lease renew */
250
+ /* ------------------------------------------------------------------ */
251
+ async renewLease(leaseId, status, githubTokenExpiresAt, modelTokenExpiresAt) {
252
+ const runnerId = this.requireRunnerId();
253
+ return this.send({
254
+ method: 'POST',
255
+ path: contract_1.ROUTES.leaseRenew(runnerId, leaseId),
256
+ token: this.requireCredential(),
257
+ body: {
258
+ protocolVersion: contract_1.PROTOCOL_VERSION,
259
+ status,
260
+ githubTokenExpiresAt,
261
+ modelTokenExpiresAt
262
+ }
263
+ });
264
+ }
265
+ /* ------------------------------------------------------------------ */
266
+ /* 5. ingest */
267
+ /* ------------------------------------------------------------------ */
268
+ /**
269
+ * THE RUNTIME-NEUTRAL ROUTE. A Daytona sandbox, a customer VM and the 20x
270
+ * desktop all call exactly this.
271
+ *
272
+ * Retried with back-off, because losing transcript to a transient blip is a
273
+ * visible product defect — the user watches text stop arriving. Retrying is
274
+ * safe: the upsert is keyed on (sessionId, partId), so a replayed batch is
275
+ * idempotent.
276
+ */
277
+ async ingest(sessionId, request) {
278
+ return this.withRetry('ingest', () => this.send({
279
+ method: 'POST',
280
+ path: contract_1.ROUTES.ingest(sessionId),
281
+ token: this.requireCredential(),
282
+ body: { protocolVersion: contract_1.PROTOCOL_VERSION, ...request }
283
+ }));
284
+ }
285
+ /* ------------------------------------------------------------------ */
286
+ /* 5b. session history — the runner's READ path */
287
+ /* ------------------------------------------------------------------ */
288
+ /**
289
+ * Read the session's model-facing conversation back from the control plane.
290
+ *
291
+ * Called only when this runner does not already hold the conversation — see
292
+ * `SessionRunner.loadMemory`, which uses `resumeFromRev` to decide. NOT
293
+ * retried on the shared retry ladder: it is on the critical path of a turn
294
+ * the user is watching, and the caller already degrades gracefully to
295
+ * whatever it holds locally rather than failing the turn.
296
+ */
297
+ async sessionHistory(sessionId, leaseId) {
298
+ return this.send({
299
+ method: 'GET',
300
+ path: contract_1.ROUTES.sessionHistory(sessionId, leaseId),
301
+ token: this.requireCredential()
302
+ });
303
+ }
304
+ /* ------------------------------------------------------------------ */
305
+ /* 6. credential rotate */
306
+ /* ------------------------------------------------------------------ */
307
+ /**
308
+ * Authenticated with the CURRENT credential, which is why a harness must
309
+ * rotate ON SCHEDULE (`rotateAfter`) and must NOT try to rotate its way out
310
+ * of a 401. An expired credential cannot authenticate its own replacement,
311
+ * so a harness that tried would loop between two routes that can only fail.
312
+ */
313
+ async rotateCredential(harnessVersion) {
314
+ const runnerId = this.requireRunnerId();
315
+ const response = await this.send({
316
+ method: 'POST',
317
+ path: contract_1.ROUTES.credentialRotate(runnerId),
318
+ token: this.requireCredential(),
319
+ body: { protocolVersion: contract_1.PROTOCOL_VERSION, harnessVersion }
320
+ });
321
+ if (typeof response.runnerCredential === 'string' &&
322
+ response.runnerCredential.length > 0) {
323
+ this.runnerCredential = response.runnerCredential;
324
+ }
325
+ return response;
326
+ }
327
+ /* ------------------------------------------------------------------ */
328
+ /* 7. deregister */
329
+ /* ------------------------------------------------------------------ */
330
+ /**
331
+ * A polite goodbye, and the one route that must never block a shutdown. A
332
+ * failure here is logged by the caller and ignored: liveness is the 30s
333
+ * heartbeat, and the watchdog requeues in-flight leases regardless.
334
+ */
335
+ async deregister(reason, hadActiveSessions) {
336
+ const runnerId = this.requireRunnerId();
337
+ return this.send({
338
+ method: 'POST',
339
+ path: contract_1.ROUTES.deregister(runnerId),
340
+ token: this.requireCredential(),
341
+ body: { protocolVersion: contract_1.PROTOCOL_VERSION, reason, hadActiveSessions }
342
+ });
343
+ }
344
+ /* ------------------------------------------------------------------ */
345
+ /* Plumbing */
346
+ /* ------------------------------------------------------------------ */
347
+ requireCredential() {
348
+ if (!this.runnerCredential) {
349
+ throw new ControlPlaneError('Not registered', 401, {
350
+ error: 'Not registered',
351
+ code: contract_1.CONTRACT_ERROR_CODES.credentialInvalid
352
+ });
353
+ }
354
+ return this.runnerCredential;
355
+ }
356
+ requireRunnerId() {
357
+ if (!this.runnerId) {
358
+ throw new ControlPlaneError('Not registered', 401, {
359
+ error: 'Not registered',
360
+ code: contract_1.CONTRACT_ERROR_CODES.runnerUnknown
361
+ });
362
+ }
363
+ return this.runnerId;
364
+ }
365
+ /**
366
+ * Exponential back-off, capped by attempt count rather than by wall clock so
367
+ * that a caller can reason about the worst case: ~31s at the defaults.
368
+ *
369
+ * A FINAL status short-circuits immediately. That is the whole reason
370
+ * `FINAL_HTTP_STATUSES` is shared with the control plane instead of being
371
+ * retyped on each side.
372
+ */
373
+ async withRetry(label, attemptFn) {
374
+ let lastError;
375
+ for (let attempt = 1; attempt <= this.maxAttempts; attempt += 1) {
376
+ try {
377
+ return await attemptFn();
378
+ }
379
+ catch (error) {
380
+ lastError = error;
381
+ if (error instanceof ControlPlaneError && error.final) {
382
+ throw error;
383
+ }
384
+ if (attempt < this.maxAttempts) {
385
+ await this.sleepImpl(this.baseDelayMs * 2 ** (attempt - 1));
386
+ }
387
+ }
388
+ }
389
+ throw lastError instanceof Error ? lastError : new Error(`${label} failed`);
390
+ }
391
+ async send(input) {
392
+ const controller = new AbortController();
393
+ const timer = setTimeout(() => controller.abort(), input.timeoutMs ?? REQUEST_TIMEOUT_MS);
394
+ try {
395
+ const headers = {
396
+ Authorization: `Bearer ${input.token}`,
397
+ // An OPTIONAL echo of the body's protocolVersion, so a proxy or an
398
+ // access log can see the version without parsing JSON. THE BODY STAYS
399
+ // AUTHORITATIVE; a server that finds the two disagreeing must refuse.
400
+ [contract_1.PROTOCOL_VERSION_HEADER]: String(contract_1.PROTOCOL_VERSION)
401
+ };
402
+ if (input.body !== undefined) {
403
+ headers['Content-Type'] = 'application/json';
404
+ }
405
+ const response = await this.fetchImpl(`${this.baseUrl}${input.path}`, {
406
+ method: input.method,
407
+ headers,
408
+ body: input.body === undefined ? undefined : JSON.stringify(input.body),
409
+ signal: controller.signal
410
+ });
411
+ // 204 is a real answer on the lease route: "no work, poll again". It is
412
+ // not an error and must not be parsed as a body.
413
+ if (response.status === 204) {
414
+ return null;
415
+ }
416
+ const text = await response.text();
417
+ if (!response.ok) {
418
+ const parsed = safeParse(text);
419
+ const message = `${input.path} returned ${response.status}: ${text.slice(0, 400)}`;
420
+ throw response.status === 426
421
+ ? new ProtocolRefusedError(message, parsed)
422
+ : new ControlPlaneError(message, response.status, parsed);
423
+ }
424
+ return (text ? JSON.parse(text) : {});
425
+ }
426
+ finally {
427
+ clearTimeout(timer);
428
+ }
429
+ }
430
+ }
431
+ exports.ControlPlaneClient = ControlPlaneClient;