@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,520 @@
1
+ /**
2
+ * THE SESSION RUNNER — one leased turn, start to finish.
3
+ *
4
+ * This is the loop 20x's `AgentManager` runs, reduced to the part that is
5
+ * actually about running an agent: prepare a workspace, wire the MCP servers
6
+ * through the loopback auth proxy, drive a backend adapter, and push every part
7
+ * it produces into the durable projection and then out to the control plane.
8
+ *
9
+ * WHAT IT DELIBERATELY IS NOT. `AgentManager` also owns tasks, worktrees, pull
10
+ * requests, plugin actions, skills sync, output-field extraction and heartbeat
11
+ * scheduling. None of that is a property of running an agent; all of it is a
12
+ * property of 20x's TASK model. Dragging it across would have made this package
13
+ * "20x without a window" rather than a harness, and would have required the
14
+ * control plane to grow a task table to match. The seam is cut at the turn.
15
+ *
16
+ * THE TURN IS THE UNIT OF WORK, which is what makes the lease design work: one
17
+ * lease is one turn, a lease that expires is requeued, and a runner that dies
18
+ * mid-turn loses at most that turn — because the parts it already produced were
19
+ * ingested as it went, not at the end.
20
+ */
21
+ import type { CodingAgentAdapter } from '../adapters';
22
+ import { SessionModelGateway } from '../adapters/model-gateway';
23
+ import { ArtifactInspector, ConversationStore, EventSink, ModelMessageRecord, Notifier, SecretProvider, Telemetry, TranscriptStore, WorkspaceProvider } from '../ports';
24
+ import { LeaseResponse, ModelMessageInput, TranscriptPartInput, TurnMetadata } from '../types';
25
+ import { McpAuthProxy } from './mcp-auth-proxy';
26
+ export interface SessionRunnerDeps {
27
+ /** Resolves a backend name from the lease to a working adapter. */
28
+ getAdapter(backend: string): CodingAgentAdapter;
29
+ /**
30
+ * The per-turn model-gateway credential holder the adapter reads from.
31
+ *
32
+ * Optional so the existing tests, which drive a fake adapter that never
33
+ * consults a gateway, keep constructing a runner without one.
34
+ */
35
+ modelGateway?: SessionModelGateway;
36
+ store: TranscriptStore;
37
+ workspace: WorkspaceProvider;
38
+ proxy: McpAuthProxy;
39
+ secrets: SecretProvider;
40
+ events: EventSink;
41
+ telemetry: Telemetry;
42
+ notifier: Notifier;
43
+ artifacts: ArtifactInspector;
44
+ /**
45
+ * Push a batch out. Returns the control plane's new cursor.
46
+ *
47
+ * Injected rather than taking a `ControlPlaneClient`, so the runner can be
48
+ * tested — and so the 20x desktop can later reuse it with an ingest that
49
+ * writes to the local database instead of over HTTPS.
50
+ */
51
+ ingest(sessionId: string, parts: TranscriptPartInput[], turn: TurnMetadata | undefined, final: boolean, modelMessages?: ModelMessageInput[]): Promise<void>;
52
+ /**
53
+ * Where the harness remembers the conversation between turns.
54
+ *
55
+ * OPTIONAL so every existing caller and test keeps working unchanged — and
56
+ * a runner without one behaves EXACTLY as it did before this fix, which is
57
+ * to say it forgets everything. That is deliberate: the degradation is a
58
+ * missing dependency, not a silent code path, and `MemoryConversationStore`
59
+ * is the default the harness actually wires.
60
+ */
61
+ conversation?: ConversationStore;
62
+ /**
63
+ * Called when the turn starts or stops waiting on a human permission.
64
+ *
65
+ * THIS IS THE LATENCY WIRE. The harness uses it to tighten how often it talks
66
+ * to the control plane while a prompt is outstanding — see
67
+ * `APPROVAL_RENEW_INTERVAL_MS` in harness.ts. Without it an approval would
68
+ * ride the 30s heartbeat and every permission prompt would cost up to half a
69
+ * minute, which is long enough that the feature reads as broken even when it
70
+ * works.
71
+ *
72
+ * Optional, so a runner constructed without one behaves exactly as before.
73
+ */
74
+ onApprovalStateChange?(sessionId: string, waiting: boolean): void;
75
+ /**
76
+ * Read the session's history from the CONTROL PLANE.
77
+ *
78
+ * Needed because a runner is disposable. When a turn is leased to a machine
79
+ * that did not run the earlier turns — a new sandbox, a redeployed VM — its
80
+ * local cache is empty and the control plane is the only place the
81
+ * conversation still exists. Optional for the same reason as above.
82
+ */
83
+ fetchHistory?(sessionId: string): Promise<{
84
+ backendSessionId: string | null;
85
+ /**
86
+ * REQUIRED, not optional, and that is the point.
87
+ *
88
+ * The history route returns the checkpoint from the SAME row the ownership
89
+ * guard consults, so this path can hand a runner a foreign handle exactly
90
+ * like the lease can. An earlier version of this type omitted the field
91
+ * entirely, so the closure in `harness.ts` silently dropped it and the
92
+ * compiler had nothing to object to — the guard was bypassed on the ONE
93
+ * path it exists for. Making it required means a caller that cannot supply
94
+ * it has to say so with an explicit `null`.
95
+ */
96
+ backendOwnerId: string | null;
97
+ messages: ModelMessageRecord[];
98
+ }>;
99
+ logger?: Pick<Console, 'log' | 'warn' | 'error'>;
100
+ /**
101
+ * The HTTP client used to download a turn's attachments. Optional; the
102
+ * global `fetch` otherwise. Injected so tests need no network.
103
+ */
104
+ fetchAttachment?: typeof fetch;
105
+ }
106
+ export interface RunTurnResult {
107
+ turn: TurnMetadata;
108
+ /** The session's max rev in the LOCAL store after the turn. */
109
+ maxRev: number;
110
+ partsProduced: number;
111
+ }
112
+ /**
113
+ * The model id the backend actually understands.
114
+ *
115
+ * The lease carries `provider` and `model` as SEPARATE fields, but Pi selects
116
+ * a provider/model pair from ONE qualified string at the adapter boundary.
117
+ * Passing `model.model` alone therefore threw the provider away: a lease
118
+ * saying `{provider:'anthropic', model:'claude-sonnet-4'}` reached the adapter
119
+ * as `claude-sonnet-4`, which has no `/`, so the adapter sent NO model at all
120
+ * and the backend silently used its own default. The control plane's choice of
121
+ * model was being ignored on every turn.
122
+ *
123
+ * Splitting on the FIRST slash is why this is a plain join and not a
124
+ * "does it already contain a slash" test: a real gateway model id is
125
+ * `peakflo/vertex_ai/gemini-3-flash-preview`, whose modelID legitimately
126
+ * contains a slash of its own.
127
+ */
128
+ export declare function qualifiedModelId(model: {
129
+ provider?: string | null;
130
+ model?: string | null;
131
+ }): string | undefined;
132
+ /**
133
+ * Refuse to run a turn that would fall back to the host's own credentials.
134
+ *
135
+ * THIS IS THE SECURITY POINT OF THE GATEWAY, not a nicety. When the lease
136
+ * names a gateway but carries no `modelToken`, the adapter builds no `peakflo`
137
+ * provider, Pi can fall through to whatever is in the host's provider store,
138
+ * and the turn may run anyway — silently spending
139
+ * the machine owner's credentials, unmetered, with no per-tenant budget. On a
140
+ * customer VM, which is outside our security perimeter, that is somebody
141
+ * else's key being used by our software without anybody noticing.
142
+ *
143
+ * A missing token must therefore be LOUD. Returning a reason rather than
144
+ * throwing keeps the decision testable on its own.
145
+ */
146
+ /**
147
+ * Say WHICH model was refused, and by whom.
148
+ *
149
+ * The bare provider text is often one word. A turn on
150
+ * a non-gateway model reported exactly `Forbidden`, which names
151
+ * neither the model nor the provider that refused it, so it reads as a fault
152
+ * in this runner. It is not: the provider rejected a model this machine holds
153
+ * no credential for.
154
+ *
155
+ * A model id names its provider (decision recorded in `modelGatewayRefusal`),
156
+ * and only the gateway provider is credentialed per turn from the lease.
157
+ * Every other provider needs a credential on the RUNNER, so for those the
158
+ * refusal is worth naming as a likely missing credential — likely, not
159
+ * certain, because a quota or a withdrawn model refuses identically and
160
+ * claiming otherwise would send the reader to the wrong place.
161
+ */
162
+ export declare function describeProviderError(error: string, qualifiedModel: string | undefined): string;
163
+ export declare function modelGatewayRefusal(lease: {
164
+ modelToken?: string | null;
165
+ session: {
166
+ runtime?: string;
167
+ agent: {
168
+ model: {
169
+ provider?: string | null;
170
+ gatewayUrl?: string | null;
171
+ };
172
+ };
173
+ };
174
+ }): string | null;
175
+ /**
176
+ * Option ids that mean YES.
177
+ *
178
+ * AN ALLOWLIST, NOT "anything that is not deny". The difference matters the
179
+ * moment a backend adds a fifth option: under a denylist an unrecognised id
180
+ * would be APPROVED, and the cost of being wrong in that direction is an
181
+ * unattended agent running a command the user meant to refuse. Under this list
182
+ * an unrecognised id is refused, and the cost of being wrong is a prompt the
183
+ * user has to answer twice.
184
+ *
185
+ * `once` and `always` are OpenCode's own wire words; `allow`, `allow-always`
186
+ * and `approved-for-session` are what `getPendingApproval` offers and what 20x
187
+ * sends. All five are accepted because both vocabularies reach this function.
188
+ */
189
+ /**
190
+ * What happened to an `answer` command.
191
+ *
192
+ * A DISCRIMINATED RESULT, NOT A BOOLEAN, and the distinction is load-bearing.
193
+ * `false` used to mean both "there was nothing to answer" and "the backend
194
+ * refused the answer", and the caller's recovery path — forgetting the
195
+ * commandId, telling the user — is right for the second and WRONG for the
196
+ * first. Collapsing them is what made that recovery path unreachable: the
197
+ * catch below swallowed the delivery failure and returned the same `false` a
198
+ * stale answer returns, so the harness's `.catch()` never fired and the user's
199
+ * click vanished with no error anywhere.
200
+ */
201
+ /**
202
+ * The permission identity a backend session was created under.
203
+ *
204
+ * THE FULL PAIR, NOT JUST `permissionMode`. `default`, `plan` and `acceptEdits`
205
+ * all map to `'ask'` and differ only in `sandboxMode`, so comparing the
206
+ * two-valued mode alone would miss a `default` -> `plan` change entirely.
207
+ * Dormant on OpenCode, where those are indistinguishable anyway — but Codex
208
+ * reads `sandboxMode` directly, and a guard written generically should not
209
+ * quietly depend on which backend happens to be wired.
210
+ *
211
+ * A LEGACY VALUE ('ask' / 'allow', written before this carried the pair) can
212
+ * never equal a fingerprint, so it reads as "unrecorded" and forces a rebuild.
213
+ * That is the intended fail-closed outcome, not an accident of the format.
214
+ */
215
+ export declare function permissionFingerprint(mode: string | null | undefined): string;
216
+ export type ApprovalOutcome =
217
+ /** Delivered to the backend. */
218
+ 'delivered'
219
+ /** Nothing to answer, or the answer named a different question. Not an error. */
220
+ | 'ignored'
221
+ /** The backend could not be told. The user must be informed and may retry. */
222
+ | 'failed';
223
+ /**
224
+ * Decode the wire format produced by the browser's question card.
225
+ *
226
+ * This deliberately matches 20x's AgentManager: each line is either
227
+ * `Header: answer` or an unlabelled single answer. OpenCode needs the headers
228
+ * to align answers with a multi-question request.
229
+ */
230
+ export declare function decodeQuestionAnswers(answer: string | undefined): Record<string, string>;
231
+ /**
232
+ * Is this option id an approval?
233
+ *
234
+ * Exported so `approving-option-ids.test.ts` can check it against what the
235
+ * ADAPTERS actually offer, rather than against a restatement of the same list —
236
+ * a hardcoded copy in the test would have passed against the bug that made
237
+ * Codex's "Allow" read as a denial.
238
+ */
239
+ export declare function isApprovingOptionId(optionId: string): boolean;
240
+ export declare class SessionRunner {
241
+ private readonly deps;
242
+ private readonly logger;
243
+ /**
244
+ * What the running turn needs in order to ANSWER a permission from outside
245
+ * the turn's own call stack.
246
+ *
247
+ * `run()` is a single long await; the answer arrives on a control-plane
248
+ * command handled by the harness, on a completely different stack. These
249
+ * three are what `answerPendingApproval` needs and cannot otherwise reach.
250
+ */
251
+ private turnContext;
252
+ /** The approval currently shown to the user, if any. */
253
+ private openApproval;
254
+ /** The transcript identity of an ordinary agent question, if one is open. */
255
+ private openQuestionPartId;
256
+ /**
257
+ * Expiry of the model token THIS RUNNING BACKEND is holding, when the
258
+ * control plane reported one — see `LeaseResponse.modelTokenExpiresAt`.
259
+ * Updated by `updateModelCredential` on a mid-turn refresh. `null` for a
260
+ * sandbox turn (its per-SANDBOX key has no per-turn expiry to track).
261
+ */
262
+ private modelTokenExpiresAt;
263
+ /**
264
+ * Written once per turn, the first time `modelTokenExpiresAt` is found to
265
+ * be in the past on a backend that cannot take a live credential update
266
+ * (`notifyConfigChanged` absent — currently Pi). Without this a self-hosted
267
+ * or desktop Pi turn that outlives its 1h key fails its NEXT model call
268
+ * with an ordinary-looking auth error and no indication why, which reads as
269
+ * a random provider outage rather than the known, disclosed limitation it
270
+ * is.
271
+ */
272
+ private modelKeyExpiryNoticeShown;
273
+ /** toolCallIds already turned into a question part, so it is emitted once. */
274
+ private readonly emittedApprovals;
275
+ /**
276
+ * Total wall-clock this turn has spent blocked on a human.
277
+ *
278
+ * ADDED BACK ONTO THE TURN DEADLINE. `maxTurnDurationMs` is 600 s and it
279
+ * exists to stop a runaway MODEL, not to put a stopwatch on the user: a
280
+ * person who takes eleven minutes to decide whether to allow a shell command
281
+ * is behaving normally, and killing their turn for it would make the approval
282
+ * feature useless for exactly the cautious user it is built for.
283
+ */
284
+ private approvalWaitMs;
285
+ private approvalWaitStartedAt;
286
+ constructor(deps: SessionRunnerDeps);
287
+ /**
288
+ * True while a permission prompt is outstanding. Read by the harness to
289
+ * decide how hard to poll the control plane for the answer.
290
+ */
291
+ isAwaitingApproval(): boolean;
292
+ /**
293
+ * Run the leased turn to completion, or until `signal` aborts it.
294
+ *
295
+ * NEVER THROWS FOR AN AGENT-SIDE FAILURE. A backend that crashes, a model that
296
+ * errors, a tool that hangs — all of those are OUTCOMES of a turn and are
297
+ * reported in `TurnMetadata.stopReason`, because the control plane needs to
298
+ * record them and the user needs to see them. Only a failure to talk to the
299
+ * control plane at all propagates, since that is the one thing the runner
300
+ * cannot report through the control plane.
301
+ */
302
+ run(lease: LeaseResponse, signal: AbortSignal): Promise<RunTurnResult>;
303
+ /**
304
+ * Does this polled part merely mirror the prompt the runner itself sent?
305
+ *
306
+ * Matched on TEXT, not on id, because the echo's id belongs to the backend
307
+ * (`user-message-3`, `user-173…`, `<uuid>-text`) and no runner can predict
308
+ * it. Matched as a PREFIX of the sent prompt, not equality, because ACP
309
+ * agents deliver the echo as `user_message_chunk` updates whose accumulated
310
+ * text grows to the full prompt over several polls — dropping only the final
311
+ * equal one would strand the intermediate versions in the store.
312
+ *
313
+ * Both sides are trimmed first: a backend that strips a trailing newline
314
+ * from the echoed text must not slip past the prefix check on that alone.
315
+ */
316
+ private isEchoOfTurnPrompt;
317
+ /**
318
+ * Poll the adapter until the turn goes quiet or the signal aborts.
319
+ *
320
+ * The adapter's `onDataAvailable` callback wakes this immediately when the
321
+ * backend has buffered output, so the 2s interval is a FLOOR for liveness
322
+ * rather than the streaming latency. Without it every token would arrive up
323
+ * to two seconds late and the UI would look broken.
324
+ */
325
+ /**
326
+ * Pump the backend until it goes quiet, returning what was produced AND any
327
+ * provider error the backend reported.
328
+ *
329
+ * THE ERROR HALF OF THE RETURN IS THE POINT. `getStatus` reports a provider
330
+ * failure as `type: 'error'` with a message, and this loop used to fold that
331
+ * into `quiet` alongside `idle` — it stopped draining, which is correct, and
332
+ * then threw the message away, which is not. The turn went on to be recorded
333
+ * as `stopReason: 'completed'` with `error: null`.
334
+ *
335
+ * That is not theoretical. On 2026-08-22 a gateway stopped serving the
336
+ * configured model; five consecutive turns logged
337
+ * `Provider error … Invalid model name passed in model=gpt-4.1`, produced no
338
+ * assistant output at all, and were all stored as SUCCESSFUL turns. The UI
339
+ * faithfully showed five user messages and no replies, the runner showed
340
+ * `ready`, and nothing anywhere said the model call had failed.
341
+ */
342
+ private drain;
343
+ /**
344
+ * THE SOFT WARNING, fired once at 90% of a CONFIGURED hard ceiling.
345
+ *
346
+ * Always written into the transcript so the user sees the countdown even
347
+ * when the backend cannot take a mid-turn nudge. `steerTurn` is optional on
348
+ * `CodingAgentAdapter` for exactly that reason: Pi can deliver a message to
349
+ * a running turn (`streamingBehavior: 'steer'`); OpenCode's `sendPrompt` is
350
+ * a single long-lived HTTP call per session and calling it again while one
351
+ * is already in flight is not safe, so it does not implement this and the
352
+ * warning stays transcript-only there.
353
+ */
354
+ private warnOfHardCeiling;
355
+ /** Wall-clock spent in the approval we are in RIGHT NOW, if any. */
356
+ private liveApprovalWaitMs;
357
+ /**
358
+ * WIRE 2. Turn a pending backend permission into a `question` transcript part.
359
+ *
360
+ * The whole channel below this already existed and was simply never joined
361
+ * up. The OpenCode adapter queues `permission.asked` events, reports
362
+ * `WAITING_APPROVAL` from `getStatus` while any are outstanding, and exposes
363
+ * `getPendingApproval`. The control plane already accepts an answer and
364
+ * queues it as a session command. The UI already renders a `question` part,
365
+ * already shows its options as buttons, and already posts `optionId` back.
366
+ * The only thing missing was somebody asking the adapter.
367
+ *
368
+ * THIS IS DELIBERATELY THE SAME SHAPE 20x EMITS (agent-manager.ts:2342-2367),
369
+ * because the two products render the same transcript and a second shape
370
+ * would mean a second renderer. ONE DIFFERENCE, ON PURPOSE: 20x builds its
371
+ * options as `{ label }` and throws `optionId` away, which forces its IPC
372
+ * layer to recover the choice by matching a display label against a hardcoded
373
+ * map of English strings (agent-manager.ts:4510-4516). We keep the id. Labels
374
+ * are ambiguous, repeatable between questions and translatable; ids are none
375
+ * of those things.
376
+ *
377
+ * `getPendingApproval` is DUCK-TYPED rather than declared on
378
+ * `CodingAgentAdapter` — again matching 20x — so a backend with no permission
379
+ * concept needs no stub and is simply never asked.
380
+ */
381
+ private emitPendingApproval;
382
+ /**
383
+ * Tell the USER that their answer could not be delivered.
384
+ *
385
+ * Without this, a failed delivery is a server-side log line and nothing else:
386
+ * the question card stays open, the turn keeps waiting (approval time is
387
+ * deliberately excluded from the turn deadline), and the person who clicked
388
+ * Allow has no way to tell their click from a slow backend. An error part is
389
+ * the only channel that reaches them.
390
+ *
391
+ * The approval is deliberately left OPEN — the backend never heard the
392
+ * answer, so the prompt is still genuinely outstanding and answering again is
393
+ * the right thing to do.
394
+ */
395
+ reportApprovalFailure(sessionId: string, reason: string): Promise<void>;
396
+ /**
397
+ * MID-TURN MODEL-CREDENTIAL REFRESH — the LONG TURNS audit's answer to a
398
+ * fixed per-turn LiteLLM key TTL. Called from the harness's lease-renewal
399
+ * callback when the control plane re-mints the token near its old expiry.
400
+ *
401
+ * Updates the shared gateway holder unconditionally (so the NEXT model call
402
+ * on this turn already uses it), then asks the adapter to push it to a
403
+ * running backend that can take a live config update. `notifyConfigChanged`
404
+ * is optional on `CodingAgentAdapter` for exactly this reason: OpenCode
405
+ * implements it (a shared server, config pushed over HTTP); Pi does not —
406
+ * its credential is fixed in the child process's environment at spawn, so a
407
+ * refresh here updates the holder for the SAME session's next turn (a fresh
408
+ * Pi process) but not the one currently running. That is a real, disclosed
409
+ * gap, not a silent one: Pi turns still rely on the 1h key outlasting the
410
+ * turn, same as before this method existed — `expiresAt` is recorded
411
+ * regardless, so `drain()`'s expiry notice still fires on the correct
412
+ * (refreshed) deadline rather than the original, already-superseded one.
413
+ */
414
+ updateModelCredential(sessionId: string, apiKey: string, expiresAt?: string): void;
415
+ /** Stop the approval clock and tell the harness it may relax. */
416
+ private closeApproval;
417
+ private closeQuestion;
418
+ /**
419
+ * WIRE 3. Apply an `answer` session command to the running turn.
420
+ *
421
+ * Before this existed, `harness.applyCommand` marked an `answer` as applied
422
+ * and DROPPED IT. That is what made the permission mode unshippable rather
423
+ * than merely unenforced: turning the mode on without this would have turned
424
+ * "silently unrestricted" into "silently hangs on every turn", and because
425
+ * the default agent stores `permissionMode: 'default'`, it would have hit
426
+ * every session at once.
427
+ *
428
+ * Returns true when the answer reached the backend.
429
+ */
430
+ answerPendingApproval(command: {
431
+ sessionId: string;
432
+ partId?: string;
433
+ answer?: string;
434
+ optionId?: string;
435
+ }): Promise<ApprovalOutcome>;
436
+ private pending;
437
+ /**
438
+ * Everything this turn projected, in order.
439
+ *
440
+ * Reset at the start of every `run`. Used only to derive the assistant half
441
+ * of the model-facing log; the transcript itself is already stored by then.
442
+ */
443
+ private turnParts;
444
+ /**
445
+ * STORE FIRST, SEND SECOND, and never the other way round.
446
+ *
447
+ * The local store is what lets a harness survive its own crash; if a part
448
+ * were sent before it was stored, a crash between the two would leave the
449
+ * control plane holding a part the runner does not know it produced, and a
450
+ * resumed turn would produce it again with a NEW id — a visible duplicate in
451
+ * the user's transcript. Storing first makes the send idempotent, because the
452
+ * upsert is keyed on (sessionId, partId).
453
+ */
454
+ private record;
455
+ /**
456
+ * Push a batch to the control plane, chunked to the contract's ceiling.
457
+ *
458
+ * The ceiling is enforced HERE rather than discovered as an opaque rejection
459
+ * mid-turn. `MAX_INGEST_PARTS` is shared with the control plane precisely so
460
+ * both sides agree on it; a limit only one side knows is not a limit.
461
+ */
462
+ private flush;
463
+ /**
464
+ * WHERE `resumeFromRev` FINALLY DOES SOMETHING.
465
+ *
466
+ * The field was specified in the frozen contract, declared in `types.ts`,
467
+ * documented in the README and named in three test files. The control plane
468
+ * computed it and put it on every lease. Nothing in the runtime had ever read
469
+ * it. This is the read.
470
+ *
471
+ * ITS MEANING, taken from the contract's own comment: "the harness already
472
+ * holds transcript up to this rev and need not replay it. Null means start
473
+ * cold." So it is a statement about what the SERVER has, which the runner
474
+ * compares against what IT has:
475
+ *
476
+ * null -> a brand-new conversation. Nothing to load.
477
+ * local maxRev >= it -> this runner ran the earlier turns and its
478
+ * cache is current. No network call.
479
+ * local maxRev < it -> this runner is behind, or has never seen this
480
+ * session at all. The control plane is the only
481
+ * place the conversation still exists; fetch it.
482
+ *
483
+ * That third case is the one that matters, and it is not an edge case: it is
484
+ * every cold sandbox, every redeployed runner, and every turn that happens to
485
+ * be leased to a different machine than the one before it.
486
+ *
487
+ * NEVER THROWS. A conversation that cannot be loaded is a turn that starts
488
+ * cold — degraded, and worth a warning — whereas a throw here would refuse a
489
+ * turn the user is waiting on. The warning is loud because a fleet silently
490
+ * forgetting everything is precisely the failure this whole change exists to
491
+ * end.
492
+ */
493
+ private priorMessageCount;
494
+ private loadMemory;
495
+ /**
496
+ * Authorise this session for exactly the secrets it was granted.
497
+ *
498
+ * Returns null when the host has no secret broker, which is the ordinary case
499
+ * on a runner — see `NoSecretProvider`, where holding no vendor credential is
500
+ * a security position rather than a gap.
501
+ */
502
+ private setUpSecrets;
503
+ /**
504
+ * Build the backend's session config.
505
+ *
506
+ * EVERY MCP SERVER GOES THROUGH THE LOOPBACK PROXY. The real gateway URL and
507
+ * the session token never reach the backend, never land in an MCP config file
508
+ * on disk, and never appear in the backend's own session state — so a leaked
509
+ * workspace does not leak the tenant's tool access. This is also what makes
510
+ * the token refreshable mid-session, which is the defect the proxy was
511
+ * originally written for.
512
+ *
513
+ * `allowedTools` from the lease is NOT enforced here, and that is deliberate.
514
+ * It is advisory — for generating AGENTS.md and for what the UI shows. THE
515
+ * GATEWAY IS AUTHORITATIVE, because a harness on a customer VM is outside our
516
+ * security perimeter and trusting it to filter its own tools is exactly
517
+ * defect G2.
518
+ */
519
+ private buildSessionConfig;
520
+ }