@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,2122 @@
1
+ "use strict";
2
+ /**
3
+ * THE SESSION RUNNER — one leased turn, start to finish.
4
+ *
5
+ * This is the loop 20x's `AgentManager` runs, reduced to the part that is
6
+ * actually about running an agent: prepare a workspace, wire the MCP servers
7
+ * through the loopback auth proxy, drive a backend adapter, and push every part
8
+ * it produces into the durable projection and then out to the control plane.
9
+ *
10
+ * WHAT IT DELIBERATELY IS NOT. `AgentManager` also owns tasks, worktrees, pull
11
+ * requests, plugin actions, skills sync, output-field extraction and heartbeat
12
+ * scheduling. None of that is a property of running an agent; all of it is a
13
+ * property of 20x's TASK model. Dragging it across would have made this package
14
+ * "20x without a window" rather than a harness, and would have required the
15
+ * control plane to grow a task table to match. The seam is cut at the turn.
16
+ *
17
+ * THE TURN IS THE UNIT OF WORK, which is what makes the lease design work: one
18
+ * lease is one turn, a lease that expires is requeued, and a runner that dies
19
+ * mid-turn loses at most that turn — because the parts it already produced were
20
+ * ingested as it went, not at the end.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.SessionRunner = void 0;
24
+ exports.qualifiedModelId = qualifiedModelId;
25
+ exports.describeProviderError = describeProviderError;
26
+ exports.modelGatewayRefusal = modelGatewayRefusal;
27
+ exports.permissionFingerprint = permissionFingerprint;
28
+ exports.decodeQuestionAnswers = decodeQuestionAnswers;
29
+ exports.isApprovingOptionId = isApprovingOptionId;
30
+ const node_crypto_1 = require("node:crypto");
31
+ const model_gateway_1 = require("../adapters/model-gateway");
32
+ const contract_1 = require("../contract");
33
+ const attachments_1 = require("./attachments");
34
+ const conversation_1 = require("./conversation");
35
+ const permission_mode_1 = require("./permission-mode");
36
+ const projection_1 = require("./projection");
37
+ /** How often the runner asks the adapter for new output when nothing pushes. */
38
+ const POLL_INTERVAL_MS = 2_000;
39
+ /**
40
+ * How long an idle status must persist before the turn is considered over.
41
+ *
42
+ * A backend reports IDLE between tool calls as well as at the end of a turn, so
43
+ * ending on the first idle would truncate almost every transcript at the first
44
+ * tool. Two consecutive quiet polls is the 20x behaviour and the reason its
45
+ * transcripts are complete.
46
+ */
47
+ const IDLE_CONFIRMATIONS = 2;
48
+ /**
49
+ * THE PRIMARY GUARD, per the LONG TURNS fix.
50
+ *
51
+ * A wall clock cannot tell a runaway model from a slow-but-healthy one — a
52
+ * real coding turn routinely runs 30-90 minutes. Progress is the honest
53
+ * signal: as long as the backend keeps producing a new message part, a tool
54
+ * call start/end, or streamed text, the turn keeps running no matter how long
55
+ * it has been open. Only silence this long means the backend is hung.
56
+ *
57
+ * 20 MINUTES, NOT 10 — raised on review. `pollMessages` only reports a tool
58
+ * call's completion (or an incremental streamed chunk), not that one is still
59
+ * running: a single tool that legitimately takes a long time — a large test
60
+ * suite, a slow build, a big migration — produces NOTHING for the guard to
61
+ * see until it finishes. 10 minutes cut that kind of tool call outright; 20
62
+ * gives real long-running commands room while still catching a backend that
63
+ * has actually gone silent (crashed, wedged, or lost its connection) well
64
+ * short of the old fixed 10-minute wall clock this whole fix replaces.
65
+ *
66
+ * Used when the lease does not name its own `maxTurnIdleMs`
67
+ * (`CODING_AGENT_MAX_TURN_IDLE_MINUTES` on the control plane).
68
+ */
69
+ const DEFAULT_MAX_TURN_IDLE_MS = 20 * 60_000;
70
+ /**
71
+ * Where the soft warning fires, as a fraction of the hard ceiling.
72
+ *
73
+ * Only meaningful when an operator has actually set
74
+ * `CODING_AGENT_MAX_TURN_MINUTES` — `maxTurnDurationMs: null` (the default)
75
+ * has no ceiling and therefore no 90% mark either.
76
+ */
77
+ const HARD_CEILING_WARNING_FRACTION = 0.9;
78
+ /** "1 minute" / "10 minutes" — never "0 minutes", which reads like a bug. */
79
+ function formatMinutes(ms) {
80
+ const minutes = Math.max(1, Math.round(ms / 60_000));
81
+ return `${minutes} minute${minutes === 1 ? '' : 's'}`;
82
+ }
83
+ /**
84
+ * The model id the backend actually understands.
85
+ *
86
+ * The lease carries `provider` and `model` as SEPARATE fields, but Pi selects
87
+ * a provider/model pair from ONE qualified string at the adapter boundary.
88
+ * Passing `model.model` alone therefore threw the provider away: a lease
89
+ * saying `{provider:'anthropic', model:'claude-sonnet-4'}` reached the adapter
90
+ * as `claude-sonnet-4`, which has no `/`, so the adapter sent NO model at all
91
+ * and the backend silently used its own default. The control plane's choice of
92
+ * model was being ignored on every turn.
93
+ *
94
+ * Splitting on the FIRST slash is why this is a plain join and not a
95
+ * "does it already contain a slash" test: a real gateway model id is
96
+ * `peakflo/vertex_ai/gemini-3-flash-preview`, whose modelID legitimately
97
+ * contains a slash of its own.
98
+ */
99
+ function qualifiedModelId(model) {
100
+ const name = (model.model ?? '').trim();
101
+ if (!name)
102
+ return undefined;
103
+ const provider = (model.provider ?? '').trim();
104
+ return provider ? `${provider}/${name}` : name;
105
+ }
106
+ /**
107
+ * Refuse to run a turn that would fall back to the host's own credentials.
108
+ *
109
+ * THIS IS THE SECURITY POINT OF THE GATEWAY, not a nicety. When the lease
110
+ * names a gateway but carries no `modelToken`, the adapter builds no `peakflo`
111
+ * provider, Pi can fall through to whatever is in the host's provider store,
112
+ * and the turn may run anyway — silently spending
113
+ * the machine owner's credentials, unmetered, with no per-tenant budget. On a
114
+ * customer VM, which is outside our security perimeter, that is somebody
115
+ * else's key being used by our software without anybody noticing.
116
+ *
117
+ * A missing token must therefore be LOUD. Returning a reason rather than
118
+ * throwing keeps the decision testable on its own.
119
+ */
120
+ /**
121
+ * Say WHICH model was refused, and by whom.
122
+ *
123
+ * The bare provider text is often one word. A turn on
124
+ * a non-gateway model reported exactly `Forbidden`, which names
125
+ * neither the model nor the provider that refused it, so it reads as a fault
126
+ * in this runner. It is not: the provider rejected a model this machine holds
127
+ * no credential for.
128
+ *
129
+ * A model id names its provider (decision recorded in `modelGatewayRefusal`),
130
+ * and only the gateway provider is credentialed per turn from the lease.
131
+ * Every other provider needs a credential on the RUNNER, so for those the
132
+ * refusal is worth naming as a likely missing credential — likely, not
133
+ * certain, because a quota or a withdrawn model refuses identically and
134
+ * claiming otherwise would send the reader to the wrong place.
135
+ */
136
+ function describeProviderError(error, qualifiedModel) {
137
+ // `qualifiedModelId` returns undefined when the lease named no model at all.
138
+ const model = (qualifiedModel ?? '').trim();
139
+ const head = model
140
+ ? `The model provider refused this turn on ${model}: ${error}`
141
+ : `The model provider refused this turn: ${error}`;
142
+ const provider = model.includes('/')
143
+ ? model.slice(0, model.indexOf('/'))
144
+ : '';
145
+ if (!provider || provider === model_gateway_1.ENTERPRISE_AI_GATEWAY_PROVIDER_ID) {
146
+ return head;
147
+ }
148
+ return (`${head}\n\n${provider} is not the Peakflo model gateway, so this runner ` +
149
+ `must hold its own credential for it. Check that the runner is signed in ` +
150
+ `to ${provider}, or choose a ${model_gateway_1.ENTERPRISE_AI_GATEWAY_PROVIDER_ID}/ model, ` +
151
+ `which is credentialed for each turn.`);
152
+ }
153
+ function modelGatewayRefusal(lease) {
154
+ const gatewayUrl = (lease.session.agent.model.gatewayUrl ?? '').trim();
155
+ if (!gatewayUrl)
156
+ return null;
157
+ const token = (lease.modelToken ?? '').trim();
158
+ if (!token) {
159
+ return (`Lease names model gateway ${gatewayUrl} but carries no modelToken. ` +
160
+ "Refusing the turn: running it would fall back to this host's own " +
161
+ 'provider credentials, which are neither tenant-metered nor ' +
162
+ 'budget-capped.');
163
+ }
164
+ /*
165
+ * THE PROVIDER IS PI'S TO CHOOSE, ON EVERY RUNTIME.
166
+ *
167
+ * There used to be a second refusal here: a lease naming `anthropic/...`
168
+ * built the `peakflo` provider and then never routed to it, because the backend
169
+ * routes by the provider in the model id — so the turn ran on whatever
170
+ * credentials the host had. That was treated as credential-borrowing and
171
+ * refused outside the desktop.
172
+ *
173
+ * It is now allowed everywhere, deliberately. A model id names its provider,
174
+ * and Pi is the thing that knows how to reach it — peakflo, google, a local
175
+ * model. The gateway is one provider among them and the
176
+ * default, not the only permitted route. Refusing the rest meant the model
177
+ * picker offered models that could never run.
178
+ *
179
+ * WHAT THIS GIVES UP, RECORDED RATHER THAN GLOSSED: a turn may run outside
180
+ * the gateway and therefore outside tenant metering and budget caps, on any
181
+ * runtime including the managed sandbox. On a user's own machine that is
182
+ * their key and their bill. On the SANDBOX it is our machine — the mitigation
183
+ * there is that the image ships Pi with no provider credentials
184
+ * configured, so there is nothing ambient to borrow; a turn that names a
185
+ * provider the sandbox cannot reach fails at the model call rather than
186
+ * silently spending. If credentials are ever baked into that image, this
187
+ * decision needs revisiting.
188
+ *
189
+ * The EMPTY-TOKEN refusal above is untouched: a lease that names a gateway
190
+ * and carries no credential is a broken lease, not a choice of provider.
191
+ */
192
+ return null;
193
+ }
194
+ /**
195
+ * Option ids that mean YES.
196
+ *
197
+ * AN ALLOWLIST, NOT "anything that is not deny". The difference matters the
198
+ * moment a backend adds a fifth option: under a denylist an unrecognised id
199
+ * would be APPROVED, and the cost of being wrong in that direction is an
200
+ * unattended agent running a command the user meant to refuse. Under this list
201
+ * an unrecognised id is refused, and the cost of being wrong is a prompt the
202
+ * user has to answer twice.
203
+ *
204
+ * `once` and `always` are OpenCode's own wire words; `allow`, `allow-always`
205
+ * and `approved-for-session` are what `getPendingApproval` offers and what 20x
206
+ * sends. All five are accepted because both vocabularies reach this function.
207
+ */
208
+ /**
209
+ * What happened to an `answer` command.
210
+ *
211
+ * A DISCRIMINATED RESULT, NOT A BOOLEAN, and the distinction is load-bearing.
212
+ * `false` used to mean both "there was nothing to answer" and "the backend
213
+ * refused the answer", and the caller's recovery path — forgetting the
214
+ * commandId, telling the user — is right for the second and WRONG for the
215
+ * first. Collapsing them is what made that recovery path unreachable: the
216
+ * catch below swallowed the delivery failure and returned the same `false` a
217
+ * stale answer returns, so the harness's `.catch()` never fired and the user's
218
+ * click vanished with no error anywhere.
219
+ */
220
+ /**
221
+ * The permission identity a backend session was created under.
222
+ *
223
+ * THE FULL PAIR, NOT JUST `permissionMode`. `default`, `plan` and `acceptEdits`
224
+ * all map to `'ask'` and differ only in `sandboxMode`, so comparing the
225
+ * two-valued mode alone would miss a `default` -> `plan` change entirely.
226
+ * Dormant on OpenCode, where those are indistinguishable anyway — but Codex
227
+ * reads `sandboxMode` directly, and a guard written generically should not
228
+ * quietly depend on which backend happens to be wired.
229
+ *
230
+ * A LEGACY VALUE ('ask' / 'allow', written before this carried the pair) can
231
+ * never equal a fingerprint, so it reads as "unrecorded" and forces a rebuild.
232
+ * That is the intended fail-closed outcome, not an accident of the format.
233
+ */
234
+ function permissionFingerprint(mode) {
235
+ const mapped = (0, permission_mode_1.mapPermissionMode)(mode);
236
+ return `${mapped.permissionMode}:${mapped.sandboxMode}`;
237
+ }
238
+ /**
239
+ * Decode the wire format produced by the browser's question card.
240
+ *
241
+ * This deliberately matches 20x's AgentManager: each line is either
242
+ * `Header: answer` or an unlabelled single answer. OpenCode needs the headers
243
+ * to align answers with a multi-question request.
244
+ */
245
+ function decodeQuestionAnswers(answer) {
246
+ const answers = {};
247
+ for (const line of (answer ?? '').split('\n')) {
248
+ const trimmed = line.trim();
249
+ if (!trimmed)
250
+ continue;
251
+ const colon = trimmed.indexOf(':');
252
+ if (colon > 0) {
253
+ answers[trimmed.slice(0, colon).trim()] = trimmed.slice(colon + 1).trim();
254
+ }
255
+ else {
256
+ answers.answer = trimmed;
257
+ }
258
+ }
259
+ return answers;
260
+ }
261
+ const APPROVING_OPTION_IDS = new Set([
262
+ // OpenCode / the shape `getPendingApproval` offers.
263
+ 'allow',
264
+ 'allow-always',
265
+ 'approved-for-session',
266
+ 'always',
267
+ 'once',
268
+ 'approved',
269
+ /*
270
+ * CODEX. ITS "ALLOW" BUTTON IS `accept`, AND OMITTING IT WAS A REAL BUG:
271
+ * `codex-app-server-adapter.ts:995` offers `{ optionId: 'accept', name:
272
+ * 'Allow' }` by default. That id passed the "was this option offered" check
273
+ * and then FAILED this one, so a user clicking Allow on a Codex session had
274
+ * their approval relayed as `approved: false` and saw "Denied" in the
275
+ * transcript for something they had explicitly permitted.
276
+ *
277
+ * The cause was duplication: Codex has its OWN approving list at
278
+ * `codex-app-server-adapter.ts:588` which does include these, and this set
279
+ * was written without consulting it. `approving-option-ids.test.ts` now
280
+ * derives the expectation from what the adapters actually offer, so a backend
281
+ * adding a new id cannot silently become a denial again.
282
+ */
283
+ 'accept',
284
+ 'acceptForSession',
285
+ 'approved_for_session'
286
+ ]);
287
+ /**
288
+ * Is this option id an approval?
289
+ *
290
+ * Exported so `approving-option-ids.test.ts` can check it against what the
291
+ * ADAPTERS actually offer, rather than against a restatement of the same list —
292
+ * a hardcoded copy in the test would have passed against the bug that made
293
+ * Codex's "Allow" read as a denial.
294
+ */
295
+ function isApprovingOptionId(optionId) {
296
+ return APPROVING_OPTION_IDS.has(optionId);
297
+ }
298
+ /** Free-text answers that mean NO, for the paths that carry no `optionId`. */
299
+ const DENYING_ANSWER_TEXT = /^\s*(no|deny|denied|reject|rejected\b.*|cancel)\s*\.?\s*$/i;
300
+ /**
301
+ * Decide what a user's answer actually means, and fail closed when it is not
302
+ * clear.
303
+ *
304
+ * THREE PATHS REACH HERE AND THEY DO NOT CARRY THE SAME INFORMATION:
305
+ *
306
+ * 1. An option BUTTON in the question card. Sends `optionId`. Unambiguous,
307
+ * and this is the ordinary case.
308
+ * 2. The card's Reject button. Sends no `optionId` and the fixed text
309
+ * "Rejected by the user."
310
+ * 3. The COMPOSER. When a question is open, typing a message posts it as an
311
+ * answer instead (composer.tsx:76-106) — with no `optionId` at all.
312
+ *
313
+ * So free text has to be interpreted, and the rule is: interpret it only when
314
+ * it plainly matches something the backend offered. Anything else DENIES, and
315
+ * says so in the transcript rather than silently doing the safer thing without
316
+ * telling the user why they are being asked again.
317
+ */
318
+ function resolveApproval(command, open) {
319
+ const answer = (command.answer ?? '').trim();
320
+ // Path 1 — an explicit option id.
321
+ if (command.optionId) {
322
+ const approved = isApprovingOptionId(command.optionId);
323
+ // An id the backend never offered is not trusted even if it LOOKS
324
+ // approving: it did not come from a button we rendered.
325
+ const offered = open.knownOptionIds.has(command.optionId);
326
+ if (!offered) {
327
+ return {
328
+ approved: false,
329
+ transcriptText: `Denied — the answer named an option (${command.optionId}) that was not offered.`
330
+ };
331
+ }
332
+ return {
333
+ approved,
334
+ optionId: command.optionId,
335
+ transcriptText: approved
336
+ ? `Approved${answer ? `: ${answer}` : ''}`
337
+ : `Denied${answer ? `: ${answer}` : ''}`
338
+ };
339
+ }
340
+ // Path 2/3 — free text. Match it against the labels the backend offered.
341
+ const matched = open.optionIdByLabel.get(answer.toLowerCase());
342
+ if (matched) {
343
+ const approved = isApprovingOptionId(matched);
344
+ return {
345
+ approved,
346
+ optionId: matched,
347
+ transcriptText: approved ? `Approved: ${answer}` : `Denied: ${answer}`
348
+ };
349
+ }
350
+ if (DENYING_ANSWER_TEXT.test(answer)) {
351
+ return { approved: false, transcriptText: `Denied: ${answer}` };
352
+ }
353
+ /*
354
+ * Unrecognised free text. DENY.
355
+ *
356
+ * The user typed something into the composer while a permission was open —
357
+ * very likely a new instruction, not consent. Treating "actually, do it a
358
+ * different way" as approval for the pending shell command would be the worst
359
+ * possible reading of it. The turn continues; the agent is told it was
360
+ * refused and why, and the user's words are carried through so the agent can
361
+ * act on them.
362
+ */
363
+ return {
364
+ approved: false,
365
+ transcriptText: answer
366
+ ? `Denied (no option chosen): ${answer}`
367
+ : 'Denied (no option chosen).'
368
+ };
369
+ }
370
+ class SessionRunner {
371
+ deps;
372
+ logger;
373
+ /**
374
+ * What the running turn needs in order to ANSWER a permission from outside
375
+ * the turn's own call stack.
376
+ *
377
+ * `run()` is a single long await; the answer arrives on a control-plane
378
+ * command handled by the harness, on a completely different stack. These
379
+ * three are what `answerPendingApproval` needs and cannot otherwise reach.
380
+ */
381
+ turnContext = null;
382
+ /** The approval currently shown to the user, if any. */
383
+ openApproval = null;
384
+ /** The transcript identity of an ordinary agent question, if one is open. */
385
+ openQuestionPartId = null;
386
+ /**
387
+ * Expiry of the model token THIS RUNNING BACKEND is holding, when the
388
+ * control plane reported one — see `LeaseResponse.modelTokenExpiresAt`.
389
+ * Updated by `updateModelCredential` on a mid-turn refresh. `null` for a
390
+ * sandbox turn (its per-SANDBOX key has no per-turn expiry to track).
391
+ */
392
+ modelTokenExpiresAt = null;
393
+ /**
394
+ * Written once per turn, the first time `modelTokenExpiresAt` is found to
395
+ * be in the past on a backend that cannot take a live credential update
396
+ * (`notifyConfigChanged` absent — currently Pi). Without this a self-hosted
397
+ * or desktop Pi turn that outlives its 1h key fails its NEXT model call
398
+ * with an ordinary-looking auth error and no indication why, which reads as
399
+ * a random provider outage rather than the known, disclosed limitation it
400
+ * is.
401
+ */
402
+ modelKeyExpiryNoticeShown = false;
403
+ /** toolCallIds already turned into a question part, so it is emitted once. */
404
+ emittedApprovals = new Set();
405
+ /**
406
+ * Total wall-clock this turn has spent blocked on a human.
407
+ *
408
+ * ADDED BACK ONTO THE TURN DEADLINE. `maxTurnDurationMs` is 600 s and it
409
+ * exists to stop a runaway MODEL, not to put a stopwatch on the user: a
410
+ * person who takes eleven minutes to decide whether to allow a shell command
411
+ * is behaving normally, and killing their turn for it would make the approval
412
+ * feature useless for exactly the cautious user it is built for.
413
+ */
414
+ approvalWaitMs = 0;
415
+ approvalWaitStartedAt = null;
416
+ constructor(deps) {
417
+ this.deps = deps;
418
+ this.logger = deps.logger ?? console;
419
+ }
420
+ /**
421
+ * True while a permission prompt is outstanding. Read by the harness to
422
+ * decide how hard to poll the control plane for the answer.
423
+ */
424
+ isAwaitingApproval() {
425
+ return this.openApproval !== null || this.openQuestionPartId !== null;
426
+ }
427
+ /**
428
+ * Run the leased turn to completion, or until `signal` aborts it.
429
+ *
430
+ * NEVER THROWS FOR AN AGENT-SIDE FAILURE. A backend that crashes, a model that
431
+ * errors, a tool that hangs — all of those are OUTCOMES of a turn and are
432
+ * reported in `TurnMetadata.stopReason`, because the control plane needs to
433
+ * record them and the user needs to see them. Only a failure to talk to the
434
+ * control plane at all propagates, since that is the one thing the runner
435
+ * cannot report through the control plane.
436
+ */
437
+ async run(lease, signal) {
438
+ const { session } = lease;
439
+ const startedAt = new Date().toISOString();
440
+ const release = this.deps.notifier.keepAwake();
441
+ let workspaceDir = null;
442
+ let adapter = null;
443
+ let backendSessionId = null;
444
+ let secretToken = null;
445
+ let partsProduced = 0;
446
+ let stopReason = 'completed';
447
+ let error;
448
+ let repositoryStatuses = [];
449
+ /** True when the backend session was resumed rather than created. */
450
+ let resumed = false;
451
+ /** Part ids already ingested before this turn. See `drain` and the log. */
452
+ let priorPartIdsForLog = new Set();
453
+ /** True when history had to be replayed into a fresh backend session. */
454
+ let replayed = false;
455
+ /** This runner's own backend instance id — see `loadMemory`. */
456
+ let ownBackendInstanceId = null;
457
+ /**
458
+ * The user's message as the MODEL receives it: `input.content` plus, when
459
+ * the turn carries attachments, the note saying where they were written.
460
+ * Also what the conversation log records, so a later turn still knows the
461
+ * files exist.
462
+ */
463
+ let turnContent = session.input.content;
464
+ this.turnParts = [];
465
+ this.modelTokenExpiresAt = lease.modelTokenExpiresAt ?? null;
466
+ this.modelKeyExpiryNoticeShown = false;
467
+ try {
468
+ const refusal = modelGatewayRefusal(lease);
469
+ if (refusal)
470
+ throw new Error(refusal);
471
+ try {
472
+ workspaceDir = await this.deps.workspace.prepare({
473
+ sessionId: session.sessionId,
474
+ mode: session.workspace.mode,
475
+ repositoryUrl: session.workspace.repositoryUrl,
476
+ ref: session.workspace.ref,
477
+ repositories: session.workspace.repositories,
478
+ requestedRepositories: session.workspace.requestedRepositories,
479
+ onRepositoryStatus: (status) => {
480
+ repositoryStatuses = [
481
+ ...repositoryStatuses.filter((current) => current.fullName !== status.fullName),
482
+ status
483
+ ];
484
+ },
485
+ refusalReason: session.workspace.refusalReason
486
+ });
487
+ const repositoryNames = session.workspace.requestedRepositories ??
488
+ (session.workspace.repositories ?? []).map(({ fullName }) => fullName);
489
+ for (const fullName of repositoryNames) {
490
+ if (!repositoryStatuses.some((current) => current.fullName === fullName)) {
491
+ repositoryStatuses.push({ fullName, status: 'checkedOut' });
492
+ }
493
+ }
494
+ }
495
+ catch (caught) {
496
+ const reason = caught instanceof Error ? caught.message : String(caught);
497
+ const status = session.workspace.refusalReason ? 'refused' : 'failed';
498
+ const names = session.workspace.requestedRepositories ??
499
+ (session.workspace.repositories ?? []).map(({ fullName }) => fullName);
500
+ for (const fullName of names) {
501
+ if (!repositoryStatuses.some((current) => current.fullName === fullName)) {
502
+ repositoryStatuses.push({ fullName, status, reason });
503
+ }
504
+ }
505
+ const failures = repositoryStatuses.filter((repository) => repository.status !== 'checkedOut');
506
+ throw new Error(failures.length > 0
507
+ ? failures
508
+ .map((repository) => `Repository ${repository.fullName} was not checked out: ${repository.reason ?? reason}`)
509
+ .join('\n')
510
+ : `Repository workspace was not prepared: ${reason}`);
511
+ }
512
+ /*
513
+ * THE USER'S ATTACHMENTS, INTO THIS TURN'S WORKSPACE — every turn, since
514
+ * each lease carries its own turn's files and the session may have moved
515
+ * runner since the last one. Never fatal: a file that cannot be fetched
516
+ * is named in the note and the turn runs without it.
517
+ */
518
+ const hasLeasedAttachments = (session.input.attachments?.length ?? 0) > 0;
519
+ /*
520
+ * ATTACHMENTS THE CONTROL PLANE ITSELF COULD NOT PREPARE — a
521
+ * `local://` URL it cannot fetch (local dev storage), or a signing
522
+ * failure. These never appear in `session.input.attachments` at all,
523
+ * so without this the block below would not even RUN when every
524
+ * attachment fell into this bucket: the turn would proceed as if
525
+ * nothing had ever been attached, silently, for both the model and
526
+ * (via the transcript note) the user. See `LeasedTurnInput
527
+ * .droppedAttachments`'s doc comment.
528
+ */
529
+ const hasDroppedAttachments = (session.input.droppedAttachments?.length ?? 0) > 0;
530
+ if (workspaceDir && (hasLeasedAttachments || hasDroppedAttachments)) {
531
+ const sync = await (0, attachments_1.syncAttachmentsToWorkspace)(session.input.attachments, workspaceDir, {
532
+ fetchImpl: this.deps.fetchAttachment,
533
+ logger: this.logger,
534
+ // Namespaces this turn's filenames from every other turn's, so a
535
+ // later turn's same-named attachment cannot silently overwrite
536
+ // an earlier turn's file that an earlier point in the
537
+ // conversation log still refers to. See `syncAttachmentsToWorkspace`'s
538
+ // `turnId` doc comment for why re-leasing THIS turn stays idempotent.
539
+ turnId: session.turnId
540
+ });
541
+ /*
542
+ * MERGED INTO THE SAME `failed` LIST `syncAttachmentsToWorkspace`'s
543
+ * OWN download failures land in, so `attachmentPromptNote` reports
544
+ * both kinds identically — the model and the user should not have
545
+ * to care WHERE in the pipeline a file was lost. The name is
546
+ * sanitised again here: this list crossed the wire in the lease,
547
+ * same as `session.input.attachments` does, and is exactly as
548
+ * untrusted (see the module's own doc comment).
549
+ */
550
+ if (session.input.droppedAttachments?.length) {
551
+ for (const dropped of session.input.droppedAttachments) {
552
+ sync.failed.push({
553
+ name: (0, attachments_1.sanitizeAttachmentName)(dropped?.name),
554
+ reason: String(dropped?.reason ?? 'could not be prepared')
555
+ });
556
+ }
557
+ }
558
+ const note = (0, attachments_1.attachmentPromptNote)(sync);
559
+ if (note)
560
+ turnContent = `${session.input.content}\n\n${note}`;
561
+ this.deps.telemetry.record('provider.turn.attachments', {
562
+ synced: sync.synced.length,
563
+ failed: sync.failed.length
564
+ });
565
+ }
566
+ // Point the shared holder at THIS turn's credential before the adapter
567
+ // builds its provider config. Revoked in `finally`, so the credential
568
+ // never outlives the turn it was minted for.
569
+ if (session.agent.model.gatewayUrl) {
570
+ /*
571
+ * REGISTER THE GATEWAY'S WHOLE CATALOGUE, as 20x does — not just the
572
+ * one model this turn happens to use.
573
+ *
574
+ * This used to build a provider containing a single model. The turn ran
575
+ * either way, but the backend's own provider list was then a lie:
576
+ * `config.providers()` is what an agent form reads to offer a choice,
577
+ * so a one-entry provider can never report what is actually reachable.
578
+ *
579
+ * The fetch is best-effort. A gateway that will not answer must not
580
+ * stop a turn whose model is already known, so we fall back to exactly
581
+ * the previous behaviour.
582
+ */
583
+ const fetched = await (0, model_gateway_1.fetchGatewayModels)({
584
+ baseUrl: session.agent.model.gatewayUrl,
585
+ apiKey: lease.modelToken
586
+ });
587
+ const named = session.agent.model.model
588
+ ? [
589
+ {
590
+ id: session.agent.model.model,
591
+ name: session.agent.model.model
592
+ }
593
+ ]
594
+ : [];
595
+ /*
596
+ * The lease's model is ALWAYS included, even when the gateway does not
597
+ * advertise it. Measured on this deployment: the model that works
598
+ * (`vertex_ai/gemini-3-flash-preview`) is absent from `/models`, while
599
+ * one that is listed (`gpt-4.1`) is refused at call time. Trusting the
600
+ * catalogue to be complete would drop the working model out of the
601
+ * provider and break the turn.
602
+ */
603
+ const merged = [...fetched];
604
+ for (const model of named) {
605
+ if (!merged.some((m) => m.id === model.id))
606
+ merged.push(model);
607
+ }
608
+ this.deps.modelGateway?.set({
609
+ apiKey: lease.modelToken,
610
+ baseUrl: session.agent.model.gatewayUrl,
611
+ models: merged
612
+ });
613
+ }
614
+ else {
615
+ // Null means "no gateway provider" — never "use whatever credentials
616
+ // are on this machine".
617
+ this.deps.modelGateway?.set(null);
618
+ }
619
+ adapter = this.deps.getAdapter(session.agent.backend);
620
+ await adapter.initialize();
621
+ secretToken = this.setUpSecrets(session);
622
+ const config = this.buildSessionConfig(session, workspaceDir, secretToken, signal);
623
+ /*
624
+ * REMEMBER THE CONVERSATION. This block is the fix for the defect that
625
+ * made this a chat product that could not hold a conversation: the
626
+ * previous code went straight to `createSession` here, every turn, and
627
+ * sent only `session.input.content` — so the model met the user for the
628
+ * first time on every message while the UI transcript looked complete.
629
+ */
630
+ /*
631
+ * WHICH BACKEND INSTANCE THIS RUNNER IS. Optional-chained through a cast
632
+ * because it is a backend-specific affordance, not part of
633
+ * `CodingAgentAdapter`, and a backend
634
+ * that cannot answer keeps the previous behaviour.
635
+ */
636
+ ownBackendInstanceId =
637
+ adapter.backendInstanceId?.() ?? null;
638
+ const memory = await this.loadMemory(session, ownBackendInstanceId);
639
+ /*
640
+ * What this runner has ALREADY ingested for this session.
641
+ *
642
+ * Read before the turn starts, from the durable projection rather than
643
+ * from anything held in memory, so it is correct after a restart too.
644
+ */
645
+ const priorPartIds = new Set(this.deps.store.getParts(session.sessionId).map((part) => part.partId));
646
+ // PREFERRED: keep talking to the backend session that already holds the
647
+ // context. Nothing is reconstructed and no tokens are re-sent.
648
+ if (memory.backendSessionId) {
649
+ try {
650
+ /*
651
+ * `resumeSession` RETURNS THE SESSION'S HISTORY, and that return
652
+ * value is load-bearing rather than incidental.
653
+ *
654
+ * `pollMessages` is asked for "everything not in these sets". On a
655
+ * resumed session that is every message the conversation has ever
656
+ * had, so the parts already in the backend must be marked seen or the
657
+ * turn re-emits the whole conversation. Seeding from OUR OWN STORE is
658
+ * not enough: a cold runner resuming a still-live backend session has
659
+ * an empty store and a full backend, which is exactly the
660
+ * harness-restart case. The backend's own answer is the only source
661
+ * that is right in both.
662
+ */
663
+ /*
664
+ * REFUSE TO RESUME WHEN THE PERMISSION MODE HAS CHANGED — this is
665
+ * what makes exit test 4 ("change the mode; the NEXT turn honours it
666
+ * with no session restart") actually true.
667
+ *
668
+ * PI BINDS THE PERMISSION EXTENSION TO THE PROCESS AT CREATION. A
669
+ * resumed native session must therefore be started with the current
670
+ * decision; a checkpoint made under a different decision is rebuilt
671
+ * from the runtime-neutral conversation log.
672
+ *
673
+ * THE CHECK LIVES HERE, NOT IN THE ADAPTER, because the runner is the
674
+ * only place that both owns the resume decision and can see a durable
675
+ * record. Two earlier attempts inside the adapter could never fire:
676
+ * the workspace is deleted and rebuilt every turn, and `getAdapter`
677
+ * constructs a fresh adapter per turn, so neither the config file nor
678
+ * the adapter's own map survives to be compared against.
679
+ *
680
+ * AN UNKNOWN PREVIOUS DECISION NOW FORCES A REBUILD, and an earlier
681
+ * version of this guard did the opposite. That was wrong, and review
682
+ * caught it: the decision is read from the LOCAL conversation store,
683
+ * so it is absent on a checkpoint written before this existed AND on
684
+ * ANY OTHER RUNNER — and a lease changing hands is described
685
+ * elsewhere in this file as an ordinary event, not an edge case. So
686
+ * "unknown skips the check" meant the guard was routinely bypassed
687
+ * exactly when a session had travelled, while the UI now presents the
688
+ * mode as enforced.
689
+ *
690
+ * It is also the wrong DIRECTION for this change. Everything else
691
+ * here fails closed — an unknown mode maps to `ask`, an unoffered
692
+ * option denies. Unknown must therefore mean "rebuild", not "trust
693
+ * the old session".
694
+ *
695
+ * THE COST, STATED: one rebuild and one replay on the first turn
696
+ * after an upgrade, and on the first turn after a lease moves to a
697
+ * different runner. Bounded, one-off per runner per session, and paid
698
+ * in the direction of enforcing the policy the user chose. The
699
+ * conversation is preserved either way — the fallback replays it.
700
+ */
701
+ const wantedDecision = permissionFingerprint(session.agent.permissionMode);
702
+ if (memory.permissionDecision !== wantedDecision) {
703
+ throw new Error('permission mode changed or unrecorded ' +
704
+ `(${memory.permissionDecision ?? 'unrecorded'} -> ${wantedDecision}); ` +
705
+ 'the backend session must be rebuilt so the new policy takes effect');
706
+ }
707
+ const history = await adapter.resumeSession(memory.backendSessionId, config);
708
+ for (const message of history ?? []) {
709
+ for (const part of message.parts ?? []) {
710
+ if (part?.id)
711
+ priorPartIds.add(part.id);
712
+ }
713
+ }
714
+ backendSessionId = memory.backendSessionId;
715
+ resumed = true;
716
+ this.deps.telemetry.record('provider.session.resumed', {
717
+ backend: session.agent.backend,
718
+ runtime: session.runtime
719
+ });
720
+ }
721
+ catch (caught) {
722
+ // AN ORDINARY OUTCOME, NOT AN ERROR. A swept sandbox, a redeployed
723
+ // runner or a restarted OpenCode all land here. Falling through to a
724
+ // fresh session with the log replayed is the whole point of keeping
725
+ // the log, so this is logged at warn and the turn continues.
726
+ this.logger.warn(`[SessionRunner] could not resume backend session ` +
727
+ `${memory.backendSessionId} for ${session.sessionId}: ` +
728
+ `${caught instanceof Error ? caught.message : String(caught)}. ` +
729
+ 'Falling back to replay.');
730
+ this.deps.conversation?.setCheckpoint(session.sessionId, null, session.agent.backend, { backendOwnerId: ownBackendInstanceId });
731
+ }
732
+ }
733
+ if (!backendSessionId) {
734
+ backendSessionId = await adapter.createSession(config);
735
+ this.deps.telemetry.record('provider.session.started', {
736
+ backend: session.agent.backend,
737
+ runtime: session.runtime
738
+ });
739
+ }
740
+ // Record the checkpoint BEFORE the prompt runs. A turn that crashes
741
+ // half-way still leaves a resumable session behind, and the alternative
742
+ // — recording it only on success — loses the context in exactly the case
743
+ // where the user most wants it back.
744
+ this.deps.conversation?.setCheckpoint(session.sessionId, backendSessionId, session.agent.backend, {
745
+ // WHICH INSTANCE minted it, so only this runner resumes it (#1591).
746
+ backendOwnerId: ownBackendInstanceId,
747
+ // The decision this backend session was CREATED under. The next turn
748
+ // compares against it to decide whether the session can be reused
749
+ // (#1602). Independent of the owner; both are recorded.
750
+ permissionDecision: permissionFingerprint(session.agent.permissionMode)
751
+ });
752
+ /*
753
+ * FALLBACK: a fresh backend session knows nothing, so it has to be told.
754
+ * When the session was RESUMED this is skipped entirely and the prompt is
755
+ * byte-for-byte what it always was — a resumed backend already holds the
756
+ * history, and replaying it as well would both duplicate it and pay for
757
+ * it twice.
758
+ */
759
+ const promptText = resumed
760
+ ? turnContent
761
+ : (0, conversation_1.buildReplayPrompt)(memory.messages.map((message) => ({
762
+ role: message.role,
763
+ content: message.content
764
+ })), turnContent);
765
+ // There was no per-turn host-note mechanism in the harness. Keep the
766
+ // addition small: a workspace may supply one note, which is sent to the
767
+ // model but is not written into the durable user conversation.
768
+ const turnNote = this.deps.workspace.takeTurnNote?.(session.sessionId);
769
+ const promptWithHostNote = turnNote
770
+ ? `${turnNote}\n\n${promptText}`
771
+ : promptText;
772
+ replayed = !resumed && memory.messages.length > 0;
773
+ priorPartIdsForLog = priorPartIds;
774
+ await adapter.sendPrompt(backendSessionId, [{ type: 'text', text: promptWithHostNote, role: 'user' }], config);
775
+ this.deps.telemetry.record('provider.turn.sent', {
776
+ backend: session.agent.backend
777
+ });
778
+ /*
779
+ * WIRE 3 NEEDS A WAY IN. `run` is one long await, and the user's answer
780
+ * arrives on a control-plane command that the harness handles on a
781
+ * completely different stack. This is what `answerPendingApproval`
782
+ * reaches through. Cleared in the `finally` below, so an answer can never
783
+ * be applied to a turn that has already ended.
784
+ */
785
+ this.turnContext = {
786
+ sessionId: session.sessionId,
787
+ backendSessionId,
788
+ adapter,
789
+ config
790
+ };
791
+ const drained = await this.drain(adapter, backendSessionId, config, session, signal,
792
+ // RESUMING REPLAYS THE WHOLE BACKEND SESSION. `pollMessages` is asked
793
+ // for "everything not in these sets", and on a resumed session that is
794
+ // every message the conversation has ever had — so without seeding, a
795
+ // second turn re-emits turn one. The transcript upsert is idempotent so
796
+ // the UI survives it, but the MODEL LOG would not: each turn would
797
+ // absorb every previous turn's text and the stored conversation would
798
+ // grow quadratically until it filled the context window.
799
+ resumed ? priorPartIds : new Set(),
800
+ // THE PROMPT THIS RUNNER JUST SENT. Drain uses it to recognise the
801
+ // backend's own echo of it — see `drain`.
802
+ promptWithHostNote);
803
+ partsProduced = drained.parts;
804
+ if (signal.aborted) {
805
+ stopReason = 'aborted';
806
+ }
807
+ else if (drained.idleTimedOut) {
808
+ // THE PRIMARY GUARD. No progress at all for `maxTurnIdleMs` — the
809
+ // backend is hung, not merely slow. A healthy turn that keeps
810
+ // producing parts never reaches this no matter how long it runs.
811
+ stopReason = 'no_progress';
812
+ error = drained.idleTimedOutMessage ?? 'Turn stopped: no progress';
813
+ partsProduced += this.record(session.sessionId, [
814
+ {
815
+ id: `turn-idle-${(0, node_crypto_1.randomUUID)()}`,
816
+ role: 'system',
817
+ partType: 'error',
818
+ content: error
819
+ }
820
+ ]);
821
+ }
822
+ else if (drained.timedOut) {
823
+ // THE LAST-LINE CEILING, and only reached when an operator has
824
+ // actually configured `CODING_AGENT_MAX_TURN_MINUTES` — the default
825
+ // is no ceiling at all, enforced by the idle guard above instead.
826
+ stopReason = 'turn_time_limit';
827
+ error = 'Turn stopped: turn_time_limit';
828
+ this.logger.warn(`[SessionRunner] turn-hard-ceiling session=${session.sessionId} ` +
829
+ `maxTurnDurationMs=${session.limits.maxTurnDurationMs}`);
830
+ partsProduced += this.record(session.sessionId, [
831
+ {
832
+ id: `turn-limit-${(0, node_crypto_1.randomUUID)()}`,
833
+ role: 'system',
834
+ partType: 'error',
835
+ content: error
836
+ }
837
+ ]);
838
+ }
839
+ else if (drained.providerError) {
840
+ // A provider failure is a FAILED turn, never a completed one. Recorded
841
+ // on the turn so the control plane and the UI can both see it, and
842
+ // written into the transcript so the user is told rather than left
843
+ // looking at a conversation that silently stopped answering.
844
+ stopReason = 'error';
845
+ error = drained.providerError;
846
+ this.logger.error(`[SessionRunner] session ${session.sessionId} provider error: ${error}`);
847
+ partsProduced += this.record(session.sessionId, [
848
+ {
849
+ id: `error-${(0, node_crypto_1.randomUUID)()}`,
850
+ role: 'system',
851
+ content: describeProviderError(error ?? String(drained.providerError), qualifiedModelId(session.agent.model)),
852
+ partType: 'error'
853
+ }
854
+ ]);
855
+ }
856
+ }
857
+ catch (caught) {
858
+ stopReason = signal.aborted ? 'aborted' : 'error';
859
+ error = caught instanceof Error ? caught.message : String(caught);
860
+ this.logger.error(`[SessionRunner] session ${session.sessionId} failed: ${error}`);
861
+ // The failure IS the transcript's last word. Recording it as a part is
862
+ // what makes an error visible to the user instead of a session that
863
+ // simply stops producing output and never says why.
864
+ partsProduced += this.record(session.sessionId, [
865
+ {
866
+ id: `error-${(0, node_crypto_1.randomUUID)()}`,
867
+ role: 'system',
868
+ partType: 'error',
869
+ content: error
870
+ }
871
+ ]);
872
+ }
873
+ finally {
874
+ release();
875
+ this.deps.modelGateway?.revoke();
876
+ /*
877
+ * THE TURN CAN NO LONGER TAKE AN ANSWER.
878
+ *
879
+ * Cleared before anything else in teardown. `answerPendingApproval`
880
+ * checks `turnContext` first, so once this is null a late answer — one
881
+ * that raced the turn ending, or that arrived after an abort — is
882
+ * refused instead of being handed to an adapter whose backend session is
883
+ * on its way out. `closeApproval` also releases the harness from its
884
+ * tightened polling, which would otherwise keep running until the next
885
+ * turn happened to reset it.
886
+ */
887
+ this.turnContext = null;
888
+ this.closeApproval(session.sessionId);
889
+ this.closeQuestion(session.sessionId);
890
+ this.emittedApprovals.clear();
891
+ /*
892
+ * STOP THE PER-TURN PI PROCESS BEFORE THE NEXT LEASE CAN START.
893
+ *
894
+ * Pi receives the virtual gateway key and MCP proxy URLs in its child
895
+ * environment. The adapter therefore owns one process per turn: no
896
+ * server-wide config push exists, and a later turn cannot inherit an old
897
+ * key or old MCP token from a warm process.
898
+ *
899
+ * THE CONVERSATION SURVIVES. Pi persists the native session file under
900
+ * its durable agent directory. The next turn starts a fresh process with
901
+ * the new credentials and resumes that file only when backend-owner and
902
+ * permission-decision checks match; otherwise it replays the neutral log.
903
+ */
904
+ try {
905
+ await adapter?.stopServer?.();
906
+ }
907
+ catch {
908
+ // Never let teardown mask the turn's real outcome.
909
+ }
910
+ // Teardown is best-effort and ORDERED: revoke the secret grant first, so
911
+ // a backend that is slow to die cannot use it during the wait.
912
+ if (secretToken) {
913
+ try {
914
+ this.deps.secrets.unregisterSession(secretToken);
915
+ }
916
+ catch {
917
+ // Never let teardown mask the turn's real outcome.
918
+ }
919
+ }
920
+ /*
921
+ * THE BACKEND SESSION IS DELIBERATELY *NOT* DESTROYED HERE.
922
+ *
923
+ * It used to be, on every turn, and that single call was the defect: the
924
+ * session holds the conversation's context, so tearing it down at the end
925
+ * of each turn is what made the model start blank on the next one. It is
926
+ * kept alive as the resume checkpoint instead.
927
+ *
928
+ * WHAT THIS COSTS, SAID PLAINLY. The backend accumulates one live session
929
+ * per conversation for the life of the process, along with the adapter's
930
+ * per-session client maps. That is bounded by concurrent conversations
931
+ * rather than by turns, and it is how 20x has always behaved. On the
932
+ * sandbox runtime the sweep destroys the whole machine when the session
933
+ * ends, so nothing outlives it. Reclaiming a backend session on an
934
+ * explicit session-end signal is a real follow-up and is recorded as one;
935
+ * a leak measured in conversations is a far smaller problem than an agent
936
+ * that cannot remember the previous sentence.
937
+ */
938
+ }
939
+ const turn = {
940
+ turnId: session.turnId,
941
+ startedAt,
942
+ finishedAt: new Date().toISOString(),
943
+ model: session.agent.model.model,
944
+ stopReason,
945
+ error,
946
+ repositories: repositoryStatuses.length > 0 ? repositoryStatuses : undefined,
947
+ // The checkpoint travels on the final ingest because the final ingest is
948
+ // the ONE message guaranteed to be sent — it is what releases the lease,
949
+ // and it is sent even when the turn failed.
950
+ backendSessionId,
951
+ backendName: backendSessionId ? session.agent.backend : undefined,
952
+ /*
953
+ * Reported with the handle, never separately. A handle whose owner is
954
+ * unknown is exactly the handle that caused this defect, so the control
955
+ * plane must never be able to hold one without the other.
956
+ */
957
+ backendOwnerId: backendSessionId && ownBackendInstanceId
958
+ ? ownBackendInstanceId
959
+ : undefined
960
+ };
961
+ /*
962
+ * WHAT THE MODEL WAS TOLD, AND WHAT IT SAID — recorded for the next turn.
963
+ *
964
+ * `session.input.content` and NOT the replayed prompt: the log is the
965
+ * conversation, and the replay preamble is scaffolding built FROM the log.
966
+ * Writing the preamble back in would nest the conversation inside itself,
967
+ * and it would do so once per turn, so the prompt would grow
968
+ * quadratically until the context window ended the session.
969
+ */
970
+ /*
971
+ * ONLY WHAT THIS TURN SAID.
972
+ *
973
+ * Belt and braces behind the `seenPartIds` seeding above. Both guard the
974
+ * same failure — a resumed session re-emitting its whole history into the
975
+ * model log — and they fail independently: the seeding depends on the
976
+ * adapter honouring `seenPartIds`, this depends only on what is already in
977
+ * our own store. The consequence of getting it wrong is not a wrong answer
978
+ * but a conversation that silently doubles in size every turn, which is the
979
+ * kind of defect that looks fine for a week.
980
+ */
981
+ const spoken = (0, conversation_1.assistantTextFrom)(this.turnParts.filter((part) => !priorPartIdsForLog.has(part.partId)));
982
+ const modelMessages = [
983
+ { role: 'user', content: turnContent },
984
+ ...(spoken ? [{ role: 'assistant', content: spoken }] : [])
985
+ ];
986
+ // Store locally FIRST, for the same reason parts are: the local copy is
987
+ // what lets this runner answer the next turn without a network call, and a
988
+ // send that happens first would be lost on a crash between the two.
989
+ try {
990
+ this.deps.conversation?.appendModelMessages(session.sessionId, modelMessages);
991
+ }
992
+ catch (caught) {
993
+ // A cache that cannot be written is a slow next turn, not a failed one.
994
+ this.logger.warn(`[SessionRunner] could not cache model messages for ` +
995
+ `${session.sessionId}: ` +
996
+ `${caught instanceof Error ? caught.message : String(caught)}`);
997
+ }
998
+ // The FINAL ingest carries the turn metadata and releases the lease. It is
999
+ // sent even when the turn failed: a lease that is never released is a
1000
+ // session that hangs until the watchdog reaps it, and the user waits.
1001
+ /*
1002
+ * THE FINAL FLUSH MUST CARRY ANY PARTS STILL PENDING, not an empty array.
1003
+ *
1004
+ * `this.pending` is otherwise drained only INSIDE `drain()`, so anything
1005
+ * recorded after it returns never reached the control plane. That silently
1006
+ * broke the error path: the catch block below records the failure as a
1007
+ * transcript part and its comment calls that "the transcript's last word",
1008
+ * but the part went into the local store and no further — the user was left
1009
+ * with a conversation that stopped answering and never said why.
1010
+ *
1011
+ * Sending `[]` here was safe only for as long as nothing was recorded after
1012
+ * the drain. That has not been true since the error path was added.
1013
+ */
1014
+ await this.flush(session.sessionId, this.pending.splice(0), turn, true, modelMessages);
1015
+ this.deps.telemetry.record('provider.turn.memory', {
1016
+ resumed,
1017
+ replayed,
1018
+ priorMessages: this.priorMessageCount
1019
+ });
1020
+ this.deps.telemetry.record('provider.session.stopped', {
1021
+ backend: session.agent.backend,
1022
+ stopReason
1023
+ });
1024
+ return {
1025
+ turn,
1026
+ maxRev: this.deps.store.getMaxRev(session.sessionId),
1027
+ partsProduced
1028
+ };
1029
+ }
1030
+ /* ---------------------------------------------------------------- */
1031
+ /* The poll loop */
1032
+ /* ---------------------------------------------------------------- */
1033
+ /**
1034
+ * Does this polled part merely mirror the prompt the runner itself sent?
1035
+ *
1036
+ * Matched on TEXT, not on id, because the echo's id belongs to the backend
1037
+ * (`user-message-3`, `user-173…`, `<uuid>-text`) and no runner can predict
1038
+ * it. Matched as a PREFIX of the sent prompt, not equality, because ACP
1039
+ * agents deliver the echo as `user_message_chunk` updates whose accumulated
1040
+ * text grows to the full prompt over several polls — dropping only the final
1041
+ * equal one would strand the intermediate versions in the store.
1042
+ *
1043
+ * Both sides are trimmed first: a backend that strips a trailing newline
1044
+ * from the echoed text must not slip past the prefix check on that alone.
1045
+ */
1046
+ isEchoOfTurnPrompt(part, sentPromptText) {
1047
+ if (typeof part.role !== 'string' || part.role.toLowerCase() !== 'user') {
1048
+ return false;
1049
+ }
1050
+ const text = part.content ?? part.text;
1051
+ if (typeof text !== 'string')
1052
+ return false;
1053
+ const echoed = text.trim();
1054
+ if (echoed.length === 0)
1055
+ return false;
1056
+ return sentPromptText.trim().startsWith(echoed);
1057
+ }
1058
+ /**
1059
+ * Poll the adapter until the turn goes quiet or the signal aborts.
1060
+ *
1061
+ * The adapter's `onDataAvailable` callback wakes this immediately when the
1062
+ * backend has buffered output, so the 2s interval is a FLOOR for liveness
1063
+ * rather than the streaming latency. Without it every token would arrive up
1064
+ * to two seconds late and the UI would look broken.
1065
+ */
1066
+ /**
1067
+ * Pump the backend until it goes quiet, returning what was produced AND any
1068
+ * provider error the backend reported.
1069
+ *
1070
+ * THE ERROR HALF OF THE RETURN IS THE POINT. `getStatus` reports a provider
1071
+ * failure as `type: 'error'` with a message, and this loop used to fold that
1072
+ * into `quiet` alongside `idle` — it stopped draining, which is correct, and
1073
+ * then threw the message away, which is not. The turn went on to be recorded
1074
+ * as `stopReason: 'completed'` with `error: null`.
1075
+ *
1076
+ * That is not theoretical. On 2026-08-22 a gateway stopped serving the
1077
+ * configured model; five consecutive turns logged
1078
+ * `Provider error … Invalid model name passed in model=gpt-4.1`, produced no
1079
+ * assistant output at all, and were all stored as SUCCESSFUL turns. The UI
1080
+ * faithfully showed five user messages and no replies, the runner showed
1081
+ * `ready`, and nothing anywhere said the model call had failed.
1082
+ */
1083
+ async drain(adapter, backendSessionId, config, session, signal, alreadySeenPartIds = new Set(),
1084
+ /** The exact text `sendPrompt` was given this turn, for echo filtering. */
1085
+ sentPromptText = null) {
1086
+ let providerError;
1087
+ let timedOut = false;
1088
+ let idleTimedOut = false;
1089
+ let idleTimedOutMessage;
1090
+ const seenMessageIds = new Set();
1091
+ const seenPartIds = new Set(alreadySeenPartIds);
1092
+ const partContentLengths = new Map();
1093
+ let produced = 0;
1094
+ let idleSeen = 0;
1095
+ let wake = null;
1096
+ adapter.onDataAvailable = () => {
1097
+ wake?.();
1098
+ };
1099
+ /*
1100
+ * THE LAST-LINE CEILING. `null` means no operator-configured ceiling — see
1101
+ * `LeasedLimits.maxTurnDurationMs`. `<= 0` is treated THE SAME WAY, not as
1102
+ * "expire immediately": the control plane already normalises an explicit
1103
+ * `0` env value to `null` before it ever reaches the wire (config.ts
1104
+ * `maxTurnDurationMsFromEnv`), so a real lease never carries a zero or
1105
+ * negative number on purpose. Treating one as "no ceiling" here — rather
1106
+ * than as an instantly-past deadline — is what keeps this side of the
1107
+ * wire consistent with that normalisation, and is also just the safer
1108
+ * reading of a value nothing should ever legitimately send.
1109
+ */
1110
+ const hardCeilingMs = session.limits.maxTurnDurationMs;
1111
+ const hardDeadline = hardCeilingMs === null ||
1112
+ hardCeilingMs === undefined ||
1113
+ hardCeilingMs <= 0
1114
+ ? null
1115
+ : Date.now() + hardCeilingMs;
1116
+ const softWarningAt = hardDeadline === null
1117
+ ? null
1118
+ : Date.now() + hardCeilingMs * HARD_CEILING_WARNING_FRACTION;
1119
+ let softWarningSent = false;
1120
+ /*
1121
+ * THE PRIMARY GUARD. Reset on every part, tool-call start/end or streamed
1122
+ * text — anything `pollMessages` returns as a new part. `lastProgressAt`
1123
+ * plus the approval total captured AT that moment is enough to compute,
1124
+ * later, exactly how much of the time since then was genuine silence
1125
+ * versus a human being waited on — see the idle check below.
1126
+ */
1127
+ const idleLimitMs = session.limits.maxTurnIdleMs ?? DEFAULT_MAX_TURN_IDLE_MS;
1128
+ let lastProgressAt = Date.now();
1129
+ let approvalWaitAtLastProgress = this.approvalWaitMs;
1130
+ try {
1131
+ while (!signal.aborted) {
1132
+ const now = Date.now();
1133
+ // Historical approval wait PLUS whatever approval is open right now.
1134
+ // Added to both the hard deadline and, below, the idle window — a
1135
+ // turn stopped to ask a person something is blocked on them, not
1136
+ // running away, and ten minutes is an entirely ordinary time for a
1137
+ // person to be away from their desk.
1138
+ const pausedMs = this.approvalWaitMs + this.liveApprovalWaitMs();
1139
+ if (hardDeadline !== null && now > hardDeadline + pausedMs) {
1140
+ timedOut = true;
1141
+ break;
1142
+ }
1143
+ /*
1144
+ * THE SOFT WARNING, once, at 90% of a CONFIGURED ceiling. Written to
1145
+ * the transcript unconditionally; also offered to the backend as a
1146
+ * mid-turn nudge when the adapter can take one (`steerTurn`) — so the
1147
+ * turn has a chance to end with a summary instead of a cut.
1148
+ */
1149
+ if (softWarningAt !== null &&
1150
+ !softWarningSent &&
1151
+ now > softWarningAt + pausedMs) {
1152
+ softWarningSent = true;
1153
+ const remainingMs = Math.max(0, hardDeadline + pausedMs - now);
1154
+ produced += await this.warnOfHardCeiling(adapter, backendSessionId, config, session, remainingMs);
1155
+ }
1156
+ /*
1157
+ * THE PI MODEL-KEY EXPIRY NOTICE — a disclosed gap, not a silent
1158
+ * failure. `updateModelCredential` can push a refreshed token into a
1159
+ * backend that implements `notifyConfigChanged` (OpenCode); one that
1160
+ * does not (currently Pi, whose credential is fixed in its child
1161
+ * process's environment at spawn) keeps running on whatever key it
1162
+ * started with even after the control plane has moved on. Once that
1163
+ * key's known expiry passes, this backend's NEXT model call is about
1164
+ * to fail with an ordinary-looking auth error that reads as a random
1165
+ * provider outage. Surfaced here instead, once, with the real reason.
1166
+ * Never fires for a sandbox turn: its per-SANDBOX key carries no
1167
+ * `modelTokenExpiresAt` to begin with (see `buildLeaseBody`).
1168
+ */
1169
+ if (!this.modelKeyExpiryNoticeShown &&
1170
+ this.modelTokenExpiresAt &&
1171
+ typeof adapter.notifyConfigChanged !== 'function' &&
1172
+ now > Date.parse(this.modelTokenExpiresAt)) {
1173
+ this.modelKeyExpiryNoticeShown = true;
1174
+ this.logger.warn(`[SessionRunner] model-key-expired-mid-turn session=${session.sessionId} ` +
1175
+ `backend=${session.agent.backend} expiredAt=${this.modelTokenExpiresAt}`);
1176
+ produced += this.record(session.sessionId, [
1177
+ {
1178
+ id: `model-key-expired-${(0, node_crypto_1.randomUUID)()}`,
1179
+ role: 'system',
1180
+ partType: 'error',
1181
+ content: 'Model credential expired after 1 hour on this runner; send a new message to continue.'
1182
+ }
1183
+ ]);
1184
+ await this.flush(session.sessionId, this.pending.splice(0), undefined, false);
1185
+ }
1186
+ const parts = (await adapter.pollMessages(backendSessionId, seenMessageIds, seenPartIds, partContentLengths, config));
1187
+ /*
1188
+ * THE PROMPT'S OWN ECHO IS NOT CONVERSATION.
1189
+ *
1190
+ * The user's message is written into the transcript by the control
1191
+ * plane when the turn is enqueued (`enqueueTurn` →
1192
+ * `recordUserTurnPart`), so it is durable before this runner is even
1193
+ * aware of the turn. Several backends ALSO echo the prompt back
1194
+ * through `pollMessages` — Codex pushes a synthetic user message into
1195
+ * its buffer, ACP agents stream `user_message` chunks, the Claude Code
1196
+ * SDK re-emits streamed input — and recording that echo would put the
1197
+ * same words in the transcript TWICE under two part ids, which no
1198
+ * idempotency key can merge.
1199
+ *
1200
+ * So anything a backend emits as a user part whose text is the prompt
1201
+ * this runner just handed it — or a streaming prefix of it — is
1202
+ * dropped. This is safe because every user word reaches the backend
1203
+ * through `sendPrompt`: mid-turn there is no OTHER user text it could
1204
+ * be. Answers to questions travel a different wire (`respondTo*`) and
1205
+ * are recorded by the answer path itself, with the `answer-…` part id.
1206
+ */
1207
+ const freshParts = sentPromptText && sentPromptText.length > 0
1208
+ ? parts.filter((part) => !this.isEchoOfTurnPrompt(part, sentPromptText))
1209
+ : parts;
1210
+ /*
1211
+ * WIRE 2: A PENDING PERMISSION BECOMES A TRANSCRIPT PART.
1212
+ *
1213
+ * Checked BEFORE the produced-parts branch `continue`s, or an approval
1214
+ * raised in the same pass as some output would not be seen until the
1215
+ * backend next went quiet — which, while it waits for the very approval
1216
+ * we failed to show, is never.
1217
+ */
1218
+ produced += await this.emitPendingApproval(adapter, backendSessionId, session);
1219
+ if (freshParts.length > 0) {
1220
+ produced += this.record(session.sessionId, freshParts);
1221
+ await this.flush(session.sessionId, this.pending.splice(0), undefined, false);
1222
+ // Real output resets the quiet counter. A backend is idle BETWEEN
1223
+ // tool calls as well as at the end of a turn, so ending on the first
1224
+ // idle would truncate almost every transcript at the first tool.
1225
+ idleSeen = 0;
1226
+ // ...and resets the PRIMARY guard's clock, snapshotting the
1227
+ // approval total so a later stretch of silence is measured from
1228
+ // here, not double-counting wait time already excluded once.
1229
+ lastProgressAt = Date.now();
1230
+ approvalWaitAtLastProgress = this.approvalWaitMs;
1231
+ continue;
1232
+ }
1233
+ const status = await adapter.getStatus(backendSessionId, config);
1234
+ if (status.type === 'error' && status.message) {
1235
+ // Last one wins: a retry that also fails should report its own reason.
1236
+ providerError = status.message;
1237
+ }
1238
+ const quiet = status.type === 'idle' || status.type === 'error';
1239
+ idleSeen = quiet ? idleSeen + 1 : 0;
1240
+ if (idleSeen >= IDLE_CONFIRMATIONS) {
1241
+ break;
1242
+ }
1243
+ /*
1244
+ * THE IDLE CHECK ITSELF. Wall-clock time since the last real progress,
1245
+ * minus whatever of that stretch was spent waiting on a human — the
1246
+ * approval accrued SINCE `lastProgressAt`, not the turn's whole
1247
+ * history, or a long-closed approval from earlier in the turn would be
1248
+ * subtracted a second time and silence that started long after it
1249
+ * closed would be under-counted.
1250
+ *
1251
+ * SKIPPED OUTRIGHT WHILE AN APPROVAL IS OPEN RIGHT NOW, rather than
1252
+ * trusted to the arithmetic above — the race that arithmetic cannot
1253
+ * close on its own: `emitPendingApproval` can OPEN a brand-new
1254
+ * approval in THIS SAME ITERATION, moments before this check runs,
1255
+ * so `liveApprovalWaitMs()` is still ~0 and barely dents an
1256
+ * `idleElapsed` that had already been accumulating stale silence for
1257
+ * up to `idleLimitMs`. Without this guard a question could be posted
1258
+ * and the turn cut as `no_progress` in the same tick, before the
1259
+ * person it was raised for ever sees it.
1260
+ *
1261
+ * TODO(approval TTL): an approval left open suppresses BOTH this
1262
+ * guard and the hard ceiling for as long as it stays open — by
1263
+ * design, since a slow human must never read as a hung backend, but
1264
+ * that means an approval nobody ever answers can hold a turn open
1265
+ * indefinitely today. Pre-existing behaviour (unrelated to the LONG
1266
+ * TURNS fix), not changed here; a bounded approval wait — after
1267
+ * which the turn ends on its own rather than waiting forever — is a
1268
+ * real follow-up, not implemented in this PR.
1269
+ */
1270
+ const approvalWaitSinceProgress = this.approvalWaitMs -
1271
+ approvalWaitAtLastProgress +
1272
+ this.liveApprovalWaitMs();
1273
+ const idleElapsed = Date.now() - lastProgressAt - approvalWaitSinceProgress;
1274
+ if (this.approvalWaitStartedAt === null && idleElapsed > idleLimitMs) {
1275
+ this.logger.warn(`[SessionRunner] session ${session.sessionId} had no progress for ` +
1276
+ `${Math.round(idleElapsed / 1000)}s (limit ${Math.round(idleLimitMs / 1000)}s)`);
1277
+ idleTimedOut = true;
1278
+ idleTimedOutMessage = `Turn stopped: no progress for ${formatMinutes(idleLimitMs)}`;
1279
+ break;
1280
+ }
1281
+ // Wait for whichever comes first: the poll interval, the backend
1282
+ // pushing (`wake`), or an abort. `finish` is idempotent because all
1283
+ // three can fire, and a second `resolveWait` on a settled promise
1284
+ // would otherwise leak the timer and the abort listener for the rest
1285
+ // of the turn.
1286
+ await new Promise((resolveWait) => {
1287
+ let settled = false;
1288
+ const timer = { handle: null };
1289
+ const finish = () => {
1290
+ if (settled) {
1291
+ return;
1292
+ }
1293
+ settled = true;
1294
+ if (timer.handle)
1295
+ clearTimeout(timer.handle);
1296
+ signal.removeEventListener('abort', finish);
1297
+ wake = null;
1298
+ resolveWait();
1299
+ };
1300
+ timer.handle = setTimeout(finish, POLL_INTERVAL_MS);
1301
+ wake = finish;
1302
+ signal.addEventListener('abort', finish, { once: true });
1303
+ });
1304
+ }
1305
+ }
1306
+ finally {
1307
+ adapter.onDataAvailable = undefined;
1308
+ // A part produced between the last flush and the loop ending must not be
1309
+ // lost; the final ingest below only carries turn metadata.
1310
+ if (this.pending.length > 0) {
1311
+ await this.flush(session.sessionId, this.pending.splice(0), undefined, false);
1312
+ }
1313
+ }
1314
+ if (signal.aborted || timedOut || idleTimedOut) {
1315
+ try {
1316
+ await adapter.abortPrompt(backendSessionId, config);
1317
+ }
1318
+ catch {
1319
+ // An abort that cannot be delivered still ends the turn here.
1320
+ }
1321
+ }
1322
+ return {
1323
+ parts: produced,
1324
+ providerError,
1325
+ timedOut,
1326
+ idleTimedOut,
1327
+ idleTimedOutMessage
1328
+ };
1329
+ }
1330
+ /**
1331
+ * THE SOFT WARNING, fired once at 90% of a CONFIGURED hard ceiling.
1332
+ *
1333
+ * Always written into the transcript so the user sees the countdown even
1334
+ * when the backend cannot take a mid-turn nudge. `steerTurn` is optional on
1335
+ * `CodingAgentAdapter` for exactly that reason: Pi can deliver a message to
1336
+ * a running turn (`streamingBehavior: 'steer'`); OpenCode's `sendPrompt` is
1337
+ * a single long-lived HTTP call per session and calling it again while one
1338
+ * is already in flight is not safe, so it does not implement this and the
1339
+ * warning stays transcript-only there.
1340
+ */
1341
+ async warnOfHardCeiling(adapter, backendSessionId, config, session, remainingMs) {
1342
+ const message = `You have ${formatMinutes(remainingMs)} left in this turn. ` +
1343
+ 'Commit your work, write a short status, and stop.';
1344
+ // THE RETURN VALUE MATTERS. This part must count toward `produced` in
1345
+ // `drain()`'s caller, the same as every other part recorded there —
1346
+ // otherwise the turn's reported `partsProduced` silently undercounts by
1347
+ // one on every turn a configured ceiling's 90% mark actually fires.
1348
+ const produced = this.record(session.sessionId, [
1349
+ {
1350
+ id: `turn-warning-${(0, node_crypto_1.randomUUID)()}`,
1351
+ role: 'system',
1352
+ partType: 'text',
1353
+ content: message
1354
+ }
1355
+ ]);
1356
+ await this.flush(session.sessionId, this.pending.splice(0), undefined, false);
1357
+ const steerTurn = adapter.steerTurn;
1358
+ if (!steerTurn) {
1359
+ this.logger.warn(`[SessionRunner] session ${session.sessionId}: backend '${session.agent.backend}' ` +
1360
+ 'cannot take a mid-turn message; the ceiling warning is transcript-only.');
1361
+ return produced;
1362
+ }
1363
+ try {
1364
+ await steerTurn.call(adapter, backendSessionId, message, config);
1365
+ }
1366
+ catch (caught) {
1367
+ this.logger.warn(`[SessionRunner] session ${session.sessionId}: could not deliver the ` +
1368
+ `ceiling warning to the backend: ${caught instanceof Error ? caught.message : String(caught)}`);
1369
+ }
1370
+ return produced;
1371
+ }
1372
+ /* ---------------------------------------------------------------- */
1373
+ /* PERMISSION APPROVALS — wires 2 and 3 of defect G1 */
1374
+ /* ---------------------------------------------------------------- */
1375
+ /** Wall-clock spent in the approval we are in RIGHT NOW, if any. */
1376
+ liveApprovalWaitMs() {
1377
+ return this.approvalWaitStartedAt === null
1378
+ ? 0
1379
+ : Date.now() - this.approvalWaitStartedAt;
1380
+ }
1381
+ /**
1382
+ * WIRE 2. Turn a pending backend permission into a `question` transcript part.
1383
+ *
1384
+ * The whole channel below this already existed and was simply never joined
1385
+ * up. The OpenCode adapter queues `permission.asked` events, reports
1386
+ * `WAITING_APPROVAL` from `getStatus` while any are outstanding, and exposes
1387
+ * `getPendingApproval`. The control plane already accepts an answer and
1388
+ * queues it as a session command. The UI already renders a `question` part,
1389
+ * already shows its options as buttons, and already posts `optionId` back.
1390
+ * The only thing missing was somebody asking the adapter.
1391
+ *
1392
+ * THIS IS DELIBERATELY THE SAME SHAPE 20x EMITS (agent-manager.ts:2342-2367),
1393
+ * because the two products render the same transcript and a second shape
1394
+ * would mean a second renderer. ONE DIFFERENCE, ON PURPOSE: 20x builds its
1395
+ * options as `{ label }` and throws `optionId` away, which forces its IPC
1396
+ * layer to recover the choice by matching a display label against a hardcoded
1397
+ * map of English strings (agent-manager.ts:4510-4516). We keep the id. Labels
1398
+ * are ambiguous, repeatable between questions and translatable; ids are none
1399
+ * of those things.
1400
+ *
1401
+ * `getPendingApproval` is DUCK-TYPED rather than declared on
1402
+ * `CodingAgentAdapter` — again matching 20x — so a backend with no permission
1403
+ * concept needs no stub and is simply never asked.
1404
+ */
1405
+ async emitPendingApproval(adapter, backendSessionId, session) {
1406
+ const capable = adapter;
1407
+ if (typeof capable.getPendingApproval !== 'function') {
1408
+ return 0;
1409
+ }
1410
+ let approval;
1411
+ try {
1412
+ approval = capable.getPendingApproval(backendSessionId);
1413
+ }
1414
+ catch (error) {
1415
+ // An adapter that throws here must not take the turn down with it: the
1416
+ // turn is still running and its output is still worth collecting.
1417
+ this.logger.warn(`[SessionRunner] getPendingApproval threw for session ${session.sessionId}: ${error instanceof Error ? error.message : String(error)}`);
1418
+ return 0;
1419
+ }
1420
+ if (!approval) {
1421
+ // The backend has stopped waiting. That happens on our own answer, and
1422
+ // ALSO when the backend resolves the permission itself — a timeout, an
1423
+ // abort, an auto-approve. Either way the turn is no longer blocked on a
1424
+ // human, so the clock starts again and the harness may relax its polling.
1425
+ this.closeApproval(session.sessionId);
1426
+ return 0;
1427
+ }
1428
+ // Already shown. Do not re-emit: an upsert on the same partId is harmless
1429
+ // to the transcript but would bump `rev` on every 2s pass, and the UI polls
1430
+ // on the cursor — the conversation would flicker for as long as the user
1431
+ // took to decide.
1432
+ if (this.emittedApprovals.has(approval.toolCallId)) {
1433
+ return 0;
1434
+ }
1435
+ this.emittedApprovals.add(approval.toolCallId);
1436
+ const partId = `question-${approval.toolCallId}`;
1437
+ const optionIdByLabel = new Map();
1438
+ const knownOptionIds = new Set();
1439
+ for (const option of approval.options) {
1440
+ optionIdByLabel.set(option.name.trim().toLowerCase(), option.optionId);
1441
+ knownOptionIds.add(option.optionId);
1442
+ }
1443
+ this.openApproval = {
1444
+ partId,
1445
+ toolCallId: approval.toolCallId,
1446
+ optionIdByLabel,
1447
+ knownOptionIds
1448
+ };
1449
+ if (this.approvalWaitStartedAt === null) {
1450
+ this.approvalWaitStartedAt = Date.now();
1451
+ }
1452
+ this.deps.onApprovalStateChange?.(session.sessionId, true);
1453
+ this.logger.log(`[SessionRunner] session ${session.sessionId} is waiting for approval: ${approval.question}`);
1454
+ const produced = this.record(session.sessionId, [
1455
+ {
1456
+ id: partId,
1457
+ role: 'assistant',
1458
+ partType: 'question',
1459
+ content: approval.question,
1460
+ tool: {
1461
+ name: 'permission',
1462
+ status: 'running',
1463
+ title: approval.question
1464
+ },
1465
+ questions: [
1466
+ {
1467
+ header: 'Permission Required',
1468
+ question: approval.question,
1469
+ options: approval.options.map((option) => ({
1470
+ id: option.optionId,
1471
+ label: option.name
1472
+ }))
1473
+ }
1474
+ ]
1475
+ }
1476
+ ]);
1477
+ /*
1478
+ * FLUSHED HERE, NOT LEFT FOR THE NEXT PASS.
1479
+ *
1480
+ * Every other part in this loop is flushed by the `parts.length > 0`
1481
+ * branch. A question raised on a pass that produced no other output would
1482
+ * otherwise sit in `this.pending` until the NEXT flush — and the next flush
1483
+ * comes when the backend produces something, which it cannot do, because it
1484
+ * is blocked on the question we did not send. The turn would hang with an
1485
+ * empty transcript and no prompt: the exact "silently hangs" failure the
1486
+ * ordering warning in this work's brief is about.
1487
+ */
1488
+ await this.flush(session.sessionId, this.pending.splice(0), undefined, false);
1489
+ return produced;
1490
+ }
1491
+ /**
1492
+ * Tell the USER that their answer could not be delivered.
1493
+ *
1494
+ * Without this, a failed delivery is a server-side log line and nothing else:
1495
+ * the question card stays open, the turn keeps waiting (approval time is
1496
+ * deliberately excluded from the turn deadline), and the person who clicked
1497
+ * Allow has no way to tell their click from a slow backend. An error part is
1498
+ * the only channel that reaches them.
1499
+ *
1500
+ * The approval is deliberately left OPEN — the backend never heard the
1501
+ * answer, so the prompt is still genuinely outstanding and answering again is
1502
+ * the right thing to do.
1503
+ */
1504
+ async reportApprovalFailure(sessionId, reason) {
1505
+ if (!this.turnContext || this.turnContext.sessionId !== sessionId) {
1506
+ return;
1507
+ }
1508
+ this.record(sessionId, [
1509
+ {
1510
+ id: `approval-error-${(0, node_crypto_1.randomUUID)()}`,
1511
+ role: 'system',
1512
+ partType: 'error',
1513
+ content: 'Your answer could not be delivered to the agent, so the request is ' +
1514
+ `still waiting. Please answer it again. (${reason})`
1515
+ }
1516
+ ]);
1517
+ await this.flush(sessionId, this.pending.splice(0), undefined, false);
1518
+ }
1519
+ /**
1520
+ * MID-TURN MODEL-CREDENTIAL REFRESH — the LONG TURNS audit's answer to a
1521
+ * fixed per-turn LiteLLM key TTL. Called from the harness's lease-renewal
1522
+ * callback when the control plane re-mints the token near its old expiry.
1523
+ *
1524
+ * Updates the shared gateway holder unconditionally (so the NEXT model call
1525
+ * on this turn already uses it), then asks the adapter to push it to a
1526
+ * running backend that can take a live config update. `notifyConfigChanged`
1527
+ * is optional on `CodingAgentAdapter` for exactly this reason: OpenCode
1528
+ * implements it (a shared server, config pushed over HTTP); Pi does not —
1529
+ * its credential is fixed in the child process's environment at spawn, so a
1530
+ * refresh here updates the holder for the SAME session's next turn (a fresh
1531
+ * Pi process) but not the one currently running. That is a real, disclosed
1532
+ * gap, not a silent one: Pi turns still rely on the 1h key outlasting the
1533
+ * turn, same as before this method existed — `expiresAt` is recorded
1534
+ * regardless, so `drain()`'s expiry notice still fires on the correct
1535
+ * (refreshed) deadline rather than the original, already-superseded one.
1536
+ */
1537
+ updateModelCredential(sessionId, apiKey, expiresAt) {
1538
+ if (!this.turnContext || this.turnContext.sessionId !== sessionId) {
1539
+ return;
1540
+ }
1541
+ if (expiresAt) {
1542
+ this.modelTokenExpiresAt = expiresAt;
1543
+ this.modelKeyExpiryNoticeShown = false;
1544
+ }
1545
+ this.deps.modelGateway?.updateApiKey(apiKey);
1546
+ try {
1547
+ void this.turnContext.adapter.notifyConfigChanged?.();
1548
+ }
1549
+ catch {
1550
+ // A backend that cannot take the push keeps running on its old
1551
+ // credential until it, too, expires — not ideal, but no worse than
1552
+ // before this method existed, and never worth ending the turn over.
1553
+ }
1554
+ }
1555
+ /** Stop the approval clock and tell the harness it may relax. */
1556
+ closeApproval(sessionId) {
1557
+ if (this.approvalWaitStartedAt !== null) {
1558
+ this.approvalWaitMs += Date.now() - this.approvalWaitStartedAt;
1559
+ this.approvalWaitStartedAt = null;
1560
+ }
1561
+ if (this.openApproval) {
1562
+ this.openApproval = null;
1563
+ this.deps.onApprovalStateChange?.(sessionId, false);
1564
+ }
1565
+ }
1566
+ closeQuestion(sessionId) {
1567
+ if (this.openQuestionPartId) {
1568
+ this.openQuestionPartId = null;
1569
+ this.deps.onApprovalStateChange?.(sessionId, false);
1570
+ }
1571
+ }
1572
+ /**
1573
+ * WIRE 3. Apply an `answer` session command to the running turn.
1574
+ *
1575
+ * Before this existed, `harness.applyCommand` marked an `answer` as applied
1576
+ * and DROPPED IT. That is what made the permission mode unshippable rather
1577
+ * than merely unenforced: turning the mode on without this would have turned
1578
+ * "silently unrestricted" into "silently hangs on every turn", and because
1579
+ * the default agent stores `permissionMode: 'default'`, it would have hit
1580
+ * every session at once.
1581
+ *
1582
+ * Returns true when the answer reached the backend.
1583
+ */
1584
+ async answerPendingApproval(command) {
1585
+ const context = this.turnContext;
1586
+ if (!context || context.sessionId !== command.sessionId) {
1587
+ return 'ignored';
1588
+ }
1589
+ const open = this.openApproval;
1590
+ if (!open) {
1591
+ /*
1592
+ * A NORMAL AGENT QUESTION, NOT A PERMISSION PROMPT.
1593
+ *
1594
+ * Both kinds are rendered as `partType: question` and travel back over
1595
+ * the same `answer` command, but only permission prompts populate
1596
+ * `openApproval`. The old code returned `ignored` here, even though every
1597
+ * extracted adapter already exposes `respondToQuestion`. Consequently a
1598
+ * click on an ordinary question reached the runner and vanished.
1599
+ */
1600
+ const capable = context.adapter;
1601
+ if (!this.openQuestionPartId) {
1602
+ this.logger.warn(`[SessionRunner] answer for session ${command.sessionId} arrived with no open question; ignoring`);
1603
+ return 'ignored';
1604
+ }
1605
+ if (command.partId && command.partId !== this.openQuestionPartId) {
1606
+ this.logger.warn(`[SessionRunner] answer for session ${command.sessionId} names part ${command.partId}, but ${this.openQuestionPartId} is open; ignoring`);
1607
+ return 'ignored';
1608
+ }
1609
+ if (typeof capable.respondToQuestion !== 'function') {
1610
+ this.logger.warn(`[SessionRunner] answer for session ${command.sessionId} arrived with no open approval and this backend cannot answer ordinary questions; ignoring`);
1611
+ return 'ignored';
1612
+ }
1613
+ const answers = decodeQuestionAnswers(command.answer);
1614
+ if (Object.keys(answers).length === 0) {
1615
+ this.logger.warn(`[SessionRunner] empty answer for ordinary question on session ${command.sessionId}; ignoring`);
1616
+ return 'ignored';
1617
+ }
1618
+ try {
1619
+ const delivered = await capable.respondToQuestion(context.backendSessionId, answers, context.config, command.partId ?? this.openQuestionPartId);
1620
+ if (delivered === false ||
1621
+ (typeof delivered === 'object' && delivered.handled === false)) {
1622
+ this.logger.error(`[SessionRunner] backend reported it did not accept the question answer for session ${command.sessionId}`);
1623
+ return 'failed';
1624
+ }
1625
+ }
1626
+ catch (error) {
1627
+ this.logger.error(`[SessionRunner] failed to deliver question answer for session ${command.sessionId}: ${error instanceof Error ? error.message : String(error)}`);
1628
+ return 'failed';
1629
+ }
1630
+ // This user part closes the question card and restores the ordinary
1631
+ // composer. Without it every later message is posted as another answer.
1632
+ this.record(context.sessionId, [
1633
+ {
1634
+ id: `answer-${command.partId ?? (0, node_crypto_1.randomUUID)()}`,
1635
+ role: 'user',
1636
+ partType: 'text',
1637
+ content: command.answer ?? Object.values(answers).join('\n')
1638
+ }
1639
+ ]);
1640
+ await this.flush(context.sessionId, this.pending.splice(0), undefined, false);
1641
+ this.closeQuestion(context.sessionId);
1642
+ return 'delivered';
1643
+ }
1644
+ /*
1645
+ * THE ANSWER MUST NAME THE QUESTION IT IS ANSWERING.
1646
+ *
1647
+ * The UI posts back the `partId` of the card the user clicked. If that is
1648
+ * not the prompt currently outstanding, the user answered a DIFFERENT
1649
+ * question — a stale tab, a double click, a card still on screen after the
1650
+ * backend moved on. Applying it would attach a human's "yes" to a tool call
1651
+ * they never read. `partId` is optional in the contract, so an answer
1652
+ * without one is accepted; an answer with the WRONG one never is.
1653
+ */
1654
+ if (command.partId && command.partId !== open.partId) {
1655
+ this.logger.warn(`[SessionRunner] answer for session ${command.sessionId} names part ${command.partId}, but ${open.partId} is open; ignoring`);
1656
+ return 'ignored';
1657
+ }
1658
+ const decision = resolveApproval(command, open);
1659
+ const capable = context.adapter;
1660
+ if (typeof capable.respondToApproval !== 'function') {
1661
+ this.logger.warn(`[SessionRunner] backend for session ${command.sessionId} cannot take an approval response`);
1662
+ return 'ignored';
1663
+ }
1664
+ this.logger.log(`[SessionRunner] answering approval on session ${command.sessionId}: approved=${decision.approved} optionId=${decision.optionId ?? '(none)'}`);
1665
+ try {
1666
+ /*
1667
+ * THE RETURN VALUE IS LOAD-BEARING. An adapter reports a failed delivery
1668
+ * by RESOLVING FALSE as well as by throwing — OpenCode's HTTP relay does
1669
+ * exactly that on a non-OK status. Ignoring it meant a failed delivery was
1670
+ * recorded as 'delivered': the card closed, the transcript said
1671
+ * "Approved", and the backend had never heard the answer.
1672
+ */
1673
+ const delivered = await capable.respondToApproval(context.backendSessionId, decision.approved, decision.optionId);
1674
+ if (delivered === false) {
1675
+ this.logger.error(`[SessionRunner] backend reported it did not accept the approval for session ${command.sessionId}`);
1676
+ return 'failed';
1677
+ }
1678
+ }
1679
+ catch (error) {
1680
+ /*
1681
+ * 'failed', NOT 'ignored'. THIS RETURN IS THE WHOLE POINT OF THE TYPE.
1682
+ *
1683
+ * It used to return `false`, the same value a stale or duplicate answer
1684
+ * returns — so the harness could not tell a lost approval from a no-op,
1685
+ * and its recovery path (forget the commandId, tell the user) was
1686
+ * UNREACHABLE CODE. The comment there described a failure mode the code
1687
+ * could not actually handle.
1688
+ *
1689
+ * The approval is deliberately left OPEN: the backend never heard the
1690
+ * answer, so the prompt is genuinely still outstanding.
1691
+ */
1692
+ this.logger.error(`[SessionRunner] failed to deliver approval for session ${command.sessionId}: ${error instanceof Error ? error.message : String(error)}`);
1693
+ return 'failed';
1694
+ }
1695
+ /*
1696
+ * THE ANSWER GOES INTO THE TRANSCRIPT, AS A USER PART.
1697
+ *
1698
+ * Two reasons, and the second is not cosmetic.
1699
+ *
1700
+ * 1. The exit test asks for "the request, the answer and the result". This
1701
+ * is the answer.
1702
+ * 2. `selectOpenQuestion` in the UI decides a question is still open by
1703
+ * looking for ANY user part after it (projection.ts:605-608). Without a
1704
+ * user part here the card would stay open and interactive for ever, and
1705
+ * the composer would keep posting answers instead of messages — so the
1706
+ * user could never send another message in that session again.
1707
+ */
1708
+ this.record(context.sessionId, [
1709
+ {
1710
+ id: `answer-${open.toolCallId}`,
1711
+ role: 'user',
1712
+ partType: 'text',
1713
+ content: decision.transcriptText
1714
+ }
1715
+ ]);
1716
+ await this.flush(context.sessionId, this.pending.splice(0), undefined, false);
1717
+ this.closeApproval(context.sessionId);
1718
+ return 'delivered';
1719
+ }
1720
+ /* ---------------------------------------------------------------- */
1721
+ /* Store, then send */
1722
+ /* ---------------------------------------------------------------- */
1723
+ pending = [];
1724
+ /**
1725
+ * Everything this turn projected, in order.
1726
+ *
1727
+ * Reset at the start of every `run`. Used only to derive the assistant half
1728
+ * of the model-facing log; the transcript itself is already stored by then.
1729
+ */
1730
+ turnParts = [];
1731
+ /**
1732
+ * STORE FIRST, SEND SECOND, and never the other way round.
1733
+ *
1734
+ * The local store is what lets a harness survive its own crash; if a part
1735
+ * were sent before it was stored, a crash between the two would leave the
1736
+ * control plane holding a part the runner does not know it produced, and a
1737
+ * resumed turn would produce it again with a NEW id — a visible duplicate in
1738
+ * the user's transcript. Storing first makes the send idempotent, because the
1739
+ * upsert is keyed on (sessionId, partId).
1740
+ */
1741
+ record(sessionId, raw) {
1742
+ for (const part of raw) {
1743
+ const partType = part?.partType ?? part?.type;
1744
+ if (part?.id &&
1745
+ partType === 'question' &&
1746
+ part.id !== this.openApproval?.partId &&
1747
+ this.openQuestionPartId !== part.id) {
1748
+ this.openQuestionPartId = part.id;
1749
+ this.deps.onApprovalStateChange?.(sessionId, true);
1750
+ }
1751
+ }
1752
+ const projected = (0, projection_1.projectParts)(raw);
1753
+ if (projected.length === 0) {
1754
+ return 0;
1755
+ }
1756
+ const { maxRev } = this.deps.store.upsertParts(sessionId, projected);
1757
+ this.deps.events.transcriptChanged(sessionId, maxRev);
1758
+ // Kept so the turn can build its MODEL-facing log from what was actually
1759
+ // produced. `this.turnParts` is reset per turn by `run`.
1760
+ for (const part of projected) {
1761
+ this.turnParts.push({
1762
+ partId: part.partId,
1763
+ role: part.role,
1764
+ partType: part.partType,
1765
+ content: part.content
1766
+ });
1767
+ }
1768
+ for (const part of projected) {
1769
+ this.pending.push((0, projection_1.toIngestPart)(part));
1770
+ }
1771
+ return projected.length;
1772
+ }
1773
+ /**
1774
+ * Push a batch to the control plane, chunked to the contract's ceiling.
1775
+ *
1776
+ * The ceiling is enforced HERE rather than discovered as an opaque rejection
1777
+ * mid-turn. `MAX_INGEST_PARTS` is shared with the control plane precisely so
1778
+ * both sides agree on it; a limit only one side knows is not a limit.
1779
+ */
1780
+ async flush(sessionId, parts, turn, final, modelMessages) {
1781
+ if (parts.length === 0 && !final && turn === undefined) {
1782
+ return;
1783
+ }
1784
+ // Oversized parts are dropped with a note rather than sent to be refused.
1785
+ // A single enormous tool result must not take the rest of the batch down.
1786
+ const sendable = parts.filter((part) => {
1787
+ const size = (0, contract_1.ingestPartByteLength)(part);
1788
+ if (size <= 1_000_000) {
1789
+ return true;
1790
+ }
1791
+ this.logger.warn(`[SessionRunner] dropping oversized part ${part.partId} (${size} bytes)`);
1792
+ return false;
1793
+ });
1794
+ const chunks = [];
1795
+ for (let i = 0; i < sendable.length; i += contract_1.MAX_INGEST_PARTS) {
1796
+ chunks.push(sendable.slice(i, i + contract_1.MAX_INGEST_PARTS));
1797
+ }
1798
+ if (chunks.length === 0) {
1799
+ chunks.push([]);
1800
+ }
1801
+ for (let i = 0; i < chunks.length; i += 1) {
1802
+ const isLast = i === chunks.length - 1;
1803
+ await this.deps.ingest(sessionId, chunks[i], isLast ? turn : undefined, isLast ? final : false, isLast ? modelMessages : undefined);
1804
+ }
1805
+ }
1806
+ /* ---------------------------------------------------------------- */
1807
+ /* Wiring */
1808
+ /* ---------------------------------------------------------------- */
1809
+ /**
1810
+ * WHERE `resumeFromRev` FINALLY DOES SOMETHING.
1811
+ *
1812
+ * The field was specified in the frozen contract, declared in `types.ts`,
1813
+ * documented in the README and named in three test files. The control plane
1814
+ * computed it and put it on every lease. Nothing in the runtime had ever read
1815
+ * it. This is the read.
1816
+ *
1817
+ * ITS MEANING, taken from the contract's own comment: "the harness already
1818
+ * holds transcript up to this rev and need not replay it. Null means start
1819
+ * cold." So it is a statement about what the SERVER has, which the runner
1820
+ * compares against what IT has:
1821
+ *
1822
+ * null -> a brand-new conversation. Nothing to load.
1823
+ * local maxRev >= it -> this runner ran the earlier turns and its
1824
+ * cache is current. No network call.
1825
+ * local maxRev < it -> this runner is behind, or has never seen this
1826
+ * session at all. The control plane is the only
1827
+ * place the conversation still exists; fetch it.
1828
+ *
1829
+ * That third case is the one that matters, and it is not an edge case: it is
1830
+ * every cold sandbox, every redeployed runner, and every turn that happens to
1831
+ * be leased to a different machine than the one before it.
1832
+ *
1833
+ * NEVER THROWS. A conversation that cannot be loaded is a turn that starts
1834
+ * cold — degraded, and worth a warning — whereas a throw here would refuse a
1835
+ * turn the user is waiting on. The warning is loud because a fleet silently
1836
+ * forgetting everything is precisely the failure this whole change exists to
1837
+ * end.
1838
+ */
1839
+ priorMessageCount = 0;
1840
+ async loadMemory(session, ownBackendInstanceId) {
1841
+ this.priorMessageCount = 0;
1842
+ const store = this.deps.conversation;
1843
+ const local = store?.getModelMessages(session.sessionId) ?? [];
1844
+ const localCheckpoint = store?.getCheckpoint(session.sessionId) ?? null;
1845
+ /*
1846
+ * Only meaningful when the checkpoint we are about to RESUME is the local
1847
+ * one. If the lease hands us a different backend session, the local record
1848
+ * describes a session we are not reusing, and comparing against it would
1849
+ * rebuild for no reason.
1850
+ */
1851
+ const permissionDecision = localCheckpoint?.backendSessionId &&
1852
+ (session.backendSessionId ?? localCheckpoint.backendSessionId) ===
1853
+ localCheckpoint.backendSessionId
1854
+ ? localCheckpoint.permissionDecision
1855
+ : undefined;
1856
+ /*
1857
+ * A CHECKPOINT MINTED BY A DIFFERENT BACKEND *INSTANCE* IS NOT A CHECKPOINT
1858
+ * EITHER — and this, not the backend-kind check below, is what had never
1859
+ * once worked.
1860
+ *
1861
+ * A Pi session file is a key into ONE Pi agent directory. The control plane
1862
+ * stores whatever the last runner reported and hands it to whoever takes
1863
+ * the next turn, so the moment a conversation is leased to a SECOND runner
1864
+ * the handle stops meaning anything: the second runner's Pi store has never
1865
+ * heard of it, answers "Session not found", and the whole history is
1866
+ * replayed and re-billed. Measured on the owner's machine, one conversation
1867
+ * bounced between a laptop runner and a cloud-sandbox runner reached over
1868
+ * ngrok and did exactly this on every turn after the second runner joined.
1869
+ *
1870
+ * Turns 2 to 7 of that same conversation DID resume — across three
1871
+ * successive runner restarts — because all three processes used one durable
1872
+ * Pi agent directory. That is the whole rule, and
1873
+ * it is why the identity is the state directory rather than the process.
1874
+ *
1875
+ * FAIL-SAFE DIRECTION. Unknown ownership is read as "not mine": replaying
1876
+ * once is recoverable, resuming somebody else's handle is not. A backend
1877
+ * that cannot report an instance id keeps the previous behaviour rather
1878
+ * than being refused outright.
1879
+ */
1880
+ const mine = (checkpoint) => {
1881
+ if (!checkpoint)
1882
+ return false;
1883
+ if (!ownBackendInstanceId)
1884
+ return true; // backend cannot say; unchanged.
1885
+ return checkpoint.backendOwnerId === ownBackendInstanceId;
1886
+ };
1887
+ const leaseCheckpoint = session.backendSessionId
1888
+ ? {
1889
+ backendSessionId: session.backendSessionId,
1890
+ backend: session.backendName ?? null,
1891
+ backendOwnerId: session.backendOwnerId ?? null
1892
+ }
1893
+ : null;
1894
+ /*
1895
+ * The lease is still PREFERRED — the control plane saw the last turn
1896
+ * wherever it ran, and this runner may only have seen an older one — but
1897
+ * only among checkpoints this runner can actually honour.
1898
+ */
1899
+ const usable = [leaseCheckpoint, localCheckpoint].find((candidate) => mine(candidate ?? null));
1900
+ if (!usable && (leaseCheckpoint || localCheckpoint)) {
1901
+ /*
1902
+ * LOG, NOT WARN, AND NOT "could not resume".
1903
+ *
1904
+ * Nothing failed. The conversation is simply being continued somewhere it
1905
+ * has never run, which is an ordinary consequence of a session that can
1906
+ * be leased to any runner. Logging it as a failure is what made a
1907
+ * fallback that ran on every single turn look like a rare edge case.
1908
+ */
1909
+ this.logger.log(`[SessionRunner] session ${session.sessionId} has a checkpoint from ` +
1910
+ `another backend instance (${leaseCheckpoint?.backendOwnerId ??
1911
+ localCheckpoint?.backendOwnerId ??
1912
+ 'unknown'}); this runner is ${ownBackendInstanceId ?? 'unidentified'}. ` +
1913
+ 'Replaying history once into a session of its own.');
1914
+ }
1915
+ let backendSessionId = usable?.backendSessionId ?? null;
1916
+ /*
1917
+ * A CHECKPOINT MINTED BY A DIFFERENT BACKEND IS NOT A CHECKPOINT.
1918
+ *
1919
+ * An OpenCode session id handed to Claude Code is not a session, it is a
1920
+ * string; resuming it fails confusingly, while ignoring it replays cleanly.
1921
+ * This arises for an ordinary reason — an administrator changes the agent's
1922
+ * backend part-way through a conversation.
1923
+ *
1924
+ * The lease's `backendName` is consulted FIRST and the local checkpoint
1925
+ * second. An earlier version checked only the local record, which meant the
1926
+ * guard could not fire on a cold runner — precisely the runner most likely
1927
+ * to be handed a checkpoint it cannot use.
1928
+ */
1929
+ const checkpointBackend = usable?.backend ?? null;
1930
+ if (backendSessionId &&
1931
+ checkpointBackend &&
1932
+ checkpointBackend !== session.agent.backend) {
1933
+ this.logger.warn(`[SessionRunner] session ${session.sessionId} carries a checkpoint from ` +
1934
+ `backend "${checkpointBackend}" but this turn runs on ` +
1935
+ `"${session.agent.backend}". Replaying history into a fresh session.`);
1936
+ backendSessionId = null;
1937
+ }
1938
+ const serverRev = session.resumeFromRev;
1939
+ if (serverRev === null || serverRev === undefined) {
1940
+ // A brand-new conversation. `local` is normally empty here; if it is not,
1941
+ // it is stale state for a reused session id and must not be replayed.
1942
+ this.priorMessageCount = 0;
1943
+ return { backendSessionId, messages: [], permissionDecision };
1944
+ }
1945
+ const localRev = this.deps.store.getMaxRev(session.sessionId);
1946
+ if (local.length > 0 && localRev >= serverRev) {
1947
+ this.priorMessageCount = local.length;
1948
+ return { backendSessionId, messages: local, permissionDecision };
1949
+ }
1950
+ if (!this.deps.fetchHistory) {
1951
+ this.logger.warn(`[SessionRunner] session ${session.sessionId} has server history ` +
1952
+ `(resumeFromRev=${serverRev}) that this runner does not hold, and no ` +
1953
+ 'history read path is wired. The turn will start without it.');
1954
+ this.priorMessageCount = local.length;
1955
+ return { backendSessionId, messages: local, permissionDecision };
1956
+ }
1957
+ try {
1958
+ const remote = await this.deps.fetchHistory(session.sessionId);
1959
+ // Cache it, so a second turn on this runner costs no network call.
1960
+ try {
1961
+ store?.replaceModelMessages(session.sessionId, remote.messages);
1962
+ }
1963
+ catch {
1964
+ /* a cache that will not write is a slow turn, not a failed one */
1965
+ }
1966
+ this.priorMessageCount = remote.messages.length;
1967
+ /*
1968
+ * THE SAME OWNERSHIP TEST, ON THE PATH THAT NEEDED IT MOST.
1969
+ *
1970
+ * `remote` carries the checkpoint from the SAME control-plane row the
1971
+ * guard above already refused, so taking it here would quietly undo that
1972
+ * refusal — and it would do so on a COLD runner, which the comment above
1973
+ * calls "precisely the runner most likely to be handed a checkpoint it
1974
+ * cannot use". That is not an edge case: an empty local store plus a
1975
+ * server-side history is the definition of a session that has just moved
1976
+ * between machines, which is the whole defect this change exists to fix.
1977
+ *
1978
+ * `mine()` rather than a second inline comparison, so the two paths
1979
+ * cannot drift.
1980
+ */
1981
+ const remoteCheckpoint = remote.backendSessionId
1982
+ ? {
1983
+ backendSessionId: remote.backendSessionId,
1984
+ backendOwnerId: remote.backendOwnerId ?? null
1985
+ }
1986
+ : null;
1987
+ if (remoteCheckpoint && !mine(remoteCheckpoint)) {
1988
+ this.logger.log(`[SessionRunner] session ${session.sessionId}: the history read ` +
1989
+ `carries a checkpoint from another backend instance ` +
1990
+ `(${remoteCheckpoint.backendOwnerId ?? 'unknown'}); this runner is ` +
1991
+ `${ownBackendInstanceId ?? 'unidentified'}. Replaying instead.`);
1992
+ }
1993
+ /*
1994
+ * MERGE NOTE (#1591 × #1602). Both changes rewrote this return.
1995
+ *
1996
+ * #1591 gates the remote handle behind `mine()` — a checkpoint minted by
1997
+ * another backend instance is not a checkpoint here, and this cold path
1998
+ * is exactly where that happens.
1999
+ *
2000
+ * #1602 reports the permission decision, which the control plane does not
2001
+ * carry, so it is only known when the session we end up on is the one we
2002
+ * already hold locally.
2003
+ *
2004
+ * The two compose once the gate runs FIRST: resolve the id, then ask
2005
+ * whether that resolved id is the local one. Computing the decision from
2006
+ * `remote.backendSessionId` — as #1602 did — would answer for a handle the
2007
+ * gate has just refused.
2008
+ */
2009
+ const resolvedBackendSessionId = (remoteCheckpoint && mine(remoteCheckpoint)
2010
+ ? remoteCheckpoint.backendSessionId
2011
+ : null) ?? backendSessionId;
2012
+ return {
2013
+ backendSessionId: resolvedBackendSessionId,
2014
+ messages: remote.messages,
2015
+ permissionDecision: resolvedBackendSessionId === backendSessionId
2016
+ ? permissionDecision
2017
+ : undefined
2018
+ };
2019
+ }
2020
+ catch (caught) {
2021
+ this.logger.warn(`[SessionRunner] could not load history for ${session.sessionId}: ` +
2022
+ `${caught instanceof Error ? caught.message : String(caught)}. ` +
2023
+ 'The turn will start with whatever this runner holds locally.');
2024
+ this.priorMessageCount = local.length;
2025
+ return { backendSessionId, messages: local, permissionDecision };
2026
+ }
2027
+ }
2028
+ /**
2029
+ * Authorise this session for exactly the secrets it was granted.
2030
+ *
2031
+ * Returns null when the host has no secret broker, which is the ordinary case
2032
+ * on a runner — see `NoSecretProvider`, where holding no vendor credential is
2033
+ * a security position rather than a gap.
2034
+ */
2035
+ setUpSecrets(session) {
2036
+ if (this.deps.secrets.getBrokerPort() === null) {
2037
+ return null;
2038
+ }
2039
+ const token = (0, node_crypto_1.randomUUID)();
2040
+ this.deps.secrets.registerSession(token, session.agent.agentId, []);
2041
+ return token;
2042
+ }
2043
+ /**
2044
+ * Build the backend's session config.
2045
+ *
2046
+ * EVERY MCP SERVER GOES THROUGH THE LOOPBACK PROXY. The real gateway URL and
2047
+ * the session token never reach the backend, never land in an MCP config file
2048
+ * on disk, and never appear in the backend's own session state — so a leaked
2049
+ * workspace does not leak the tenant's tool access. This is also what makes
2050
+ * the token refreshable mid-session, which is the defect the proxy was
2051
+ * originally written for.
2052
+ *
2053
+ * `allowedTools` from the lease is NOT enforced here, and that is deliberate.
2054
+ * It is advisory — for generating AGENTS.md and for what the UI shows. THE
2055
+ * GATEWAY IS AUTHORITATIVE, because a harness on a customer VM is outside our
2056
+ * security perimeter and trusting it to filter its own tools is exactly
2057
+ * defect G2.
2058
+ */
2059
+ buildSessionConfig(session, workspaceDir, secretToken, signal) {
2060
+ const mcpServers = {};
2061
+ for (const server of session.agent.mcpServers) {
2062
+ const proxied = this.deps.proxy.registerTarget(server.url, server.name);
2063
+ mcpServers[server.name] = {
2064
+ name: server.name,
2065
+ type: 'http',
2066
+ // A null proxy URL means the proxy is not running. Falling back to the
2067
+ // direct URL would put the session token in the backend's config, so
2068
+ // the server is simply omitted instead — fewer tools is recoverable,
2069
+ // a leaked token is not.
2070
+ url: proxied
2071
+ };
2072
+ if (!proxied) {
2073
+ delete mcpServers[server.name];
2074
+ this.logger.warn(`[SessionRunner] MCP server "${server.name}" omitted: proxy not running`);
2075
+ }
2076
+ }
2077
+ const abort = new AbortController();
2078
+ signal.addEventListener('abort', () => abort.abort(), { once: true });
2079
+ const brokerPort = this.deps.secrets.getBrokerPort();
2080
+ return {
2081
+ agentId: session.agent.agentId,
2082
+ // The adapters were written against 20x's task model and call this
2083
+ // `taskId`. It is the SESSION id here. Renaming the field in the adapters
2084
+ // would have made them no longer "extracted unchanged", which is worth
2085
+ // more than the tidier name.
2086
+ taskId: session.sessionId,
2087
+ workspaceDir,
2088
+ model: qualifiedModelId(session.agent.model),
2089
+ systemPrompt: session.agent.systemPrompt ?? undefined,
2090
+ skills: session.agent.skills.map((skill) => ({
2091
+ name: skill.name,
2092
+ content: skill.content
2093
+ })),
2094
+ promptAbort: abort,
2095
+ mcpServers: mcpServers,
2096
+ secretBrokerPort: brokerPort ?? undefined,
2097
+ secretSessionToken: secretToken ?? undefined,
2098
+ secretShellPath: this.deps.secrets.writeShellWrapper() ?? undefined,
2099
+ environment: this.deps.workspace.getEnvironment?.(session.sessionId, [
2100
+ session.agent.model.gatewayUrl ?? '',
2101
+ ...session.agent.mcpServers.map((server) => server.url)
2102
+ ]),
2103
+ /*
2104
+ * DEFECT G1, THE OTHER HALF.
2105
+ *
2106
+ * `session.agent.permissionMode` has been on the lease since the contract
2107
+ * was frozen, and until now it DIED HERE: this object was built without
2108
+ * the field, so every session fell to the adapter default no matter what
2109
+ * the user had chosen. The picker in the agent editor was decorative, and
2110
+ * the UI said so in a caveat.
2111
+ *
2112
+ * The lease's vocabulary is four-valued and the adapter's is two-valued,
2113
+ * so the translation is deliberate rather than incidental — the whole
2114
+ * table, and what it costs on each backend, is written down in
2115
+ * ./permission-mode.ts. `sandboxMode` is set from the same call because
2116
+ * it is what carries the part of the intent 'ask' | 'allow' cannot.
2117
+ */
2118
+ ...(0, permission_mode_1.mapPermissionMode)(session.agent.permissionMode)
2119
+ };
2120
+ }
2121
+ }
2122
+ exports.SessionRunner = SessionRunner;