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