@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
package/dist/ports.d.ts
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PORTS — the seams the extraction cuts along.
|
|
3
|
+
*
|
|
4
|
+
* The 20x coding-agent runtime reached for seven process-wide singletons and
|
|
5
|
+
* three Electron symbols. None of them exist on a Linux VM, and none of them
|
|
6
|
+
* were ever load-bearing: each is a small, nameable capability that the HOST
|
|
7
|
+
* supplies and the runtime merely uses.
|
|
8
|
+
*
|
|
9
|
+
* Every interface below is therefore written from the RUNTIME's point of view —
|
|
10
|
+
* what it needs — never from the host's. That is what lets 20x pass an
|
|
11
|
+
* Electron-backed implementation, the harness pass a server-backed one, and a
|
|
12
|
+
* test pass nothing at all.
|
|
13
|
+
*
|
|
14
|
+
* TWO RULES THAT KEEP THIS HONEST
|
|
15
|
+
*
|
|
16
|
+
* 1. NO PORT MAY BE REQUIRED. Every one has a working no-op or in-memory
|
|
17
|
+
* default (see ./ports-default). A port that a host must implement before
|
|
18
|
+
* anything runs is not a seam, it is a second constructor — and it would
|
|
19
|
+
* make the package untestable and un-adoptable in one step.
|
|
20
|
+
*
|
|
21
|
+
* 2. A PORT DESCRIBES A CAPABILITY, NOT A HOST. There is no `isDesktop()`,
|
|
22
|
+
* no `isSandbox()` and no runtime discriminator anywhere in this file. The
|
|
23
|
+
* moment a port lets the runtime ask WHO is hosting it, the three hosting
|
|
24
|
+
* modes stop being the same program and the extraction has bought nothing.
|
|
25
|
+
*/
|
|
26
|
+
import type { JsonValue } from './types';
|
|
27
|
+
/**
|
|
28
|
+
* Access to the user's stored secrets, and to the local broker that hands them
|
|
29
|
+
* to an agent process WITHOUT putting them in that process's environment.
|
|
30
|
+
*
|
|
31
|
+
* WHY A BROKER AT ALL, rather than just setting env vars: an agent runs shell
|
|
32
|
+
* commands on the user's behalf, and anything it runs inherits its environment.
|
|
33
|
+
* A secret in `process.env` is therefore readable by every command the model
|
|
34
|
+
* decides to run, and by anything that reads `/proc/<pid>/environ`. The broker
|
|
35
|
+
* keeps the values on the other side of a loopback HTTP call authenticated by a
|
|
36
|
+
* per-session token, so a leaked transcript does not leak the secrets.
|
|
37
|
+
*
|
|
38
|
+
* A host with no secret store returns null from `getBrokerPort()` and the
|
|
39
|
+
* runtime simply runs without secrets — it does not fail.
|
|
40
|
+
*/
|
|
41
|
+
export interface SecretProvider {
|
|
42
|
+
/**
|
|
43
|
+
* The loopback port of the running secret broker, or null when the host has
|
|
44
|
+
* no broker. NEVER bound to anything but 127.0.0.1.
|
|
45
|
+
*/
|
|
46
|
+
getBrokerPort(): number | null;
|
|
47
|
+
/**
|
|
48
|
+
* Authorise one session to fetch exactly `secretIds` and nothing else.
|
|
49
|
+
* The token is generated by the CALLER and is per session, so revoking one
|
|
50
|
+
* session cannot affect another.
|
|
51
|
+
*/
|
|
52
|
+
registerSession(token: string, agentId: string, secretIds: string[]): void;
|
|
53
|
+
/** Revoke a session's access. Called on every session teardown path. */
|
|
54
|
+
unregisterSession(token: string): void;
|
|
55
|
+
/**
|
|
56
|
+
* Absolute path to the shell wrapper that resolves `$SECRET_*` through the
|
|
57
|
+
* broker, or null when the host has none. Handed to the agent as its $SHELL.
|
|
58
|
+
*/
|
|
59
|
+
writeShellWrapper(): string | null;
|
|
60
|
+
/**
|
|
61
|
+
* Decrypted values, for the backends that ignore $SHELL (Claude Code does).
|
|
62
|
+
* Returns {} when the host has no secret store.
|
|
63
|
+
*
|
|
64
|
+
* This is the one method that puts plaintext in the agent's environment, and
|
|
65
|
+
* it exists only because a backend forced it. Prefer the wrapper.
|
|
66
|
+
*/
|
|
67
|
+
getSecretEnv(secretIds: string[]): Record<string, string>;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Where transcript changes and other UI-facing events go.
|
|
71
|
+
*
|
|
72
|
+
* On the desktop this is `mainWindow.webContents.send(...)` — an IPC hop to a
|
|
73
|
+
* renderer in the same machine. On a runner it is `POST /ingest` — an HTTPS hop
|
|
74
|
+
* to the control plane. THE RUNTIME CANNOT TELL THE DIFFERENCE, and that is the
|
|
75
|
+
* entire point: the transcript projection is identical in both, which is why a
|
|
76
|
+
* cloud session can later be mirrored into the desktop's local table with no UI
|
|
77
|
+
* change at all.
|
|
78
|
+
*
|
|
79
|
+
* Delivery is FIRE AND FORGET by design. An event sink that could reject would
|
|
80
|
+
* put transport failure in the middle of the agent loop; durability is the
|
|
81
|
+
* store's job (see TranscriptStore), not the sink's.
|
|
82
|
+
*/
|
|
83
|
+
export interface EventSink {
|
|
84
|
+
/**
|
|
85
|
+
* The transcript for `sessionId` changed. `maxRev` is the client cursor —
|
|
86
|
+
* a reader that already holds this rev has nothing to fetch.
|
|
87
|
+
*/
|
|
88
|
+
transcriptChanged(sessionId: string, maxRev: number): void;
|
|
89
|
+
/** Any other host-specific channel. Unknown channels must be ignored, not throw. */
|
|
90
|
+
emit(channel: string, payload: JsonValue): void;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Telling a HUMAN AT THIS MACHINE something, and keeping the machine awake.
|
|
94
|
+
*
|
|
95
|
+
* Both are no-ops on a server, and deliberately so — a headless runner has no
|
|
96
|
+
* one to notify and never sleeps. Keeping them as a port rather than deleting
|
|
97
|
+
* them is what lets the desktop keep its behaviour unchanged while the same
|
|
98
|
+
* code runs on a VM.
|
|
99
|
+
*/
|
|
100
|
+
export interface Notifier {
|
|
101
|
+
/** Show a desktop notification. `onClick` focuses the host window, if any. */
|
|
102
|
+
notify(input: {
|
|
103
|
+
title: string;
|
|
104
|
+
body: string;
|
|
105
|
+
onClick?: () => void;
|
|
106
|
+
}): void;
|
|
107
|
+
/**
|
|
108
|
+
* Prevent the machine sleeping mid-turn. Returns a release function.
|
|
109
|
+
*
|
|
110
|
+
* A LAPTOP THAT SUSPENDS MID-TURN IS A DEFECT: the agent's child processes
|
|
111
|
+
* are frozen, the model connection times out, and the user returns to a
|
|
112
|
+
* half-written transcript. On a server this is a no-op returning a no-op.
|
|
113
|
+
*/
|
|
114
|
+
keepAwake(): () => void;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The task-management MCP server the agent is given.
|
|
118
|
+
*
|
|
119
|
+
* IN PROCESS ON THE DESKTOP, A REMOTE GATEWAY ON A RUNNER. This is the port
|
|
120
|
+
* whose two implementations differ most, and the difference is entirely inside
|
|
121
|
+
* `getUrl()` — the runtime only ever needs a URL to hand the backend.
|
|
122
|
+
*
|
|
123
|
+
* Returns null when the host offers no task MCP. The runtime must then simply
|
|
124
|
+
* not configure that server, NOT fail: a free-standing session with no human
|
|
125
|
+
* task behind it is explicitly allowed (decision 6).
|
|
126
|
+
*/
|
|
127
|
+
export interface TaskMcpProvider {
|
|
128
|
+
/**
|
|
129
|
+
* The MCP endpoint for this scope, or null when unavailable.
|
|
130
|
+
*
|
|
131
|
+
* `await`ed because the desktop implementation may still be starting its
|
|
132
|
+
* in-process server when the first session begins.
|
|
133
|
+
*/
|
|
134
|
+
getUrl(scope: TaskMcpScope): Promise<string | null>;
|
|
135
|
+
}
|
|
136
|
+
export interface TaskMcpScope {
|
|
137
|
+
taskId?: string | null;
|
|
138
|
+
parentTaskId?: string | null;
|
|
139
|
+
artifactTaskId?: string | null;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Product analytics. A NO-OP ON A SERVER.
|
|
143
|
+
*
|
|
144
|
+
* Every 20x call site was already written as `analytics()?.record(...)`, so the
|
|
145
|
+
* singleton was ALWAYS allowed to be absent. That is why this port is safe to
|
|
146
|
+
* default to a no-op: the runtime has never been able to depend on it.
|
|
147
|
+
*/
|
|
148
|
+
export interface Telemetry {
|
|
149
|
+
record(event: string, properties?: Record<string, unknown>): void;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Supplies the bearer token for an outbound MCP call, FRESHLY, ON EVERY CALL.
|
|
153
|
+
*
|
|
154
|
+
* THIS PORT IS NEEDED IN EVERY MODE, which is why the measurement called it out
|
|
155
|
+
* separately. ACP, OpenCode and Claude Code all bake MCP headers at session
|
|
156
|
+
* start and none of them refresh mid-session, so a one-hour token expires
|
|
157
|
+
* during a long run and every subsequent tool call 401s with no recovery path.
|
|
158
|
+
* The loopback proxy in ./runtime/mcp-auth-proxy decouples token lifetime from
|
|
159
|
+
* session lifetime by asking this port per request.
|
|
160
|
+
*
|
|
161
|
+
* Desktop: the enterprise JWT. Runner: the session-scoped MCP token from the
|
|
162
|
+
* lease — which carries NO tool list, so the gateway's allowlist stays
|
|
163
|
+
* authoritative and revocation is immediate.
|
|
164
|
+
*/
|
|
165
|
+
export interface TokenInjector {
|
|
166
|
+
/**
|
|
167
|
+
* A currently-valid bearer token for `targetUrl`.
|
|
168
|
+
*
|
|
169
|
+
* Throwing is a legitimate answer and means "this integration cannot be
|
|
170
|
+
* authenticated right now"; the proxy answers the agent 502 rather than
|
|
171
|
+
* forwarding an unauthenticated request. Returning null means "send no
|
|
172
|
+
* Authorization header at all", which is different and is how an already
|
|
173
|
+
* public endpoint is expressed.
|
|
174
|
+
*/
|
|
175
|
+
getToken(targetUrl: string): Promise<string | null>;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Turns a path the agent wrote into a described artifact (kind, size, preview).
|
|
179
|
+
*
|
|
180
|
+
* Returns null for anything it cannot or should not describe. A host with no
|
|
181
|
+
* artifact concept returns null always, and the runtime carries on.
|
|
182
|
+
*/
|
|
183
|
+
export interface ArtifactInspector {
|
|
184
|
+
inspect(workspaceDir: string, artifactPath: string): Promise<InspectedArtifact | null>;
|
|
185
|
+
}
|
|
186
|
+
export interface InspectedArtifact {
|
|
187
|
+
path: string;
|
|
188
|
+
name: string;
|
|
189
|
+
mimeType?: string;
|
|
190
|
+
sizeBytes?: number;
|
|
191
|
+
/** Host-defined extras. Kept JSON-shaped so it can cross the wire unchanged. */
|
|
192
|
+
meta?: JsonValue;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The durable transcript projection.
|
|
196
|
+
*
|
|
197
|
+
* A NARROW SLICE OF `DatabaseManager` ON PURPOSE. The measurement recorded
|
|
198
|
+
* DatabaseManager as "already at an interface boundary" because 20x imports it
|
|
199
|
+
* with `import type`, and that is true syntactically — but the SURFACE the
|
|
200
|
+
* runtime actually touches is 20 methods spanning tasks, skills, secrets, MCP
|
|
201
|
+
* servers and settings, i.e. most of the desktop's database. Lifting that whole
|
|
202
|
+
* surface into the package would drag the 20x task model along with it and the
|
|
203
|
+
* "harness" would just be 20x with the window removed.
|
|
204
|
+
*
|
|
205
|
+
* So the port is cut where the DATA is genuinely shared rather than where the
|
|
206
|
+
* import happened to be: the transcript projection, and nothing else. Its
|
|
207
|
+
* invariants (below) are the ones the control plane also implements, so all
|
|
208
|
+
* three writers agree.
|
|
209
|
+
*
|
|
210
|
+
* THE SEVEN INVARIANTS, which an implementation MUST honour:
|
|
211
|
+
* 1. Upsert keyed on (sessionId, partId), in ONE transaction.
|
|
212
|
+
* 2. A NEW part takes MAX(seq)+1. An EXISTING part KEEPS its seq and only
|
|
213
|
+
* replaces content — this is exactly how token streaming works, and
|
|
214
|
+
* re-seq'ing would make a growing part jump to the end on every delta.
|
|
215
|
+
* 3. partType/tool/payload COALESCE against the stored value, so a partial
|
|
216
|
+
* update never erases metadata a fuller earlier update established.
|
|
217
|
+
* 4. createdAt is NEVER overwritten on conflict; the first write wins.
|
|
218
|
+
* 5. rev is monotonic and is bumped on UPDATES as well as inserts.
|
|
219
|
+
* 6. `maxRev` is PER SESSION — it is the client's cursor.
|
|
220
|
+
* 7. Reads are ORDER BY createdAt ASC, seq ASC. Insertion order is not
|
|
221
|
+
* chronological once older history is backfilled.
|
|
222
|
+
*/
|
|
223
|
+
export interface TranscriptStore {
|
|
224
|
+
/**
|
|
225
|
+
* Write a batch. Returns the session's new maxRev and which parts changed.
|
|
226
|
+
*
|
|
227
|
+
* SYNCHRONOUS on purpose. The desktop implementation is better-sqlite3, which
|
|
228
|
+
* is synchronous, and the agent loop calls this on every streaming delta;
|
|
229
|
+
* making it a promise would add a microtask per token for no gain and would
|
|
230
|
+
* open a window in which two batches interleave.
|
|
231
|
+
*/
|
|
232
|
+
upsertParts(sessionId: string, parts: StoredPartInput[]): {
|
|
233
|
+
maxRev: number;
|
|
234
|
+
changedPartIds: string[];
|
|
235
|
+
};
|
|
236
|
+
/** Everything with rev > sinceRev, plus the new cursor. */
|
|
237
|
+
getDelta(sessionId: string, sinceRev: number): {
|
|
238
|
+
parts: StoredPart[];
|
|
239
|
+
maxRev: number;
|
|
240
|
+
};
|
|
241
|
+
/** The whole ordered transcript. */
|
|
242
|
+
getParts(sessionId: string): StoredPart[];
|
|
243
|
+
/** The session's current cursor. 0 when empty. */
|
|
244
|
+
getMaxRev(sessionId: string): number;
|
|
245
|
+
/** Drop a session's transcript. */
|
|
246
|
+
deleteParts(sessionId: string): void;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* What goes IN. `seq` and `rev` are absent by construction, exactly as in the
|
|
250
|
+
* wire contract's `TranscriptPartInput` — a caller must not be able to
|
|
251
|
+
* fabricate an ordering or replay an old rev over a newer one.
|
|
252
|
+
*/
|
|
253
|
+
export interface StoredPartInput {
|
|
254
|
+
partId: string;
|
|
255
|
+
role: 'user' | 'assistant' | 'system';
|
|
256
|
+
content: string;
|
|
257
|
+
partType: string;
|
|
258
|
+
tool?: string | null;
|
|
259
|
+
payload?: JsonValue | null;
|
|
260
|
+
/** Unix ms. The part's ORIGINAL time when known, not the write time. */
|
|
261
|
+
createdAt?: number;
|
|
262
|
+
}
|
|
263
|
+
/** What comes OUT. */
|
|
264
|
+
export interface StoredPart extends StoredPartInput {
|
|
265
|
+
seq: number;
|
|
266
|
+
rev: number;
|
|
267
|
+
createdAt: number;
|
|
268
|
+
updatedAt: number;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Where a session's files live.
|
|
272
|
+
*
|
|
273
|
+
* COLLAPSED FROM THREE MANAGERS TO ONE PORT. The measurement listed
|
|
274
|
+
* WorktreeManager, GitHubManager and GitLabManager as three separate
|
|
275
|
+
* already-injected dependencies, but between them the runtime calls exactly
|
|
276
|
+
* three methods at exactly three call sites. Injecting three whole managers to
|
|
277
|
+
* reach three methods would import the desktop's entire git and forge stack
|
|
278
|
+
* into a package whose default workspace mode is a bare scratch directory.
|
|
279
|
+
*
|
|
280
|
+
* The default implementation makes a plain directory and refuses
|
|
281
|
+
* 'repository' mode, which is correct for a cloud sandbox: repository access is
|
|
282
|
+
* an explicit per-agent opt-in on a runner the customer chose.
|
|
283
|
+
*/
|
|
284
|
+
/**
|
|
285
|
+
* A record in the model-facing conversation log, with its allocated order.
|
|
286
|
+
*
|
|
287
|
+
* `seq` is allocated by whoever stores it and is never sent by a caller — the
|
|
288
|
+
* same rule the transcript contract applies to `seq` and `rev`, and for the
|
|
289
|
+
* same reason: a runner that could choose its own ordering could rewrite
|
|
290
|
+
* history.
|
|
291
|
+
*/
|
|
292
|
+
export interface ModelMessageRecord {
|
|
293
|
+
seq: number;
|
|
294
|
+
role: 'user' | 'assistant';
|
|
295
|
+
content: string;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* WHERE THE HARNESS REMEMBERS A CONVERSATION BETWEEN TURNS.
|
|
299
|
+
*
|
|
300
|
+
* This port is the fix for the defect that mattered most in this project: the
|
|
301
|
+
* runner created and destroyed a backend session on every turn and sent only
|
|
302
|
+
* the new user message, so the model began every turn with no idea what had
|
|
303
|
+
* just been discussed, while the UI transcript looked perfect.
|
|
304
|
+
*
|
|
305
|
+
* See `store/conversation-store.ts` for why a checkpoint and a message log are
|
|
306
|
+
* both needed, and `runtime/conversation.ts` for why the message log is not the
|
|
307
|
+
* transcript projection.
|
|
308
|
+
*/
|
|
309
|
+
export interface SessionCheckpoint {
|
|
310
|
+
/** The backend's own handle for the conversation. Opaque here. */
|
|
311
|
+
backendSessionId: string;
|
|
312
|
+
/** Which KIND of backend minted it — 'opencode', 'claude-code', ... */
|
|
313
|
+
backend: string;
|
|
314
|
+
/**
|
|
315
|
+
* Which INSTANCE of that backend minted it.
|
|
316
|
+
*
|
|
317
|
+
* Null on a checkpoint written before ownership was recorded, which is read
|
|
318
|
+
* as "unknown, therefore not mine" — replay once rather than resume a handle
|
|
319
|
+
* that may belong to another machine.
|
|
320
|
+
*/
|
|
321
|
+
backendOwnerId: string | null;
|
|
322
|
+
/**
|
|
323
|
+
* The permission decision ('ask' | 'allow') the backend session was CREATED
|
|
324
|
+
* under, when it is known.
|
|
325
|
+
*
|
|
326
|
+
* NOT DECORATION. OpenCode binds a session's permission policy at creation
|
|
327
|
+
* and nothing reaches it afterwards — not the workspace config file, not a
|
|
328
|
+
* directory-scoped `config.update`; both were tried and MEASURED to do
|
|
329
|
+
* nothing. So a session created under `allow` keeps running unprompted
|
|
330
|
+
* however the agent is later edited, which is defect G1 reopening on
|
|
331
|
+
* message two, in the direction that hurts.
|
|
332
|
+
*
|
|
333
|
+
* This is the only durable record of that. The workspace is deleted and
|
|
334
|
+
* rebuilt every turn, and the adapter is constructed fresh per turn, so
|
|
335
|
+
* neither can carry it.
|
|
336
|
+
*/
|
|
337
|
+
permissionDecision?: string;
|
|
338
|
+
}
|
|
339
|
+
export interface ConversationStore {
|
|
340
|
+
/**
|
|
341
|
+
* The backend's own handle for this conversation, when one is still held.
|
|
342
|
+
*
|
|
343
|
+
* MERGE NOTE (#1591 × #1602): both changes added a field to this checkpoint —
|
|
344
|
+
* `backendOwnerId`, so a handle is only resumed on the instance that minted
|
|
345
|
+
* it, and `permissionDecision`, so a session's creation-time permission
|
|
346
|
+
* policy survives the turn. They are independent and both are kept. The
|
|
347
|
+
* inline shape #1602 introduced is folded back into the named
|
|
348
|
+
* `SessionCheckpoint` so there is ONE declaration to add the next field to.
|
|
349
|
+
*/
|
|
350
|
+
getCheckpoint(sessionId: string): SessionCheckpoint | null;
|
|
351
|
+
/**
|
|
352
|
+
* Record it, or clear it by passing null when the backend has lost it.
|
|
353
|
+
*
|
|
354
|
+
* THE LAST ARGUMENT IS A NAMED OBJECT, NOT TWO POSITIONAL STRINGS, AND THAT
|
|
355
|
+
* IS DELIBERATE. #1591 and #1602 each added an optional string here, in
|
|
356
|
+
* parallel. Merging them produced `setCheckpoint(id, ses, 'opencode',
|
|
357
|
+
* 'allow')` writing "allow" into the OWNER slot — silently, because both are
|
|
358
|
+
* `string | undefined` and the compiler cannot tell them apart. Only a test
|
|
359
|
+
* caught it. Two adjacent optional strings of the same type is a
|
|
360
|
+
* transposition waiting to happen; naming them removes the class.
|
|
361
|
+
*/
|
|
362
|
+
setCheckpoint(sessionId: string, backendSessionId: string | null, backend: string, extra?: {
|
|
363
|
+
backendOwnerId?: string | null;
|
|
364
|
+
permissionDecision?: string;
|
|
365
|
+
}): void;
|
|
366
|
+
/** The whole log, in order. */
|
|
367
|
+
getModelMessages(sessionId: string): ModelMessageRecord[];
|
|
368
|
+
/** Append this turn's messages, allocating `seq` at the store. */
|
|
369
|
+
appendModelMessages(sessionId: string, messages: {
|
|
370
|
+
role: 'user' | 'assistant';
|
|
371
|
+
content: string;
|
|
372
|
+
}[]): void;
|
|
373
|
+
/** Replace the local cache with the control plane's copy. */
|
|
374
|
+
replaceModelMessages(sessionId: string, messages: ModelMessageRecord[]): void;
|
|
375
|
+
/** Drop everything for a session. */
|
|
376
|
+
forget(sessionId: string): void;
|
|
377
|
+
}
|
|
378
|
+
export interface WorkspaceProvider {
|
|
379
|
+
/**
|
|
380
|
+
* Prepare and return the absolute working directory for a session.
|
|
381
|
+
*
|
|
382
|
+
* MUST be unique per session. Decision 2 dropped the unix-user-per-session
|
|
383
|
+
* design, so inside a tenant this directory IS the isolation boundary as far
|
|
384
|
+
* as the filesystem is concerned — the rest is enforced by the harness and
|
|
385
|
+
* the MCP gateway rather than by the kernel. Create it 0700.
|
|
386
|
+
*/
|
|
387
|
+
prepare(input: WorkspaceRequest): Promise<string>;
|
|
388
|
+
/** Environment values that describe the workspace prepared for this turn. */
|
|
389
|
+
getEnvironment?(sessionId: string, directUrls?: string[]): Record<string, string>;
|
|
390
|
+
/** One-use host note to prepend to the next model turn, when present. */
|
|
391
|
+
takeTurnNote?(sessionId: string): string | null;
|
|
392
|
+
/**
|
|
393
|
+
* Save dirty repository work before a turn gives up its credential.
|
|
394
|
+
* A false result means a remote backup failed and cleanup must keep the tree.
|
|
395
|
+
*/
|
|
396
|
+
preserveAtTurnEnd?(sessionId: string): Promise<boolean>;
|
|
397
|
+
/**
|
|
398
|
+
* Release a session workspace. A non-forced release must keep a dirty Git
|
|
399
|
+
* worktree. Force is reserved for an explicit session-end or wipe command.
|
|
400
|
+
*/
|
|
401
|
+
release(sessionId: string, options?: {
|
|
402
|
+
force?: boolean;
|
|
403
|
+
}): Promise<boolean | void>;
|
|
404
|
+
}
|
|
405
|
+
export interface WorkspaceRequest {
|
|
406
|
+
sessionId: string;
|
|
407
|
+
mode: 'scratch' | 'repository';
|
|
408
|
+
repositoryUrl?: string;
|
|
409
|
+
ref?: string;
|
|
410
|
+
repositories?: import('./types').LeasedGitHubRepository[];
|
|
411
|
+
requestedRepositories?: string[];
|
|
412
|
+
onRepositoryStatus?: (status: {
|
|
413
|
+
fullName: string;
|
|
414
|
+
status: 'checkedOut';
|
|
415
|
+
}) => void;
|
|
416
|
+
refusalReason?: string;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Every port, all optional.
|
|
420
|
+
*
|
|
421
|
+
* One options object rather than positional constructor arguments, because the
|
|
422
|
+
* 20x `AgentManager` is constructed in 20-odd tests as `new AgentManager(db)`
|
|
423
|
+
* and a widened arity would break every one of them. An options bag with
|
|
424
|
+
* defaults is additive: existing callers keep working untouched.
|
|
425
|
+
*/
|
|
426
|
+
export interface HarnessPorts {
|
|
427
|
+
secrets?: SecretProvider;
|
|
428
|
+
events?: EventSink;
|
|
429
|
+
notifier?: Notifier;
|
|
430
|
+
taskMcp?: TaskMcpProvider;
|
|
431
|
+
telemetry?: Telemetry;
|
|
432
|
+
tokens?: TokenInjector;
|
|
433
|
+
artifacts?: ArtifactInspector;
|
|
434
|
+
store?: TranscriptStore;
|
|
435
|
+
workspace?: WorkspaceProvider;
|
|
436
|
+
}
|
package/dist/ports.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* THE PORTS — the seams the extraction cuts along.
|
|
4
|
+
*
|
|
5
|
+
* The 20x coding-agent runtime reached for seven process-wide singletons and
|
|
6
|
+
* three Electron symbols. None of them exist on a Linux VM, and none of them
|
|
7
|
+
* were ever load-bearing: each is a small, nameable capability that the HOST
|
|
8
|
+
* supplies and the runtime merely uses.
|
|
9
|
+
*
|
|
10
|
+
* Every interface below is therefore written from the RUNTIME's point of view —
|
|
11
|
+
* what it needs — never from the host's. That is what lets 20x pass an
|
|
12
|
+
* Electron-backed implementation, the harness pass a server-backed one, and a
|
|
13
|
+
* test pass nothing at all.
|
|
14
|
+
*
|
|
15
|
+
* TWO RULES THAT KEEP THIS HONEST
|
|
16
|
+
*
|
|
17
|
+
* 1. NO PORT MAY BE REQUIRED. Every one has a working no-op or in-memory
|
|
18
|
+
* default (see ./ports-default). A port that a host must implement before
|
|
19
|
+
* anything runs is not a seam, it is a second constructor — and it would
|
|
20
|
+
* make the package untestable and un-adoptable in one step.
|
|
21
|
+
*
|
|
22
|
+
* 2. A PORT DESCRIBES A CAPABILITY, NOT A HOST. There is no `isDesktop()`,
|
|
23
|
+
* no `isSandbox()` and no runtime discriminator anywhere in this file. The
|
|
24
|
+
* moment a port lets the runtime ask WHO is hosting it, the three hosting
|
|
25
|
+
* modes stop being the same program and the extraction has bought nothing.
|
|
26
|
+
*/
|
|
27
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { LeasedAttachment } from '../types';
|
|
2
|
+
/** The directory, relative to the workspace, that attachments land in. */
|
|
3
|
+
export declare const ATTACHMENTS_DIR = "attachments";
|
|
4
|
+
/**
|
|
5
|
+
* Per-file ceiling. MIRRORS `CHAT_ATTACHMENT_MAX_BYTES` in
|
|
6
|
+
* `packages/core/src/services/coding-agents/chat-attachments.ts` — this
|
|
7
|
+
* package does not depend on core, so the number is repeated, not imported.
|
|
8
|
+
*/
|
|
9
|
+
export declare const MAX_ATTACHMENT_BYTES: number;
|
|
10
|
+
/** Per-download wall clock. */
|
|
11
|
+
export declare const ATTACHMENT_DOWNLOAD_TIMEOUT_MS = 120000;
|
|
12
|
+
export interface SyncedAttachment {
|
|
13
|
+
/** The file's name inside `attachments/`. May differ from the lease's. */
|
|
14
|
+
name: string;
|
|
15
|
+
/** `attachments/<name>` — relative to the workspace, as the agent sees it. */
|
|
16
|
+
relativePath: string;
|
|
17
|
+
mimeType: string;
|
|
18
|
+
sizeBytes: number;
|
|
19
|
+
}
|
|
20
|
+
export interface FailedAttachment {
|
|
21
|
+
name: string;
|
|
22
|
+
reason: string;
|
|
23
|
+
}
|
|
24
|
+
export interface AttachmentSyncResult {
|
|
25
|
+
synced: SyncedAttachment[];
|
|
26
|
+
failed: FailedAttachment[];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* One safe path segment: the basename, without control characters, reserved
|
|
30
|
+
* characters or leading dots. `..`, `a/b`, `C:\x\y.pdf` and `` all come out as
|
|
31
|
+
* something that cannot leave the directory.
|
|
32
|
+
*/
|
|
33
|
+
export declare function sanitizeAttachmentName(raw: unknown): string;
|
|
34
|
+
/**
|
|
35
|
+
* A MIME type safe to interpolate into the model's prompt.
|
|
36
|
+
*
|
|
37
|
+
* `attachmentPromptNote` puts every synced file's type straight into text the
|
|
38
|
+
* model reads. Like the name (`sanitizeAttachmentName` above), the lease's
|
|
39
|
+
* `mimeType` is UNTRUSTED as far as this package is concerned — it may also
|
|
40
|
+
* simply be whatever a browser's `File.type` reported for the original
|
|
41
|
+
* upload, carried through unchanged. Anything that is not a plain
|
|
42
|
+
* `type/subtype` token — in particular anything containing whitespace,
|
|
43
|
+
* a newline, or a control character — becomes `application/octet-stream`
|
|
44
|
+
* rather than reaching the prompt.
|
|
45
|
+
*
|
|
46
|
+
* A NARROWER PURPOSE than core's `sanitizeAttachmentMimeType`: that function
|
|
47
|
+
* additionally allow-lists WHICH types are safe to serve as a browser
|
|
48
|
+
* `Content-Type` (closing a stored-XSS path). This one only needs to keep the
|
|
49
|
+
* PROMPT safe, so a shape check is enough — a real, unusual-but-legitimate
|
|
50
|
+
* type like `application/vnd.ms-excel` must still reach the model.
|
|
51
|
+
*/
|
|
52
|
+
export declare function sanitizeAttachmentMimeType(raw: unknown): string;
|
|
53
|
+
/**
|
|
54
|
+
* A short, filesystem-safe, deterministic token for a turn id — used to
|
|
55
|
+
* namespace one turn's files from another's (see `turnId` below). Hashed
|
|
56
|
+
* rather than sliced from the id verbatim: turn ids are not guaranteed to be
|
|
57
|
+
* short, URL-safe or free of characters `sanitizeAttachmentName` would
|
|
58
|
+
* mangle, and a re-lease of the SAME turn must produce the SAME token for
|
|
59
|
+
* this function to stay idempotent, which a hash guarantees regardless of
|
|
60
|
+
* the id's own shape.
|
|
61
|
+
*/
|
|
62
|
+
export declare function turnToken(turnId: string): string;
|
|
63
|
+
/**
|
|
64
|
+
* Download every attachment into `<workspaceDir>/attachments/`.
|
|
65
|
+
*
|
|
66
|
+
* Returns what landed and what did not; never throws for a single file.
|
|
67
|
+
*/
|
|
68
|
+
export declare function syncAttachmentsToWorkspace(attachments: LeasedAttachment[] | undefined, workspaceDir: string, options?: {
|
|
69
|
+
fetchImpl?: typeof fetch;
|
|
70
|
+
logger?: Pick<Console, 'warn'>;
|
|
71
|
+
maxBytes?: number;
|
|
72
|
+
timeoutMs?: number;
|
|
73
|
+
/**
|
|
74
|
+
* A stable identifier for the turn these attachments belong to (the
|
|
75
|
+
* lease's `turnId`). When given, every synced filename is prefixed with
|
|
76
|
+
* a short token derived from it — see `turnToken` — so a LATER turn that
|
|
77
|
+
* happens to attach a same-named file never silently overwrites an
|
|
78
|
+
* EARLIER turn's file still referenced from an earlier point in the
|
|
79
|
+
* conversation log. A RE-LEASE of the SAME turn reuses the same token
|
|
80
|
+
* (same `turnId` in, same token out), which is what keeps this function
|
|
81
|
+
* idempotent under a retried lease. Omitted by callers — mostly tests —
|
|
82
|
+
* that don't need cross-turn isolation.
|
|
83
|
+
*/
|
|
84
|
+
turnId?: string;
|
|
85
|
+
}): Promise<AttachmentSyncResult>;
|
|
86
|
+
/**
|
|
87
|
+
* The note appended to the user's message. Empty when nothing was attached.
|
|
88
|
+
*
|
|
89
|
+
* Paths are given relative to the working directory (`./attachments/...`),
|
|
90
|
+
* which is what every backend's file tools resolve against.
|
|
91
|
+
*/
|
|
92
|
+
export declare function attachmentPromptNote(result: AttachmentSyncResult): string;
|