@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,273 @@
1
+ /**
2
+ * The executable half of the wire contract: versions, routes, the part-type
3
+ * vocabulary and the predicates that enforce them.
4
+ *
5
+ * Everything here is PURE — no I/O, no process state, no harness behaviour.
6
+ * That is deliberate: the control plane (Track A) imports the same predicates
7
+ * the harness (Track B) does, so the two cannot disagree about what "supported"
8
+ * or "ephemeral" means. A rule written twice is a rule that drifts.
9
+ */
10
+ /**
11
+ * The wire contract revision. A plain integer, not semver: there is no useful
12
+ * distinction between a "minor" and a "patch" change to a protocol that two
13
+ * independently deployed programs must agree on.
14
+ *
15
+ * BUMP THIS when a change would make an older peer misbehave — a removed
16
+ * field, a narrowed type, a changed default, a new REQUIRED field. Do NOT bump
17
+ * it for a new OPTIONAL field: both sides already tolerate those, and bumping
18
+ * needlessly forces a fleet-wide upgrade for nothing.
19
+ *
20
+ * v1 — register, heartbeat, lease, renew, ingest, rotate, deregister,
21
+ * bootstrap. Frozen at Step 1, before Tracks A and B started.
22
+ * v2 — LONG TURNS: `LeasedLimits.maxTurnDurationMs` may now be `null` ("no
23
+ * ceiling"). A v1 harness computes `Date.now() + maxTurnDurationMs`
24
+ * directly (session-runner.ts, pre-fix), and `Date.now() + null`
25
+ * coerces to `Date.now()` — an already-past deadline that cuts the turn
26
+ * as `turn_time_limit` on its very first poll. THIS IS EXACTLY THE
27
+ * "changed default" case the rule above bumps for: the control plane's
28
+ * DEFAULT is unaffected (see `MIN_PROTOCOL_VERSION_FOR_NO_CEILING` — a
29
+ * v1 harness still gets a real number by default), and only a control
30
+ * plane an operator has explicitly configured for no ceiling at all
31
+ * needs to tell such a harness apart, which it does by refusing it
32
+ * rather than silently serving a value that would misbehave.
33
+ */
34
+ export declare const PROTOCOL_VERSION = 2;
35
+ /**
36
+ * The floor a harness must meet to be served `maxTurnDurationMs: null`.
37
+ *
38
+ * NOT the same thing as `MIN_SUPPORTED_PROTOCOL_VERSION` below, which is a
39
+ * blanket floor for the whole fleet — raising that would force every runner
40
+ * to upgrade before ANY of them can do ANYTHING, for a case (an operator
41
+ * opting a tenant out of the hard ceiling entirely) that most deployments
42
+ * never hit. This floor is checked ONLY at the moment a lease is about to
43
+ * carry `null`, so a v1 harness keeps working normally right up until an
44
+ * operator asks this control plane for the one behaviour it cannot express.
45
+ */
46
+ export declare const MIN_PROTOCOL_VERSION_FOR_NO_CEILING = 2;
47
+ /**
48
+ * The harness asks its host supervisor to install the current pin again.
49
+ * All other exit codes keep their normal process meaning.
50
+ */
51
+ export declare const HARNESS_UPGRADE_EXIT_CODE = 75;
52
+ /**
53
+ * The floor this build will talk to. The control plane holds the authoritative
54
+ * floor in configuration and may raise it above this without a harness
55
+ * release; this constant is the harness's own view, used to fail fast rather
56
+ * than to grant anything.
57
+ */
58
+ export declare const MIN_SUPPORTED_PROTOCOL_VERSION = 1;
59
+ /**
60
+ * An OPTIONAL echo of the body's `protocolVersion`.
61
+ *
62
+ * THE BODY IS AUTHORITATIVE. This header exists only so a proxy, an access log
63
+ * or a `curl -v` can see the version without parsing a JSON body — it is a
64
+ * debugging affordance, not a second source of truth, and a peer that omits it
65
+ * is perfectly conformant. `pf-bootstrap` does not send it.
66
+ *
67
+ * A server that sees it and finds it DISAGREEING with the body must refuse the
68
+ * request (400): the two disagreeing means something between the peers rewrote
69
+ * one of them, and guessing which to believe is how a version gate gets
70
+ * bypassed.
71
+ */
72
+ export declare const PROTOCOL_VERSION_HEADER = "x-pf-protocol-version";
73
+ /**
74
+ * Is a peer speaking a version this side can serve?
75
+ *
76
+ * FAILS CLOSED: a missing, non-integer, zero or negative version is refused.
77
+ * Phase 0 learned this the hard way — the first version gate tested only
78
+ * `accepted === false`, so a 200 that simply omitted the field sailed through.
79
+ * An absent version is not a permissive default; it is an unknown peer.
80
+ *
81
+ * A peer NEWER than this build is allowed. The contract only ever adds
82
+ * optional fields within a version, and the newer side is responsible for
83
+ * remaining readable by the older one. Refusing forward would make every
84
+ * control-plane deploy a synchronised fleet upgrade.
85
+ */
86
+ export declare function isProtocolSupported(peerProtocolVersion: unknown, floor?: number): boolean;
87
+ /**
88
+ * Statuses that are FINAL. Never retried, at any route.
89
+ *
90
+ * Matched on the STATUS and never on the message, so that rewording an error
91
+ * string cannot quietly turn a fail-fast into six attempts over ~31 seconds.
92
+ *
93
+ * 426 Upgrade Required is the RUNNING-FLEET version gate. Registration is
94
+ * gated by `accepted: false`, but a floor can also be raised under runners
95
+ * that are already registered; they then meet 426 at heartbeat, lease or
96
+ * ingest and must exit rather than hammer a control plane that will never
97
+ * serve them.
98
+ *
99
+ * 401 IS FINAL, AND THAT IS DELIBERATE — a harness must NOT try to rotate its
100
+ * way out of one. Rotation is authenticated with the CURRENT credential, so a
101
+ * credential that has already expired cannot rotate itself; a harness that
102
+ * tried would loop between two routes that can only both fail. `rotateAfter`
103
+ * is the mechanism: rotate on schedule, comfortably before expiry, and treat
104
+ * an actual 401 as "this host is finished, exit and be re-enrolled".
105
+ *
106
+ * NOTE FOR TRACK B: `harness.ts` currently inlines [400, 401, 403, 404, 409]
107
+ * and so does NOT treat 426 as final. Import this constant instead of
108
+ * re-typing the list when the skeleton body is replaced.
109
+ */
110
+ export declare const FINAL_HTTP_STATUSES: readonly number[];
111
+ export declare function isFinalStatus(status: number): boolean;
112
+ /** Stable, machine-readable error codes. */
113
+ export declare const CONTRACT_ERROR_CODES: {
114
+ readonly protocolUnsupported: "protocol_version_unsupported";
115
+ readonly harnessVersionUnsupported: "harness_version_unsupported";
116
+ readonly enrolmentTokenInvalid: "enrolment_token_invalid";
117
+ readonly enrolmentTokenSpent: "enrolment_token_spent";
118
+ readonly credentialInvalid: "runner_credential_invalid";
119
+ readonly credentialExpired: "runner_credential_expired";
120
+ readonly runnerUnknown: "runner_unknown";
121
+ readonly runnerDisabled: "runner_disabled";
122
+ readonly leaseUnknown: "lease_unknown";
123
+ readonly leaseExpired: "lease_expired";
124
+ readonly leaseNotOwned: "lease_not_owned";
125
+ readonly sessionUnknown: "session_unknown";
126
+ readonly rateLimited: "rate_limited";
127
+ };
128
+ /**
129
+ * How long the lease route holds a poll open.
130
+ *
131
+ * 25s matches the existing `boundSseStream` heartbeat, and it is chosen
132
+ * against Cloud Run, which bills a request slot for the WHOLE life of a
133
+ * connection and caps it at 3600s. That economics is what rules out one
134
+ * persistent WebSocket per runner and produces this long-poll in its place.
135
+ */
136
+ export declare const DEFAULT_LEASE_WAIT_SECONDS = 25;
137
+ /** A poll longer than this wastes a Cloud Run request slot. */
138
+ export declare const MAX_LEASE_WAIT_SECONDS = 60;
139
+ /** Liveness: 30s beats, offline after three missed, in-flight leases requeued. */
140
+ export declare const DEFAULT_HEARTBEAT_INTERVAL_MS = 30000;
141
+ export declare const MISSED_HEARTBEATS_BEFORE_OFFLINE = 3;
142
+ /** Visibility timeout. Every successful ingest extends it by this much. */
143
+ export declare const DEFAULT_LEASE_TTL_MS = 120000;
144
+ /** Registration and ingest back-off, shared so both tracks agree. */
145
+ export declare const DEFAULT_MAX_ATTEMPTS = 6;
146
+ export declare const DEFAULT_BASE_DELAY_MS = 1000;
147
+ /**
148
+ * The ceilings behind the `too-large` rejection reason.
149
+ *
150
+ * They are stated here rather than left to Track A because a limit only one
151
+ * side knows is not a limit — the harness would discover it as an opaque
152
+ * rejection mid-turn, with a partly-written transcript and no way to tell a
153
+ * transient failure from a permanent one.
154
+ *
155
+ * A part over `MAX_INGEST_PART_BYTES` is rejected individually, so one
156
+ * enormous tool result cannot take the whole batch down with it. A batch over
157
+ * `MAX_INGEST_PARTS` is rejected whole, with 413.
158
+ */
159
+ export declare const MAX_INGEST_PART_BYTES = 1000000;
160
+ export declare const MAX_INGEST_PARTS = 500;
161
+ /** UTF-8 byte length of a part's content plus its serialised payload. */
162
+ export declare function ingestPartByteLength(part: {
163
+ content?: unknown;
164
+ payload?: unknown;
165
+ }): number;
166
+ /**
167
+ * The route table. Ids are interpolated with `encodeURIComponent`, so a runner
168
+ * or session id containing a slash cannot walk out of its own path.
169
+ *
170
+ * EVERY route here is called BY THE HARNESS, OUTBOUND. There is no inverse
171
+ * table, and there must never be one: the moment the control plane needs to
172
+ * dial a runner, a customer VM behind NAT stops working without a tunnel.
173
+ */
174
+ export declare const ROUTES: {
175
+ /** Called by pf-bootstrap, before the harness exists. Bearer: enrolment token. */
176
+ readonly bootstrap: () => string;
177
+ /** Bearer: enrolment token. Burns it, returns the runner credential. */
178
+ readonly register: () => string;
179
+ /** Bearer: runner credential. 30s period. */
180
+ readonly heartbeat: (runnerId: string) => string;
181
+ /**
182
+ * Bearer: runner credential. Bounded long-poll: 204 idle, 200 with work.
183
+ *
184
+ * `wait` is CLAMPED here rather than left to the server to trim silently. A
185
+ * harness that asked for 300s and was quietly given 60s would look, from its
186
+ * own logs, like a control plane returning 204 five times too often.
187
+ *
188
+ * `protocolVersion` rides in the QUERY STRING because this is the one
189
+ * gated route with no request body to put it in. Leaving it off would make
190
+ * lease the single route a below-floor runner could still call — and lease
191
+ * is the route it would call hardest, in a tight poll loop, precisely when
192
+ * it should be exiting.
193
+ */
194
+ readonly lease: (runnerId: string, waitSeconds?: number, protocolVersion?: number) => string;
195
+ /** Bearer: runner credential. Visibility-timeout renewal. */
196
+ readonly leaseRenew: (runnerId: string, leaseId: string) => string;
197
+ /**
198
+ * Bearer: runner credential. THE RUNTIME-NEUTRAL ROUTE — a Daytona sandbox,
199
+ * a customer VM and the 20x desktop all call this identical path.
200
+ */
201
+ readonly ingest: (sessionId: string) => string;
202
+ /**
203
+ * The runner's HISTORY READ PATH.
204
+ *
205
+ * Bearer: the runner credential, scoped to a live lease on the session. Added
206
+ * because until it existed the harness could only ever WRITE a conversation
207
+ * out — there was no way to read one back, so a runner that had not
208
+ * personally run the earlier turns had no way to learn what they were, and
209
+ * the model started every turn blank.
210
+ */
211
+ readonly sessionHistory: (sessionId: string, leaseId: string) => string;
212
+ /** Bearer: the CURRENT runner credential. */
213
+ readonly credentialRotate: (runnerId: string) => string;
214
+ /** Bearer: runner credential. Idempotent. */
215
+ readonly deregister: (runnerId: string) => string;
216
+ };
217
+ /**
218
+ * The part-type vocabulary in use today.
219
+ *
220
+ * THIS IS NOT A CLOSED SET, and `TranscriptPart.partType` is deliberately
221
+ * `string`. A new part type must be addable by the harness without a
222
+ * control-plane deploy, and an unknown type must render as text rather than
223
+ * crash a reader. Use this list for documentation and UI switches, never as a
224
+ * validation allowlist.
225
+ */
226
+ export declare const PERSISTED_PART_TYPES: readonly string[];
227
+ /**
228
+ * THE CLOSED SET. These three are UI-only transitions and MUST NEVER be
229
+ * persisted or ingested.
230
+ *
231
+ * They are frame markers, not content: they carry duration and token counts
232
+ * that belong in `TurnMetadata` (defect G7), and persisting them would put
233
+ * thousands of meaningless rows in every transcript and stream them to every
234
+ * reader. The control plane rejects them at ingest with reason 'ephemeral',
235
+ * which is NOT an error — the harness must not retry a dropped part.
236
+ *
237
+ * Unlike the vocabulary above, this list IS exhaustive and IS enforced.
238
+ */
239
+ export declare const EPHEMERAL_PART_TYPES: readonly string[];
240
+ /**
241
+ * Must this part be dropped rather than stored?
242
+ *
243
+ * Compared case-insensitively after trimming. A part type is produced by
244
+ * several different backends, and ' Step-Start ' being persisted because of a
245
+ * stray space would be a silent, permanent corruption of the projection.
246
+ */
247
+ export declare function isEphemeralPartType(partType: unknown): boolean;
248
+ /**
249
+ * Is this part worth storing at all?
250
+ *
251
+ * Drops the ephemeral three, and drops parts with neither content nor payload.
252
+ * An empty part is usually a stream artefact — the opening frame of a text
253
+ * part that never received a delta — and storing it puts a blank bubble in the
254
+ * UI for ever.
255
+ *
256
+ * A part with a payload but no content is KEPT: a tool call with arguments and
257
+ * no textual body is perfectly ordinary.
258
+ *
259
+ * `isUpdate` MATTERS, and getting it wrong is subtle. On an update,
260
+ * `payload: null` is a deliberate CLEAR (invariant 3), not an absence — so a
261
+ * caller sending empty content together with `payload: null` to wipe a
262
+ * previously-set payload must reach the coalesce step. Treating that as
263
+ * "empty" would drop it here, silently, and the contract documents rejections
264
+ * as "not an error" — so the caller would never learn the clear had not
265
+ * happened. On an INSERT the same body really is an empty part, and is dropped.
266
+ */
267
+ export declare function isPersistablePart(part: {
268
+ partType?: unknown;
269
+ content?: unknown;
270
+ payload?: unknown;
271
+ }, options?: {
272
+ isUpdate?: boolean;
273
+ }): boolean;
@@ -0,0 +1,357 @@
1
+ "use strict";
2
+ /**
3
+ * The executable half of the wire contract: versions, routes, the part-type
4
+ * vocabulary and the predicates that enforce them.
5
+ *
6
+ * Everything here is PURE — no I/O, no process state, no harness behaviour.
7
+ * That is deliberate: the control plane (Track A) imports the same predicates
8
+ * the harness (Track B) does, so the two cannot disagree about what "supported"
9
+ * or "ephemeral" means. A rule written twice is a rule that drifts.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.EPHEMERAL_PART_TYPES = exports.PERSISTED_PART_TYPES = exports.ROUTES = exports.MAX_INGEST_PARTS = exports.MAX_INGEST_PART_BYTES = exports.DEFAULT_BASE_DELAY_MS = exports.DEFAULT_MAX_ATTEMPTS = exports.DEFAULT_LEASE_TTL_MS = exports.MISSED_HEARTBEATS_BEFORE_OFFLINE = exports.DEFAULT_HEARTBEAT_INTERVAL_MS = exports.MAX_LEASE_WAIT_SECONDS = exports.DEFAULT_LEASE_WAIT_SECONDS = exports.CONTRACT_ERROR_CODES = exports.FINAL_HTTP_STATUSES = exports.PROTOCOL_VERSION_HEADER = exports.MIN_SUPPORTED_PROTOCOL_VERSION = exports.HARNESS_UPGRADE_EXIT_CODE = exports.MIN_PROTOCOL_VERSION_FOR_NO_CEILING = exports.PROTOCOL_VERSION = void 0;
13
+ exports.isProtocolSupported = isProtocolSupported;
14
+ exports.isFinalStatus = isFinalStatus;
15
+ exports.ingestPartByteLength = ingestPartByteLength;
16
+ exports.isEphemeralPartType = isEphemeralPartType;
17
+ exports.isPersistablePart = isPersistablePart;
18
+ /* -------------------------------------------------------------------------- */
19
+ /* Versioning */
20
+ /* -------------------------------------------------------------------------- */
21
+ /**
22
+ * The wire contract revision. A plain integer, not semver: there is no useful
23
+ * distinction between a "minor" and a "patch" change to a protocol that two
24
+ * independently deployed programs must agree on.
25
+ *
26
+ * BUMP THIS when a change would make an older peer misbehave — a removed
27
+ * field, a narrowed type, a changed default, a new REQUIRED field. Do NOT bump
28
+ * it for a new OPTIONAL field: both sides already tolerate those, and bumping
29
+ * needlessly forces a fleet-wide upgrade for nothing.
30
+ *
31
+ * v1 — register, heartbeat, lease, renew, ingest, rotate, deregister,
32
+ * bootstrap. Frozen at Step 1, before Tracks A and B started.
33
+ * v2 — LONG TURNS: `LeasedLimits.maxTurnDurationMs` may now be `null` ("no
34
+ * ceiling"). A v1 harness computes `Date.now() + maxTurnDurationMs`
35
+ * directly (session-runner.ts, pre-fix), and `Date.now() + null`
36
+ * coerces to `Date.now()` — an already-past deadline that cuts the turn
37
+ * as `turn_time_limit` on its very first poll. THIS IS EXACTLY THE
38
+ * "changed default" case the rule above bumps for: the control plane's
39
+ * DEFAULT is unaffected (see `MIN_PROTOCOL_VERSION_FOR_NO_CEILING` — a
40
+ * v1 harness still gets a real number by default), and only a control
41
+ * plane an operator has explicitly configured for no ceiling at all
42
+ * needs to tell such a harness apart, which it does by refusing it
43
+ * rather than silently serving a value that would misbehave.
44
+ */
45
+ exports.PROTOCOL_VERSION = 2;
46
+ /**
47
+ * The floor a harness must meet to be served `maxTurnDurationMs: null`.
48
+ *
49
+ * NOT the same thing as `MIN_SUPPORTED_PROTOCOL_VERSION` below, which is a
50
+ * blanket floor for the whole fleet — raising that would force every runner
51
+ * to upgrade before ANY of them can do ANYTHING, for a case (an operator
52
+ * opting a tenant out of the hard ceiling entirely) that most deployments
53
+ * never hit. This floor is checked ONLY at the moment a lease is about to
54
+ * carry `null`, so a v1 harness keeps working normally right up until an
55
+ * operator asks this control plane for the one behaviour it cannot express.
56
+ */
57
+ exports.MIN_PROTOCOL_VERSION_FOR_NO_CEILING = 2;
58
+ /**
59
+ * The harness asks its host supervisor to install the current pin again.
60
+ * All other exit codes keep their normal process meaning.
61
+ */
62
+ exports.HARNESS_UPGRADE_EXIT_CODE = 75;
63
+ /**
64
+ * The floor this build will talk to. The control plane holds the authoritative
65
+ * floor in configuration and may raise it above this without a harness
66
+ * release; this constant is the harness's own view, used to fail fast rather
67
+ * than to grant anything.
68
+ */
69
+ exports.MIN_SUPPORTED_PROTOCOL_VERSION = 1;
70
+ /**
71
+ * An OPTIONAL echo of the body's `protocolVersion`.
72
+ *
73
+ * THE BODY IS AUTHORITATIVE. This header exists only so a proxy, an access log
74
+ * or a `curl -v` can see the version without parsing a JSON body — it is a
75
+ * debugging affordance, not a second source of truth, and a peer that omits it
76
+ * is perfectly conformant. `pf-bootstrap` does not send it.
77
+ *
78
+ * A server that sees it and finds it DISAGREEING with the body must refuse the
79
+ * request (400): the two disagreeing means something between the peers rewrote
80
+ * one of them, and guessing which to believe is how a version gate gets
81
+ * bypassed.
82
+ */
83
+ exports.PROTOCOL_VERSION_HEADER = 'x-pf-protocol-version';
84
+ /**
85
+ * Is a peer speaking a version this side can serve?
86
+ *
87
+ * FAILS CLOSED: a missing, non-integer, zero or negative version is refused.
88
+ * Phase 0 learned this the hard way — the first version gate tested only
89
+ * `accepted === false`, so a 200 that simply omitted the field sailed through.
90
+ * An absent version is not a permissive default; it is an unknown peer.
91
+ *
92
+ * A peer NEWER than this build is allowed. The contract only ever adds
93
+ * optional fields within a version, and the newer side is responsible for
94
+ * remaining readable by the older one. Refusing forward would make every
95
+ * control-plane deploy a synchronised fleet upgrade.
96
+ */
97
+ function isProtocolSupported(peerProtocolVersion, floor = exports.MIN_SUPPORTED_PROTOCOL_VERSION) {
98
+ return (typeof peerProtocolVersion === 'number' &&
99
+ Number.isInteger(peerProtocolVersion) &&
100
+ peerProtocolVersion > 0 &&
101
+ peerProtocolVersion >= floor);
102
+ }
103
+ /* -------------------------------------------------------------------------- */
104
+ /* Retry semantics */
105
+ /* -------------------------------------------------------------------------- */
106
+ /**
107
+ * Statuses that are FINAL. Never retried, at any route.
108
+ *
109
+ * Matched on the STATUS and never on the message, so that rewording an error
110
+ * string cannot quietly turn a fail-fast into six attempts over ~31 seconds.
111
+ *
112
+ * 426 Upgrade Required is the RUNNING-FLEET version gate. Registration is
113
+ * gated by `accepted: false`, but a floor can also be raised under runners
114
+ * that are already registered; they then meet 426 at heartbeat, lease or
115
+ * ingest and must exit rather than hammer a control plane that will never
116
+ * serve them.
117
+ *
118
+ * 401 IS FINAL, AND THAT IS DELIBERATE — a harness must NOT try to rotate its
119
+ * way out of one. Rotation is authenticated with the CURRENT credential, so a
120
+ * credential that has already expired cannot rotate itself; a harness that
121
+ * tried would loop between two routes that can only both fail. `rotateAfter`
122
+ * is the mechanism: rotate on schedule, comfortably before expiry, and treat
123
+ * an actual 401 as "this host is finished, exit and be re-enrolled".
124
+ *
125
+ * NOTE FOR TRACK B: `harness.ts` currently inlines [400, 401, 403, 404, 409]
126
+ * and so does NOT treat 426 as final. Import this constant instead of
127
+ * re-typing the list when the skeleton body is replaced.
128
+ */
129
+ exports.FINAL_HTTP_STATUSES = [
130
+ 400, 401, 403, 404, 409, 426
131
+ ];
132
+ function isFinalStatus(status) {
133
+ return exports.FINAL_HTTP_STATUSES.includes(status);
134
+ }
135
+ /** Stable, machine-readable error codes. */
136
+ exports.CONTRACT_ERROR_CODES = {
137
+ protocolUnsupported: 'protocol_version_unsupported',
138
+ harnessVersionUnsupported: 'harness_version_unsupported',
139
+ enrolmentTokenInvalid: 'enrolment_token_invalid',
140
+ enrolmentTokenSpent: 'enrolment_token_spent',
141
+ credentialInvalid: 'runner_credential_invalid',
142
+ credentialExpired: 'runner_credential_expired',
143
+ runnerUnknown: 'runner_unknown',
144
+ runnerDisabled: 'runner_disabled',
145
+ leaseUnknown: 'lease_unknown',
146
+ leaseExpired: 'lease_expired',
147
+ leaseNotOwned: 'lease_not_owned',
148
+ sessionUnknown: 'session_unknown',
149
+ rateLimited: 'rate_limited'
150
+ };
151
+ /* -------------------------------------------------------------------------- */
152
+ /* Timing */
153
+ /* -------------------------------------------------------------------------- */
154
+ /**
155
+ * How long the lease route holds a poll open.
156
+ *
157
+ * 25s matches the existing `boundSseStream` heartbeat, and it is chosen
158
+ * against Cloud Run, which bills a request slot for the WHOLE life of a
159
+ * connection and caps it at 3600s. That economics is what rules out one
160
+ * persistent WebSocket per runner and produces this long-poll in its place.
161
+ */
162
+ exports.DEFAULT_LEASE_WAIT_SECONDS = 25;
163
+ /** A poll longer than this wastes a Cloud Run request slot. */
164
+ exports.MAX_LEASE_WAIT_SECONDS = 60;
165
+ /** Liveness: 30s beats, offline after three missed, in-flight leases requeued. */
166
+ exports.DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
167
+ exports.MISSED_HEARTBEATS_BEFORE_OFFLINE = 3;
168
+ /** Visibility timeout. Every successful ingest extends it by this much. */
169
+ exports.DEFAULT_LEASE_TTL_MS = 120_000;
170
+ /** Registration and ingest back-off, shared so both tracks agree. */
171
+ exports.DEFAULT_MAX_ATTEMPTS = 6;
172
+ exports.DEFAULT_BASE_DELAY_MS = 1_000;
173
+ /* -------------------------------------------------------------------------- */
174
+ /* Ingest ceilings */
175
+ /* -------------------------------------------------------------------------- */
176
+ /**
177
+ * The ceilings behind the `too-large` rejection reason.
178
+ *
179
+ * They are stated here rather than left to Track A because a limit only one
180
+ * side knows is not a limit — the harness would discover it as an opaque
181
+ * rejection mid-turn, with a partly-written transcript and no way to tell a
182
+ * transient failure from a permanent one.
183
+ *
184
+ * A part over `MAX_INGEST_PART_BYTES` is rejected individually, so one
185
+ * enormous tool result cannot take the whole batch down with it. A batch over
186
+ * `MAX_INGEST_PARTS` is rejected whole, with 413.
187
+ */
188
+ exports.MAX_INGEST_PART_BYTES = 1_000_000;
189
+ exports.MAX_INGEST_PARTS = 500;
190
+ /** UTF-8 byte length of a part's content plus its serialised payload. */
191
+ function ingestPartByteLength(part) {
192
+ const content = typeof part.content === 'string' ? part.content : '';
193
+ const payload = part.payload === undefined || part.payload === null
194
+ ? ''
195
+ : JSON.stringify(part.payload);
196
+ // Byte length, not string length: a transcript is full of non-ASCII, and
197
+ // measuring in UTF-16 code units would let a limit be exceeded by ~3x.
198
+ return (Buffer.byteLength(content, 'utf8') + Buffer.byteLength(payload, 'utf8'));
199
+ }
200
+ /* -------------------------------------------------------------------------- */
201
+ /* Routes */
202
+ /* -------------------------------------------------------------------------- */
203
+ /**
204
+ * The route table. Ids are interpolated with `encodeURIComponent`, so a runner
205
+ * or session id containing a slash cannot walk out of its own path.
206
+ *
207
+ * EVERY route here is called BY THE HARNESS, OUTBOUND. There is no inverse
208
+ * table, and there must never be one: the moment the control plane needs to
209
+ * dial a runner, a customer VM behind NAT stops working without a tunnel.
210
+ */
211
+ exports.ROUTES = {
212
+ /** Called by pf-bootstrap, before the harness exists. Bearer: enrolment token. */
213
+ bootstrap: () => '/api/coding-agents/harness/bootstrap',
214
+ /** Bearer: enrolment token. Burns it, returns the runner credential. */
215
+ register: () => '/api/runners/register',
216
+ /** Bearer: runner credential. 30s period. */
217
+ heartbeat: (runnerId) => `/api/runners/${encodeURIComponent(runnerId)}/heartbeat`,
218
+ /**
219
+ * Bearer: runner credential. Bounded long-poll: 204 idle, 200 with work.
220
+ *
221
+ * `wait` is CLAMPED here rather than left to the server to trim silently. A
222
+ * harness that asked for 300s and was quietly given 60s would look, from its
223
+ * own logs, like a control plane returning 204 five times too often.
224
+ *
225
+ * `protocolVersion` rides in the QUERY STRING because this is the one
226
+ * gated route with no request body to put it in. Leaving it off would make
227
+ * lease the single route a below-floor runner could still call — and lease
228
+ * is the route it would call hardest, in a tight poll loop, precisely when
229
+ * it should be exiting.
230
+ */
231
+ lease: (runnerId, waitSeconds = exports.DEFAULT_LEASE_WAIT_SECONDS, protocolVersion = exports.PROTOCOL_VERSION) => {
232
+ const wait = Math.min(Math.max(Math.trunc(waitSeconds) || 0, 0), exports.MAX_LEASE_WAIT_SECONDS);
233
+ return `/api/runners/${encodeURIComponent(runnerId)}/lease?wait=${wait}&protocolVersion=${protocolVersion}`;
234
+ },
235
+ /** Bearer: runner credential. Visibility-timeout renewal. */
236
+ leaseRenew: (runnerId, leaseId) => `/api/runners/${encodeURIComponent(runnerId)}/lease/${encodeURIComponent(leaseId)}/renew`,
237
+ /**
238
+ * Bearer: runner credential. THE RUNTIME-NEUTRAL ROUTE — a Daytona sandbox,
239
+ * a customer VM and the 20x desktop all call this identical path.
240
+ */
241
+ ingest: (sessionId) => `/api/coding-agents/sessions/${encodeURIComponent(sessionId)}/ingest`,
242
+ /**
243
+ * The runner's HISTORY READ PATH.
244
+ *
245
+ * Bearer: the runner credential, scoped to a live lease on the session. Added
246
+ * because until it existed the harness could only ever WRITE a conversation
247
+ * out — there was no way to read one back, so a runner that had not
248
+ * personally run the earlier turns had no way to learn what they were, and
249
+ * the model started every turn blank.
250
+ */
251
+ sessionHistory: (sessionId, leaseId) => `/api/coding-agents/sessions/${encodeURIComponent(sessionId)}/history` +
252
+ `?leaseId=${encodeURIComponent(leaseId)}`,
253
+ /** Bearer: the CURRENT runner credential. */
254
+ credentialRotate: (runnerId) => `/api/runners/${encodeURIComponent(runnerId)}/credential/rotate`,
255
+ /** Bearer: runner credential. Idempotent. */
256
+ deregister: (runnerId) => `/api/runners/${encodeURIComponent(runnerId)}/deregister`
257
+ };
258
+ /*
259
+ * THE PHASE 0 ROUTES ARE GONE.
260
+ *
261
+ * `/api/coding-agents/harness/register` and `.../heartbeat` were kept through
262
+ * Step 1 only so the sandbox e2e suite could drive the real image while the
263
+ * harness was still a skeleton. They were never aliases of the canonical
264
+ * `/api/runners/*` pair — the shapes differed, and the old request carried no
265
+ * `protocolVersion`, so THAT PATH COULD NOT BE PROTOCOL-VERSION GATED AT ALL.
266
+ * For as long as it existed, the only registration route running in production
267
+ * was exempt from half of the versioning guarantee.
268
+ *
269
+ * Track B deleted both, and the exemption went with them. Every route in this
270
+ * file now carries a version the control plane can refuse.
271
+ */
272
+ /* -------------------------------------------------------------------------- */
273
+ /* Transcript part types */
274
+ /* -------------------------------------------------------------------------- */
275
+ /**
276
+ * The part-type vocabulary in use today.
277
+ *
278
+ * THIS IS NOT A CLOSED SET, and `TranscriptPart.partType` is deliberately
279
+ * `string`. A new part type must be addable by the harness without a
280
+ * control-plane deploy, and an unknown type must render as text rather than
281
+ * crash a reader. Use this list for documentation and UI switches, never as a
282
+ * validation allowlist.
283
+ */
284
+ exports.PERSISTED_PART_TYPES = [
285
+ 'text',
286
+ 'tool',
287
+ 'question',
288
+ 'reasoning',
289
+ 'error',
290
+ 'todowrite',
291
+ 'task_progress',
292
+ 'task-notification',
293
+ 'planreview',
294
+ 'retry'
295
+ ];
296
+ /**
297
+ * THE CLOSED SET. These three are UI-only transitions and MUST NEVER be
298
+ * persisted or ingested.
299
+ *
300
+ * They are frame markers, not content: they carry duration and token counts
301
+ * that belong in `TurnMetadata` (defect G7), and persisting them would put
302
+ * thousands of meaningless rows in every transcript and stream them to every
303
+ * reader. The control plane rejects them at ingest with reason 'ephemeral',
304
+ * which is NOT an error — the harness must not retry a dropped part.
305
+ *
306
+ * Unlike the vocabulary above, this list IS exhaustive and IS enforced.
307
+ */
308
+ exports.EPHEMERAL_PART_TYPES = [
309
+ 'step-start',
310
+ 'step-finish',
311
+ 'system-status'
312
+ ];
313
+ /**
314
+ * Must this part be dropped rather than stored?
315
+ *
316
+ * Compared case-insensitively after trimming. A part type is produced by
317
+ * several different backends, and ' Step-Start ' being persisted because of a
318
+ * stray space would be a silent, permanent corruption of the projection.
319
+ */
320
+ function isEphemeralPartType(partType) {
321
+ return (typeof partType === 'string' &&
322
+ exports.EPHEMERAL_PART_TYPES.includes(partType.trim().toLowerCase()));
323
+ }
324
+ /**
325
+ * Is this part worth storing at all?
326
+ *
327
+ * Drops the ephemeral three, and drops parts with neither content nor payload.
328
+ * An empty part is usually a stream artefact — the opening frame of a text
329
+ * part that never received a delta — and storing it puts a blank bubble in the
330
+ * UI for ever.
331
+ *
332
+ * A part with a payload but no content is KEPT: a tool call with arguments and
333
+ * no textual body is perfectly ordinary.
334
+ *
335
+ * `isUpdate` MATTERS, and getting it wrong is subtle. On an update,
336
+ * `payload: null` is a deliberate CLEAR (invariant 3), not an absence — so a
337
+ * caller sending empty content together with `payload: null` to wipe a
338
+ * previously-set payload must reach the coalesce step. Treating that as
339
+ * "empty" would drop it here, silently, and the contract documents rejections
340
+ * as "not an error" — so the caller would never learn the clear had not
341
+ * happened. On an INSERT the same body really is an empty part, and is dropped.
342
+ */
343
+ function isPersistablePart(part, options = {}) {
344
+ if (isEphemeralPartType(part.partType)) {
345
+ return false;
346
+ }
347
+ const hasContent = typeof part.content === 'string' && part.content.trim().length > 0;
348
+ if (hasContent) {
349
+ return true;
350
+ }
351
+ // A real payload always earns the part its place.
352
+ if (part.payload !== undefined && part.payload !== null) {
353
+ return true;
354
+ }
355
+ // An explicit null on an EXISTING part is a clear, and must be let through.
356
+ return options.isUpdate === true && part.payload === null;
357
+ }