@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,300 @@
1
+ "use strict";
2
+ /**
3
+ * MCP AUTH PROXY — extracted from 20x `src/main/mcp-auth-proxy.ts`.
4
+ *
5
+ * WHY IT EXISTS, unchanged from the original: ACP, OpenCode and Claude Code all
6
+ * bake MCP server headers at SESSION start and none of them refresh those
7
+ * headers mid-session. A one-hour token therefore expires during a long agent
8
+ * run, and every MCP tool call after that point returns 401 with no recovery
9
+ * path — the session is alive, the model keeps trying, and every tool fails
10
+ * until a human notices. This proxy decouples token lifetime from session
11
+ * lifetime by fetching a fresh token PER REQUEST.
12
+ *
13
+ * AND IT MATTERS EVEN MORE ON A RUNNER. The agent is pointed at
14
+ * `127.0.0.1:<port>/<id>`, so the real gateway URL and the real token never
15
+ * appear in an MCP config file, in a backend's session state, or in anything
16
+ * the agent can read. The token is in this process's memory and nowhere else.
17
+ *
18
+ * WHAT CHANGED IN THE EXTRACTION
19
+ * - `enterpriseAuth.getJwt()` became the `TokenInjector` port, so the desktop
20
+ * passes its enterprise JWT and a runner passes the session-scoped MCP token
21
+ * from the lease.
22
+ * - `new Notification(...)` on a token failure became the `Notifier` port, a
23
+ * no-op on a server.
24
+ * - Module-level singletons (`let server`, `let port`, three Maps and a
25
+ * counter) became instance state. The original could only ever have ONE proxy
26
+ * per process, which is correct for a desktop with one signed-in user and
27
+ * wrong for a runner that may hold several sessions with DIFFERENT tokens.
28
+ * Sharing one proxy across sessions there would let session A's agent reach
29
+ * session B's targets by guessing a small integer id.
30
+ * - Ids are random, not `nextId++`. See `registerTarget`.
31
+ *
32
+ * ON "THE HARNESS MUST NEVER OPEN A LISTENING PORT": this binds 127.0.0.1 ONLY,
33
+ * exactly as the health check does. The property that rule protects is that
34
+ * nothing outside the host can initiate a connection to the harness, so a
35
+ * customer VM behind NAT needs no tunnel. A loopback socket is not reachable
36
+ * from the network and does not weaken it. Binding 0.0.0.0 here WOULD, and the
37
+ * test suite asserts the bind address.
38
+ */
39
+ Object.defineProperty(exports, "__esModule", { value: true });
40
+ exports.McpAuthProxy = void 0;
41
+ const node_http_1 = require("node:http");
42
+ const node_https_1 = require("node:https");
43
+ const node_crypto_1 = require("node:crypto");
44
+ class McpAuthProxy {
45
+ options;
46
+ server = null;
47
+ port = null;
48
+ targets = new Map();
49
+ /** Reverse lookup, so re-registering the same URL does not leak ids. */
50
+ idsByUrl = new Map();
51
+ /** One notification per integration per process, not one per failed call. */
52
+ notifiedFailures = new Set();
53
+ logger;
54
+ constructor(options) {
55
+ this.options = options;
56
+ this.logger = options.logger ?? console;
57
+ }
58
+ getPort() {
59
+ return this.port;
60
+ }
61
+ /**
62
+ * Register a real MCP URL and get back a loopback URL that injects a fresh
63
+ * token on every forwarded request. Returns null when the proxy is not running.
64
+ *
65
+ * IDS ARE RANDOM, where the 20x original used `String(nextId++)`. A
66
+ * monotonic counter is fine for a desktop with a single user, but on a runner
67
+ * holding several sessions it means session A can reach session B's targets by
68
+ * requesting `/1`, `/2`, `/3` — and the proxy would dutifully attach B's
69
+ * token. 16 random bytes makes that unguessable. It costs nothing.
70
+ */
71
+ registerTarget(targetUrl, label) {
72
+ if (!this.port) {
73
+ return null;
74
+ }
75
+ const existingId = this.idsByUrl.get(targetUrl);
76
+ if (existingId) {
77
+ if (label) {
78
+ const target = this.targets.get(existingId);
79
+ if (target) {
80
+ target.label = label;
81
+ }
82
+ }
83
+ return `http://127.0.0.1:${this.port}/${existingId}`;
84
+ }
85
+ const id = (0, node_crypto_1.randomBytes)(16).toString('hex');
86
+ this.targets.set(id, { url: targetUrl, label: label || `target ${id}` });
87
+ this.idsByUrl.set(targetUrl, id);
88
+ return `http://127.0.0.1:${this.port}/${id}`;
89
+ }
90
+ unregisterTarget(proxyUrl) {
91
+ try {
92
+ const id = new URL(proxyUrl).pathname.split('/')[1];
93
+ const target = id ? this.targets.get(id) : undefined;
94
+ if (id && target) {
95
+ this.targets.delete(id);
96
+ this.idsByUrl.delete(target.url);
97
+ }
98
+ }
99
+ catch {
100
+ // A malformed URL means there is nothing to unregister.
101
+ }
102
+ }
103
+ start() {
104
+ if (this.server && this.port) {
105
+ return Promise.resolve(this.port);
106
+ }
107
+ return new Promise((resolvePort, reject) => {
108
+ const server = (0, node_http_1.createServer)((req, res) => {
109
+ void this.handleRequest(req, res);
110
+ });
111
+ this.server = server;
112
+ server.once('error', reject);
113
+ // LOOPBACK ONLY. Never 0.0.0.0, never a fixed port.
114
+ server.listen(0, '127.0.0.1', () => {
115
+ const address = server.address();
116
+ if (typeof address === 'object' && address) {
117
+ this.port = address.port;
118
+ resolvePort(address.port);
119
+ }
120
+ else {
121
+ reject(new Error('MCP auth proxy could not determine its address'));
122
+ }
123
+ });
124
+ });
125
+ }
126
+ async stop() {
127
+ const server = this.server;
128
+ this.server = null;
129
+ this.port = null;
130
+ this.targets.clear();
131
+ this.idsByUrl.clear();
132
+ this.notifiedFailures.clear();
133
+ if (!server) {
134
+ return;
135
+ }
136
+ await new Promise((resolveClose) => {
137
+ server.close(() => resolveClose());
138
+ // A live SSE response holds the server open; MCP streams are long-lived,
139
+ // so a shutdown that waited for them would hang for ever.
140
+ server.closeAllConnections?.();
141
+ });
142
+ }
143
+ async handleRequest(req, res) {
144
+ try {
145
+ const reqUrl = new URL(req.url || '/', 'http://localhost');
146
+ const pathParts = reqUrl.pathname.split('/').filter(Boolean);
147
+ const id = pathParts[0];
148
+ const target = id ? this.targets.get(id) : undefined;
149
+ if (!target) {
150
+ /*
151
+ * A TERMINAL ROUTING FAILURE, WORDED SO NOTHING MISREADS IT.
152
+ *
153
+ * This id is not registered with THIS proxy instance, so the MCP server
154
+ * is not routable for this session. It is NOT an auth challenge and NOT
155
+ * an allowlist denial (that is the gateway's `server_not_allowed`, a 403
156
+ * further upstream) — it is a registration/lifetime mismatch, and no
157
+ * amount of retrying resolves an id this proxy never issued.
158
+ *
159
+ * THREE THINGS THIS RESPONSE GETS RIGHT, each a bug it replaces:
160
+ * 1. A JSON body, not the bare text `Unknown proxy target`. OpenCode's
161
+ * MCP client treats a non-2xx during connect as a possible OAuth
162
+ * challenge and `JSON.parse`s the body; a plaintext body threw
163
+ * `SyntaxError: Unexpected identifier "Unknown"`, which is the
164
+ * "Invalid OAuth error response" a human then spent hours chasing.
165
+ * Valid JSON removes the parse crash. (The client STILL should not
166
+ * route a 404 into OAuth at all — that is an upstream defect,
167
+ * filed separately.)
168
+ * 2. `terminal: true` and an explicit "do not retry", because the
169
+ * agent that hit this reasoned it was a transient connectivity
170
+ * problem and burned turns waiting. An unroutable target never
171
+ * becomes routable by waiting.
172
+ * 3. A distinct `reason` so a filtered-out server and an unknown
173
+ * target are not indistinguishable at this layer.
174
+ */
175
+ res.writeHead(404, {
176
+ 'Content-Type': 'application/json',
177
+ 'Cache-Control': 'no-store'
178
+ });
179
+ res.end(JSON.stringify({
180
+ error: 'unknown_proxy_target',
181
+ reason: 'unknown_proxy_target',
182
+ terminal: true,
183
+ retryable: false,
184
+ message: 'MCP loopback proxy: no target is registered for this id, so ' +
185
+ 'this server is not routable for this session. This is a ' +
186
+ 'terminal routing failure (not authentication, not an allowlist ' +
187
+ 'denial) — do not retry.'
188
+ }));
189
+ return;
190
+ }
191
+ const targetUrl = new URL(target.url);
192
+ const subPath = pathParts.slice(1);
193
+ if (subPath.length > 0) {
194
+ targetUrl.pathname =
195
+ targetUrl.pathname.replace(/\/$/, '') + '/' + subPath.join('/');
196
+ }
197
+ targetUrl.search = reqUrl.search;
198
+ let token;
199
+ try {
200
+ token = await this.options.tokens.getToken(target.url);
201
+ }
202
+ catch (error) {
203
+ this.reportTokenFailure(id, target.label, error);
204
+ res.writeHead(502, { 'Content-Type': 'application/json' });
205
+ res.end(JSON.stringify({
206
+ error: `Failed to obtain auth token for "${target.label}"`
207
+ }));
208
+ return;
209
+ }
210
+ const forwardHeaders = {};
211
+ for (const [key, value] of Object.entries(req.headers)) {
212
+ const lower = key.toLowerCase();
213
+ // `host` is rewritten below. `connection` is hop-by-hop and must not be
214
+ // forwarded. `authorization` is DROPPED rather than passed through:
215
+ // whatever the agent put there is not the credential we intend to use,
216
+ // and forwarding it would let a backend's own stale header win.
217
+ if (lower === 'host' ||
218
+ lower === 'connection' ||
219
+ lower === 'authorization') {
220
+ continue;
221
+ }
222
+ forwardHeaders[key] = value;
223
+ }
224
+ // A null token means "this endpoint needs no Authorization header",
225
+ // which is a different thing from a failure and must stay expressible.
226
+ if (token !== null) {
227
+ forwardHeaders['authorization'] = `Bearer ${token}`;
228
+ }
229
+ forwardHeaders['host'] = targetUrl.host;
230
+ const isHttps = targetUrl.protocol === 'https:';
231
+ const send = isHttps ? node_https_1.request : node_http_1.request;
232
+ const proxyReq = send(targetUrl.toString(), { method: req.method, headers: forwardHeaders }, (proxyRes) => {
233
+ const responseHeaders = { ...proxyRes.headers };
234
+ const contentType = String(proxyRes.headers['content-type'] || '');
235
+ const isSSE = contentType.includes('text/event-stream');
236
+ if (!isSSE) {
237
+ // Node re-chunks the proxy→client leg itself. Kept for SSE so
238
+ // events flush immediately instead of being buffered — a buffered
239
+ // MCP stream looks exactly like a hung tool call.
240
+ delete responseHeaders['transfer-encoding'];
241
+ }
242
+ res.writeHead(proxyRes.statusCode || 502, responseHeaders);
243
+ if (isSSE) {
244
+ proxyRes.on('data', (chunk) => {
245
+ res.write(chunk);
246
+ });
247
+ proxyRes.on('end', () => res.end());
248
+ proxyRes.on('error', () => res.end());
249
+ }
250
+ else {
251
+ proxyRes.pipe(res);
252
+ }
253
+ });
254
+ proxyReq.on('error', (error) => {
255
+ this.logger.error(`[McpAuthProxy] Upstream error for "${target.label}": ${error.message}`);
256
+ if (!res.headersSent) {
257
+ res.writeHead(502, { 'Content-Type': 'application/json' });
258
+ res.end(JSON.stringify({ error: `Upstream error: ${error.message}` }));
259
+ }
260
+ else {
261
+ res.end();
262
+ }
263
+ });
264
+ req.pipe(proxyReq);
265
+ }
266
+ catch (error) {
267
+ this.logger.error('[McpAuthProxy] Unexpected error:', error);
268
+ if (!res.headersSent) {
269
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
270
+ res.end('Internal proxy error');
271
+ }
272
+ }
273
+ }
274
+ /**
275
+ * One notification per TARGET per process — keyed on the target id, not on
276
+ * the human-readable label.
277
+ *
278
+ * Two different servers can be registered under the same label (a tenant with
279
+ * two Jira instances, say). Keyed on the label, the second one's auth failure
280
+ * would be silently swallowed for ever because the first had already "used
281
+ * up" the notification. The id is unique by construction.
282
+ */
283
+ reportTokenFailure(id, label, error) {
284
+ this.logger.error(`[McpAuthProxy] Failed to get a token for "${label}":`, error);
285
+ if (this.notifiedFailures.has(id)) {
286
+ return;
287
+ }
288
+ this.notifiedFailures.add(id);
289
+ try {
290
+ this.options.notifier?.notify({
291
+ title: 'MCP Auth Failed',
292
+ body: `Could not authenticate "${label}" — please sign in again.`
293
+ });
294
+ }
295
+ catch {
296
+ // A notifier must never be able to break a request path.
297
+ }
298
+ }
299
+ }
300
+ exports.McpAuthProxy = McpAuthProxy;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * THE PERMISSION-MODE MAPPING — defect G1's other half.
3
+ *
4
+ * THE PROBLEM. Two vocabularies meet here and they are not the same size.
5
+ *
6
+ * The LEASE carries what the user actually chose in the agent editor, which
7
+ * is Claude Code's four-valued vocabulary:
8
+ * 'default' | 'plan' | 'acceptEdits' | 'bypassPermissions'
9
+ *
10
+ * The shared ADAPTER contract (`SessionConfig.permissionMode`) is two-valued:
11
+ * 'ask' | 'allow'
12
+ * because it has to mean the same thing to Pi, Claude Code, Codex and
13
+ * ACP, and only one of those four backends has a native notion of a plan mode.
14
+ *
15
+ * Until this module existed the lease value was simply DROPPED on the runner:
16
+ * `buildSessionConfig` never set `permissionMode`, so every session fell to the
17
+ * adapter default. That is defect G1 — a Permission control that is recorded,
18
+ * served, rendered, and enforced nowhere.
19
+ *
20
+ * THE RULE THE MAPPING OBEYS: **a mode may map to something STRICTER than the
21
+ * user asked for, never to something looser.** Everything that is not an
22
+ * explicit "stop asking me" maps to `'ask'`. `'allow'` is reachable from
23
+ * exactly one input, `bypassPermissions`, and from nothing else — including
24
+ * from an unrecognised value, which fails closed onto `'ask'`.
25
+ *
26
+ * ---------------------------------------------------------------------------
27
+ * THE TABLE, AND WHAT IS AND IS NOT FAITHFUL
28
+ * ---------------------------------------------------------------------------
29
+ *
30
+ * lease mode | permissionMode | sandboxMode | faithful?
31
+ * ---------------------|----------------|--------------------|-----------
32
+ * 'default' | 'ask' | 'workspace-write' | YES
33
+ * 'plan' | 'ask' | 'read-only' | partly
34
+ * 'acceptEdits' | 'ask' | 'workspace-write' | partly
35
+ * 'bypassPermissions' | 'allow' | 'danger-full-access'| YES
36
+ *
37
+ * `sandboxMode` is NOT decoration. It is the existing second field on
38
+ * `SessionConfig` that carries the part of the user's intent `'ask' | 'allow'`
39
+ * cannot hold, and it is what makes the mapping lossless for the backends that
40
+ * read it.
41
+ *
42
+ * PER BACKEND, SAID PLAINLY — this is the "say so explicitly rather than
43
+ * quietly collapsing them" the brief asks for:
44
+ *
45
+ * CLAUDE CODE — THREE OF FOUR. Its SDK natively takes all four values, so
46
+ * `claudeCodePermissionMode()` below reconstructs `default`, `plan` and
47
+ * `bypassPermissions` exactly. A user who picks `plan` gets Claude Code's
48
+ * real plan mode.
49
+ *
50
+ * `acceptEdits` IS THE EXCEPTION, AND IT IS LOST HERE RATHER THAN IN A
51
+ * BACKEND. An earlier version of this comment claimed Claude Code was
52
+ * lossless and "nothing is collapsed". THAT WAS WRONG, and the round-trip
53
+ * test quietly omitted `acceptEdits` rather than failing — which is how a
54
+ * false claim survives review. Corrected after it was caught.
55
+ *
56
+ * The reason is structural, not an oversight: `acceptEdits` and `default`
57
+ * both need `permissionMode: 'ask'` with a writable workspace, and the pair
58
+ * (`'ask' | 'allow'`, three sandbox modes) has no third slot to tell them
59
+ * apart. Distinguishing them would mean WIDENING the shared adapter contract,
60
+ * which 20x also uses — a bigger change than this defect warrants, and one
61
+ * the brief explicitly scoped out.
62
+ *
63
+ * So `acceptEdits` resolves to `default` on EVERY backend: the user is asked
64
+ * about edits they said they would accept. Interrupted more than they asked,
65
+ * never less — the same safe direction as everything else in this table, and
66
+ * the UI says so rather than implying a distinction that does not exist.
67
+ *
68
+ * CODEX — PARTLY. It reads `sandboxMode` directly, so `plan` really is
69
+ * read-only there, and its approval policy is `'never'` only for `'allow'`.
70
+ *
71
+ * PI — COLLAPSED, AND THIS IS THE ONE THAT MATTERS, because Pi is
72
+ * the only backend wired into the cloud runner today. Pi has no plan
73
+ * mode and no accept-edits mode; it has a permission prompt or it has none.
74
+ * So on Pi `plan` and `acceptEdits` are INDISTINGUISHABLE FROM
75
+ * `default`: all three prompt for everything.
76
+ *
77
+ * That collapse is deliberately in the SAFE direction. `acceptEdits` promises
78
+ * "do not ask me about edits", and on Pi we ask anyway — the user is
79
+ * interrupted more than they asked to be, never less. The alternative
80
+ * (mapping `acceptEdits` to `'allow'`) would honour the label by granting
81
+ * silent write access the user never asked for, which is the same class of
82
+ * defect as G1 itself. The agent editor says this out loud rather than
83
+ * letting the picker imply a distinction the runtime does not make.
84
+ */
85
+ /** What the lease carries — the value stored on the agent definition. */
86
+ export type LeasePermissionMode = 'default' | 'plan' | 'acceptEdits' | 'bypassPermissions';
87
+ /** What the shared adapter contract accepts. */
88
+ export type AdapterPermissionMode = 'ask' | 'allow';
89
+ export type AdapterSandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access';
90
+ export interface MappedPermission {
91
+ permissionMode: AdapterPermissionMode;
92
+ sandboxMode: AdapterSandboxMode;
93
+ }
94
+ /**
95
+ * THE ONLY PLACE the four-valued lease mode becomes the two-valued adapter
96
+ * mode. Deliberately total: an unknown value — a newer control plane, a hand-
97
+ * edited row — is not an error, it is `'ask'`, because the failure mode of
98
+ * guessing wrong in the other direction is an unattended agent with no prompt.
99
+ */
100
+ export declare function mapPermissionMode(mode: LeasePermissionMode | string | null | undefined): MappedPermission;
101
+ /**
102
+ * The inverse, for Claude Code only — it is the one backend whose own SDK takes
103
+ * the four-valued vocabulary, so the pair that `mapPermissionMode` produced can
104
+ * be turned back into exactly what the user chose.
105
+ *
106
+ * This is why the mapping is lossless there and lossy on Pi: the loss is
107
+ * in the BACKEND, not in the wire.
108
+ *
109
+ * Absent `permissionMode` means the caller never set one. That is the pre-G1
110
+ * state and it must NOT silently become `bypassPermissions` again, so it maps
111
+ * to `'default'` — Claude Code's own asking mode.
112
+ */
113
+ export declare function claudeCodePermissionMode(config: {
114
+ permissionMode?: AdapterPermissionMode;
115
+ sandboxMode?: AdapterSandboxMode;
116
+ }): LeasePermissionMode;
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+ /**
3
+ * THE PERMISSION-MODE MAPPING — defect G1's other half.
4
+ *
5
+ * THE PROBLEM. Two vocabularies meet here and they are not the same size.
6
+ *
7
+ * The LEASE carries what the user actually chose in the agent editor, which
8
+ * is Claude Code's four-valued vocabulary:
9
+ * 'default' | 'plan' | 'acceptEdits' | 'bypassPermissions'
10
+ *
11
+ * The shared ADAPTER contract (`SessionConfig.permissionMode`) is two-valued:
12
+ * 'ask' | 'allow'
13
+ * because it has to mean the same thing to Pi, Claude Code, Codex and
14
+ * ACP, and only one of those four backends has a native notion of a plan mode.
15
+ *
16
+ * Until this module existed the lease value was simply DROPPED on the runner:
17
+ * `buildSessionConfig` never set `permissionMode`, so every session fell to the
18
+ * adapter default. That is defect G1 — a Permission control that is recorded,
19
+ * served, rendered, and enforced nowhere.
20
+ *
21
+ * THE RULE THE MAPPING OBEYS: **a mode may map to something STRICTER than the
22
+ * user asked for, never to something looser.** Everything that is not an
23
+ * explicit "stop asking me" maps to `'ask'`. `'allow'` is reachable from
24
+ * exactly one input, `bypassPermissions`, and from nothing else — including
25
+ * from an unrecognised value, which fails closed onto `'ask'`.
26
+ *
27
+ * ---------------------------------------------------------------------------
28
+ * THE TABLE, AND WHAT IS AND IS NOT FAITHFUL
29
+ * ---------------------------------------------------------------------------
30
+ *
31
+ * lease mode | permissionMode | sandboxMode | faithful?
32
+ * ---------------------|----------------|--------------------|-----------
33
+ * 'default' | 'ask' | 'workspace-write' | YES
34
+ * 'plan' | 'ask' | 'read-only' | partly
35
+ * 'acceptEdits' | 'ask' | 'workspace-write' | partly
36
+ * 'bypassPermissions' | 'allow' | 'danger-full-access'| YES
37
+ *
38
+ * `sandboxMode` is NOT decoration. It is the existing second field on
39
+ * `SessionConfig` that carries the part of the user's intent `'ask' | 'allow'`
40
+ * cannot hold, and it is what makes the mapping lossless for the backends that
41
+ * read it.
42
+ *
43
+ * PER BACKEND, SAID PLAINLY — this is the "say so explicitly rather than
44
+ * quietly collapsing them" the brief asks for:
45
+ *
46
+ * CLAUDE CODE — THREE OF FOUR. Its SDK natively takes all four values, so
47
+ * `claudeCodePermissionMode()` below reconstructs `default`, `plan` and
48
+ * `bypassPermissions` exactly. A user who picks `plan` gets Claude Code's
49
+ * real plan mode.
50
+ *
51
+ * `acceptEdits` IS THE EXCEPTION, AND IT IS LOST HERE RATHER THAN IN A
52
+ * BACKEND. An earlier version of this comment claimed Claude Code was
53
+ * lossless and "nothing is collapsed". THAT WAS WRONG, and the round-trip
54
+ * test quietly omitted `acceptEdits` rather than failing — which is how a
55
+ * false claim survives review. Corrected after it was caught.
56
+ *
57
+ * The reason is structural, not an oversight: `acceptEdits` and `default`
58
+ * both need `permissionMode: 'ask'` with a writable workspace, and the pair
59
+ * (`'ask' | 'allow'`, three sandbox modes) has no third slot to tell them
60
+ * apart. Distinguishing them would mean WIDENING the shared adapter contract,
61
+ * which 20x also uses — a bigger change than this defect warrants, and one
62
+ * the brief explicitly scoped out.
63
+ *
64
+ * So `acceptEdits` resolves to `default` on EVERY backend: the user is asked
65
+ * about edits they said they would accept. Interrupted more than they asked,
66
+ * never less — the same safe direction as everything else in this table, and
67
+ * the UI says so rather than implying a distinction that does not exist.
68
+ *
69
+ * CODEX — PARTLY. It reads `sandboxMode` directly, so `plan` really is
70
+ * read-only there, and its approval policy is `'never'` only for `'allow'`.
71
+ *
72
+ * PI — COLLAPSED, AND THIS IS THE ONE THAT MATTERS, because Pi is
73
+ * the only backend wired into the cloud runner today. Pi has no plan
74
+ * mode and no accept-edits mode; it has a permission prompt or it has none.
75
+ * So on Pi `plan` and `acceptEdits` are INDISTINGUISHABLE FROM
76
+ * `default`: all three prompt for everything.
77
+ *
78
+ * That collapse is deliberately in the SAFE direction. `acceptEdits` promises
79
+ * "do not ask me about edits", and on Pi we ask anyway — the user is
80
+ * interrupted more than they asked to be, never less. The alternative
81
+ * (mapping `acceptEdits` to `'allow'`) would honour the label by granting
82
+ * silent write access the user never asked for, which is the same class of
83
+ * defect as G1 itself. The agent editor says this out loud rather than
84
+ * letting the picker imply a distinction the runtime does not make.
85
+ */
86
+ Object.defineProperty(exports, "__esModule", { value: true });
87
+ exports.mapPermissionMode = mapPermissionMode;
88
+ exports.claudeCodePermissionMode = claudeCodePermissionMode;
89
+ /**
90
+ * THE ONLY PLACE the four-valued lease mode becomes the two-valued adapter
91
+ * mode. Deliberately total: an unknown value — a newer control plane, a hand-
92
+ * edited row — is not an error, it is `'ask'`, because the failure mode of
93
+ * guessing wrong in the other direction is an unattended agent with no prompt.
94
+ */
95
+ function mapPermissionMode(mode) {
96
+ switch (mode) {
97
+ case 'bypassPermissions':
98
+ return {
99
+ permissionMode: 'allow',
100
+ sandboxMode: 'danger-full-access'
101
+ };
102
+ case 'plan':
103
+ return { permissionMode: 'ask', sandboxMode: 'read-only' };
104
+ case 'acceptEdits':
105
+ return { permissionMode: 'ask', sandboxMode: 'workspace-write' };
106
+ case 'default':
107
+ return { permissionMode: 'ask', sandboxMode: 'workspace-write' };
108
+ default:
109
+ // Unrecognised, absent, null. Ask. See the rule above.
110
+ return { permissionMode: 'ask', sandboxMode: 'workspace-write' };
111
+ }
112
+ }
113
+ /**
114
+ * The inverse, for Claude Code only — it is the one backend whose own SDK takes
115
+ * the four-valued vocabulary, so the pair that `mapPermissionMode` produced can
116
+ * be turned back into exactly what the user chose.
117
+ *
118
+ * This is why the mapping is lossless there and lossy on Pi: the loss is
119
+ * in the BACKEND, not in the wire.
120
+ *
121
+ * Absent `permissionMode` means the caller never set one. That is the pre-G1
122
+ * state and it must NOT silently become `bypassPermissions` again, so it maps
123
+ * to `'default'` — Claude Code's own asking mode.
124
+ */
125
+ function claudeCodePermissionMode(config) {
126
+ if (config.permissionMode === 'allow') {
127
+ return 'bypassPermissions';
128
+ }
129
+ if (config.sandboxMode === 'read-only') {
130
+ return 'plan';
131
+ }
132
+ if (config.sandboxMode === 'workspace-write') {
133
+ /*
134
+ * `acceptEdits` AND `default` SHARE THIS PAIR, so this direction cannot
135
+ * tell them apart — which means `acceptEdits` is UNREACHABLE from here and
136
+ * no backend ever receives it. That is a real, deliberate loss, described
137
+ * in the header and surfaced to the user in the agent editor; it is not a
138
+ * gap someone forgot to close.
139
+ *
140
+ * `default` is the stricter of the two, so it is the right answer when the
141
+ * pair is ambiguous.
142
+ */
143
+ return 'default';
144
+ }
145
+ return 'default';
146
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * THE PROJECTION — adapter message parts in, transcript parts out.
3
+ *
4
+ * Ported from the single chokepoint in 20x `agent-manager.ts`
5
+ * (`persistTranscriptEvent`, :5269) through which every piece of live streaming
6
+ * output passes on its way to the durable log. Keeping it a chokepoint is the
7
+ * whole reason the 20x transcript is reliable, and it is reproduced here for the
8
+ * same reason: one function decides what a transcript part IS, so a new backend
9
+ * cannot invent a second answer.
10
+ *
11
+ * THREE FILTERS, IN ORDER, each of which exists because of a real defect:
12
+ *
13
+ * 1. NO ID, NO PART. The upsert is keyed on (sessionId, partId); a part with no
14
+ * id has no identity, so a second delta for it would insert a duplicate
15
+ * instead of updating the first.
16
+ *
17
+ * 2. THE EPHEMERAL THREE ARE DROPPED. 'step-start', 'step-finish' and
18
+ * 'system-status' are UI-only frame markers. Persisting them would put
19
+ * thousands of meaningless rows in every transcript and stream each one to
20
+ * every reader. Their actual information — duration, token counts — belongs
21
+ * in `TurnMetadata`, which is what closes defect G7.
22
+ *
23
+ * The check goes through the SHARED predicate `isEphemeralPartType`, which
24
+ * trims and lowercases. A backend emitting ' Step-Start ' must not slip past
25
+ * a case-sensitive comparison and corrupt the projection permanently.
26
+ *
27
+ * 3. AN EMPTY PART IS DROPPED. The opening frame of a text part that never
28
+ * received a delta is a stream artefact, and storing it puts a blank bubble
29
+ * in the UI for ever. A part with a payload and no content is KEPT — a tool
30
+ * call with arguments and no textual body is perfectly ordinary.
31
+ */
32
+ import { StoredPartInput } from '../ports';
33
+ import { TranscriptPartInput } from '../types';
34
+ /**
35
+ * The adapter-side part shape.
36
+ *
37
+ * Structurally typed against `MessagePart` from the adapter interface rather
38
+ * than imported from it. The projection must accept a part from ANY backend,
39
+ * including one added later that this package has never seen, so it depends on
40
+ * the fields it reads and on nothing else.
41
+ */
42
+ export interface ProjectableMessagePart {
43
+ id?: string;
44
+ role?: string;
45
+ type?: string;
46
+ text?: string;
47
+ content?: string;
48
+ partType?: string;
49
+ tool?: unknown;
50
+ questions?: unknown;
51
+ todos?: unknown;
52
+ taskProgress?: unknown;
53
+ /** Unix ms when the event was ORIGINALLY received. Preserved through replay. */
54
+ receivedAt?: number;
55
+ }
56
+ /**
57
+ * Project a batch of adapter parts into transcript parts.
58
+ *
59
+ * Returns only the parts worth storing. An empty result is an ordinary outcome
60
+ * — a poll that saw nothing but step markers — and is NOT an error.
61
+ */
62
+ export declare function projectParts(raw: ReadonlyArray<ProjectableMessagePart | null | undefined>): StoredPartInput[];
63
+ /**
64
+ * Convert a stored part into the wire shape for `POST /ingest`.
65
+ *
66
+ * `seq` and `rev` are dropped: they are SERVER-ALLOCATED, and a runner that
67
+ * could send them could fabricate an ordering or replay an old rev over a newer
68
+ * one. The contract's `TranscriptPartInput` has no field for either, so this is
69
+ * enforced by the type rather than by a convention someone must remember.
70
+ */
71
+ export declare function toIngestPart(part: StoredPartInput): TranscriptPartInput;