@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,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;