@volter/supercode-teams 0.2.20

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 (79) hide show
  1. package/README.md +119 -0
  2. package/attention.mjs +191 -0
  3. package/bin/fleet.mjs +151 -0
  4. package/bin/teams.mjs +291 -0
  5. package/bin/workspace/args.mjs +66 -0
  6. package/bin/workspace/files.mjs +110 -0
  7. package/bin/workspace/lifecycle.mjs +113 -0
  8. package/bin/workspace/volter-device.mjs +42 -0
  9. package/bin/workspace/volter-login.mjs +185 -0
  10. package/bin/workspace.mjs +726 -0
  11. package/browser-attach.mjs +90 -0
  12. package/client.mjs +212 -0
  13. package/connector/effects.mjs +95 -0
  14. package/connector/index.d.ts +69 -0
  15. package/connector/index.mjs +174 -0
  16. package/connector/native-continuation.d.ts +8 -0
  17. package/connector/native-continuation.mjs +50 -0
  18. package/connector/native-effects.mjs +164 -0
  19. package/context.mjs +34 -0
  20. package/contracts/adapter-map.json +16 -0
  21. package/contracts/index.d.ts +299 -0
  22. package/contracts/index.mjs +37 -0
  23. package/execution.mjs +206 -0
  24. package/fleet.mjs +78 -0
  25. package/index.mjs +12 -0
  26. package/machine-door.mjs +36 -0
  27. package/machine.mjs +1398 -0
  28. package/package.json +80 -0
  29. package/porthole-hook.cjs +137 -0
  30. package/remote/job-exec.py +146 -0
  31. package/remote/wire.py +23 -0
  32. package/server/arc2-transactions.mjs +125 -0
  33. package/server/attachment-relay.mjs +149 -0
  34. package/server/catalog.mjs +7 -0
  35. package/server/continuations.mjs +56 -0
  36. package/server/correlation.d.ts +4 -0
  37. package/server/correlation.mjs +17 -0
  38. package/server/effects-transactions.mjs +119 -0
  39. package/server/effects.mjs +97 -0
  40. package/server/event-routes.mjs +208 -0
  41. package/server/exports.mjs +46 -0
  42. package/server/harness-routes.mjs +248 -0
  43. package/server/index.d.ts +31 -0
  44. package/server/index.mjs +52 -0
  45. package/server/ingest.mjs +24 -0
  46. package/server/node-upgrade.mjs +39 -0
  47. package/server/otlp/collector-config.d.ts +2 -0
  48. package/server/otlp/collector-config.mjs +26 -0
  49. package/server/otlp/decoders/index.d.ts +63 -0
  50. package/server/otlp/decoders/index.mjs +311 -0
  51. package/server/otlp/index.d.ts +4 -0
  52. package/server/otlp/index.mjs +4 -0
  53. package/server/otlp/projection.d.ts +5 -0
  54. package/server/otlp/projection.mjs +17 -0
  55. package/server/otlp/receiver.d.ts +3 -0
  56. package/server/otlp/receiver.mjs +36 -0
  57. package/server/otlp/schema/google/protobuf/any.proto +162 -0
  58. package/server/otlp/schema/google/rpc/status.proto +48 -0
  59. package/server/otlp/schema/manifest.json +22 -0
  60. package/server/otlp/schema/opentelemetry/proto/collector/logs/v1/logs_service.proto +77 -0
  61. package/server/otlp/schema/opentelemetry/proto/collector/metrics/v1/metrics_service.proto +77 -0
  62. package/server/otlp/schema/opentelemetry/proto/collector/trace/v1/trace_service.proto +77 -0
  63. package/server/otlp/schema/opentelemetry/proto/common/v1/common.proto +154 -0
  64. package/server/otlp/schema/opentelemetry/proto/logs/v1/logs.proto +226 -0
  65. package/server/otlp/schema/opentelemetry/proto/metrics/v1/metrics.proto +735 -0
  66. package/server/otlp/schema/opentelemetry/proto/resource/v1/resource.proto +45 -0
  67. package/server/otlp/schema/opentelemetry/proto/trace/v1/trace.proto +359 -0
  68. package/server/otlp/transport.d.ts +10 -0
  69. package/server/otlp/transport.mjs +424 -0
  70. package/server/otlp/wire-codec.mjs +153 -0
  71. package/server/store-core.mjs +139 -0
  72. package/server/store-worker.mjs +23 -0
  73. package/worker/fetch-adapter.mjs +49 -0
  74. package/worker/index.mjs +127 -0
  75. package/worker/websocket-duplex.mjs +29 -0
  76. package/worker/wrangler.toml +38 -0
  77. package/workspace-client.d.ts +75 -0
  78. package/workspace-client.mjs +171 -0
  79. package/workspace-move.mjs +38 -0
package/machine.mjs ADDED
@@ -0,0 +1,1398 @@
1
+ // The machine daemon: one per OS user and home. It is the neutral core
2
+ // (machine-core.mjs: identity, log, the local door, the server link, admission and
3
+ // product doors) with the harness's own doors registered on it. It owns one
4
+ // `supercode harness serve` and the terminal host, and serves these streams:
5
+ //
6
+ // stream "harness" operators only: harness.v1 lines to the one serve process,
7
+ // request ids rewritten per connection, notifications routed
8
+ // by the subscription/connection they name (never fanned out)
9
+ // stream "terminal" a pane attachment under observe|control
10
+ // stream "node" describe, launch, launches.get|list, panes.*, files.*,
11
+ // ports.*, attention.*, title.*
12
+ // stream "frontend" frontend.v2 to a hosted runtime, one client id per caller
13
+ // stream "port" one TCP connection to an announced port
14
+ // stream "watch" panes and ports as a snapshot then deltas
15
+ // stream "product" a target another product serves here (products.mjs), admitted first
16
+ // stream "exec" | "sftp" | "exec-server" operators: a pilot's tool process or file view
17
+ // stream "workspace" operators: a project leaving or arriving
18
+ //
19
+ // Two doors reach it. The LOCAL door is an owner-only Unix socket in the home:
20
+ // whoever can open it is this OS user, an operator. The REMOTE door is the
21
+ // Teams server: with `link`, the daemon holds one outbound WebSocket to the
22
+ // server, which forwards a caller's streams stamped with the machine grants it
23
+ // checked; the daemon re-derives that admission from its own synced copy of the
24
+ // grants before acting. There is no inbound port and no address book.
25
+ //
26
+ // It decides nothing on anyone's behalf. Every act is logged.
27
+ import { spawn, spawnSync } from 'node:child_process';
28
+ import { EventEmitter } from 'node:events';
29
+ import { readFileSync, writeFileSync, writeSync, statSync, readdirSync, mkdirSync, renameSync, rmSync, existsSync, openSync, readSync, closeSync } from 'node:fs';
30
+ import { join, resolve, dirname } from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
32
+ import { hostname } from 'node:os';
33
+ import { randomBytes, createHash } from 'node:crypto';
34
+ import { WebSocket } from 'ws';
35
+ import { TmuxTerminalHost, classifyAttentionSignal, composerHoldsDraft, composerReadyForInput } from '@volter/supercode-terminal';
36
+ import { findLiveReceipt, mintFrontendCredential } from '@volter/supercode-harness-sdk/live-runtime';
37
+ import { NODE_PROTOCOL } from '@volter/teams/channel';
38
+ import { holds, observesPanes } from '@volter/teams/admission';
39
+ import { MachineCore, MACHINE_VERSION } from '@volter/teams/machine-core';
40
+ import { realpathSync } from 'node:fs';
41
+ import { fingerprint, signBytes, canonical } from '@volter/teams/identity';
42
+ import { teamsHome, paths, readJson, writeJson, Launches } from '@volter/teams/home';
43
+ import { request as httpRequest } from 'node:http';
44
+ import { DEFAULT_RANK, normalizeRank, judge } from './attention.mjs';
45
+ import { JobOwner, executionCapabilities, streamProgram, serveWorkspace } from './execution.mjs';
46
+
47
+ /** The preload every launched pane carries: it announces the ports a pane's node processes listen on. */
48
+ const PORTHOLE_HOOK = fileURLToPath(new URL('./porthole-hook.cjs', import.meta.url));
49
+ /** How long a launch's `initialInput` waits for its pane to become ready for
50
+ * input before it is delivered anyway. Long enough for the slowest cold
51
+ * start anyone has reported (a first-run agent TUI fetching its config over a
52
+ * slow link); past it the input goes in regardless and the launch record says
53
+ * so, because silently dropping a caller's first message is worse than an
54
+ * imperfectly-timed one. */
55
+ const INITIAL_INPUT_READY_DEADLINE_MS = 90_000;
56
+ /** How often the readiness evidence is re-read while waiting. Same cadence the
57
+ * terminal controller polls its own composer at: fast enough that a ready pane
58
+ * is not left idling, cheap enough to run for the whole deadline. */
59
+ const INITIAL_INPUT_POLL_MS = 250;
60
+ /** The pause between the two composer readings a user's turn waits on. */
61
+ const COMPOSER_RECHECK_MS = 400;
62
+
63
+ /** The settle floor for a pane whose kind has no composer pattern: the screen
64
+ * must have stopped changing for this long before the pane counts as ready. */
65
+ const INITIAL_INPUT_SETTLE_MS = 1_000;
66
+
67
+ export class TeamsMachine extends MachineCore {
68
+ /**
69
+ * `link`: { server, teamId, machineId, credential, incarnationId, configuration } to hold the
70
+ * server link; `configuration()` resolves the machine's current configuration from the server
71
+ * (custodian and machine grants). Without it the daemon serves its local door only.
72
+ */
73
+ constructor({ home = teamsHome(), supercode = 'supercode', tmuxSocket, floor = null, limits = null, env = process.env, link = null } = {}) {
74
+ super({ home, floor, limits, env, link });
75
+ this.supercodeBin = supercode;
76
+ this.tmuxSocket = tmuxSocket ?? null;
77
+ this.launches = new Launches(this.p.launches);
78
+ this.host = new TmuxTerminalHost({
79
+ // Stable owner id so panes this daemon launched stay "owned" across restarts.
80
+ ownerId: `supercode-teams-${fingerprint(this.identity.publicKeyPem)}`,
81
+ ...(tmuxSocket ? { socket: tmuxSocket } : {}),
82
+ });
83
+ // How many pane attachments a principal may hold (limits.json); every refusal is logged.
84
+ this.attachmentsByPrincipal = new Map(); // principal → live pane attachments
85
+ this.serve = null;
86
+ this.serveBuffer = '';
87
+ this.routes = new Map(); // routing key (subscription/connection id) → connection index
88
+ this.portsDir = join(home, 'ports');
89
+ // Panes whose REMOTE input is refused. It survives a daemon restart:
90
+ // a hand-back that outlives this process is still a hand-back.
91
+ this.suspended = new Set(Object.keys(readJson(this.p.suspended, { v: 1, panes: {} }).panes));
92
+ // Idle evidence for attention: the last capture we saw of each pane and
93
+ // when it last differed. In memory and nowhere else — there is no timer and
94
+ // no watcher; every `panes.list` / `attention.*` call is the sample. A pane
95
+ // first seen this call is idle 0, which is the truth about what we know, and
96
+ // a daemon restart honestly resets the clock rather than inventing history.
97
+ this.paneScreens = new Map(); // terminal session id → { hash, changedAt }
98
+ // Launches whose `initialInput` is still waiting for its pane to be ready
99
+ //. The launch reply does not wait for delivery — a cold TUI can take
100
+ // longer than the caller's own RPC timeout — so the wait lives here, where
101
+ // `stop()` can abandon it rather than leave a timer running past the daemon.
102
+ this.pendingInitialInputs = new Map(); // launch key → { abort, task }
103
+ this.registerDoor('harness', (entry, stream, accept, refuse, index) => this.#serveHarness(index, entry, stream, accept, refuse));
104
+ this.registerDoor('terminal', (entry, stream, accept, refuse) => this.#attachTerminal(entry, stream, accept, refuse));
105
+ this.registerDoor('node', (entry, stream, accept) => { accept(); return this.#serveNodeRpc(entry, stream); });
106
+ this.registerDoor('frontend', (entry, stream, accept, refuse) => this.#serveFrontendProxy(entry, stream, accept, refuse));
107
+ this.registerDoor('port', (entry, stream, accept, refuse) => this.#servePortForward(entry, stream, accept, refuse));
108
+ this.registerDoor('watch', (entry, stream, accept, refuse) => this.#serveWatch(entry, stream, accept, refuse));
109
+ for (const name of ['exec', 'sftp', 'exec-server']) this.registerDoor(name, (entry, stream, accept, refuse) => this.#serveExecution(entry, stream, accept, refuse));
110
+ this.registerDoor('workspace', (entry, stream, accept, refuse) => this.#serveWorkspace(entry, stream, accept, refuse));
111
+ this.registerDoor('mail', (entry, stream, accept, refuse) => this.#serveMail(entry, stream, accept, refuse));
112
+ }
113
+
114
+ // ---- lifecycle ------------------------------------------------------------
115
+
116
+ async beforeListen() {
117
+ mkdirSync(this.portsDir, { recursive: true }); // the port table's announce directory exists before any pane launches
118
+ this.#startServe();
119
+ this.#startMailWatch();
120
+ this.jobs = new JobOwner({ home: this.home, env: this.env, log: (row) => this.log.append({ principal: null, cap: 'exec', target: `job:${row.job}`, outcome: row.outcome, detail: row.reason }) });
121
+ this.jobs.reapOrphans();
122
+ }
123
+
124
+ async beforeClose() {
125
+ // A launch still waiting for its pane to be ready is abandoned, not hurried:
126
+ // this daemon is going away, and shoving a message at a half-booted TUI on the
127
+ // way out is the very failure the wait exists to prevent. The launch record
128
+ // already says the input was never delivered.
129
+ for (const { abort } of this.pendingInitialInputs.values()) abort.abort();
130
+ await Promise.allSettled([...this.pendingInitialInputs.values()].map(({ task }) => task));
131
+ this.pendingInitialInputs.clear();
132
+ }
133
+
134
+ async afterStop() {
135
+ this.jobs?.stopAll();
136
+ this.stoppingMailWatch = true;
137
+ this.mailWatch?.kill('SIGTERM');
138
+ this.serve?.kill('SIGTERM');
139
+ this.host.dispose();
140
+ }
141
+
142
+ connectionClosed(index) {
143
+ for (const [key, owners] of this.routes) { owners.delete(index); if (owners.size === 0) this.routes.delete(key); }
144
+ }
145
+
146
+ #startServe() {
147
+ const child = spawn(this.supercodeBin, ['harness', 'serve'], { stdio: ['pipe', 'pipe', 'pipe'], env: this.env });
148
+ child.stdout.setEncoding('utf8');
149
+ child.stdout.on('data', (chunk) => {
150
+ this.serveBuffer += chunk;
151
+ let index;
152
+ while ((index = this.serveBuffer.indexOf('\n')) >= 0) {
153
+ const line = this.serveBuffer.slice(0, index);
154
+ this.serveBuffer = this.serveBuffer.slice(index + 1);
155
+ if (line) this.#routeServeLine(line);
156
+ }
157
+ });
158
+ child.stderr.setEncoding('utf8');
159
+ child.stderr.on('data', (text) => this.emit('serve-stderr', text));
160
+ child.on('exit', (code, signal) => {
161
+ this.emit('serve-exit', { code, signal });
162
+ for (const [, entry] of this.connections) entry.harness?.close('harness serve exited');
163
+ });
164
+ this.serve = child;
165
+ }
166
+
167
+ // Idle notices for every session on this machine (`supercode message watch`):
168
+ // it settles `--notify-when-idle` subscriptions from session activity. It
169
+ // lives beside `harness serve` and is restarted if it exits, so a
170
+ // subscription made at any time has a watcher.
171
+ #startMailWatch() {
172
+ if (this.stoppingMailWatch) return;
173
+ const child = spawn(this.supercodeBin, ['message', 'watch'], { stdio: ['ignore', 'ignore', 'pipe'], env: this.env });
174
+ child.stderr.setEncoding('utf8');
175
+ child.stderr.on('data', (text) => this.emit('mail-watch-stderr', text));
176
+ child.on('error', (error) => this.emit('mail-watch-stderr', error.message));
177
+ child.on('exit', () => {
178
+ if (this.mailWatch !== child || this.stoppingMailWatch) return;
179
+ setTimeout(() => this.#startMailWatch(), 5000).unref();
180
+ });
181
+ this.mailWatch = child;
182
+ }
183
+
184
+ /** One `harness.v1` request of the daemon's own, to its `harness serve`: the
185
+ * connection id INTERNAL_CONNECTION marks the answer as ours. */
186
+ #serveRequest(method, params, timeoutMs = 3000) {
187
+ this.internalRequests ??= new Map();
188
+ const id = (this.internalNext = (this.internalNext ?? 0) + 1);
189
+ return new Promise((resolve, reject) => {
190
+ if (!this.serve?.stdin?.writable) return reject(new Error('harness serve is not running'));
191
+ this.internalRequests.set(id, { resolve, reject });
192
+ setTimeout(() => { if (this.internalRequests.delete(id)) reject(new Error(`${method} timed out`)); }, timeoutMs).unref();
193
+ this.serve.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: joinId(INTERNAL_CONNECTION, id), method, params })}\n`);
194
+ });
195
+ }
196
+
197
+ /** Response and notification routing: ids carry the connection index. */
198
+ #routeServeLine(line) {
199
+ let message;
200
+ try { message = JSON.parse(line); } catch { return; }
201
+ if (message.id !== undefined && message.id !== null) {
202
+ const [conn, original] = splitId(message.id);
203
+ const entry = this.connections.get(conn);
204
+ if (conn === INTERNAL_CONNECTION) {
205
+ const pending = this.internalRequests?.get(original);
206
+ if (!pending) return;
207
+ this.internalRequests.delete(original);
208
+ if (message.error) pending.reject(new Error(message.error.message ?? 'harness serve refused'));
209
+ else pending.resolve(message.result);
210
+ return;
211
+ }
212
+ if (!entry?.harness) return;
213
+ // Learn routing keys from results so later notifications find their owner.
214
+ if (message.result && typeof message.result === 'object') {
215
+ for (const key of ['subscription', 'connection', 'runtime_id', 'subscription_id']) {
216
+ const value = message.result[key];
217
+ // Owners are a set: two operators following one thing both keep receiving (routed, never fanned out to non-owners).
218
+ if (typeof value === 'string') { if (!this.routes.has(value)) this.routes.set(value, new Set()); this.routes.get(value).add(conn); }
219
+ }
220
+ }
221
+ writeLine(entry.harness, JSON.stringify({ ...message, id: original }));
222
+ return;
223
+ }
224
+ // Notification: find the connection that owns what it names.
225
+ const params = message.params ?? {};
226
+ for (const key of ['subscription', 'connection', 'runtime_id', 'subscription_id']) {
227
+ const value = params[key];
228
+ if (typeof value === 'string' && this.routes.has(value)) {
229
+ for (const owner of this.routes.get(value)) {
230
+ const harness = this.connections.get(owner)?.harness;
231
+ if (harness) writeLine(harness, line);
232
+ }
233
+ return;
234
+ }
235
+ }
236
+ this.emit('unrouted-notification', message);
237
+ }
238
+
239
+ /**
240
+ * Claim one of a principal's pane attachments (`maxAttachmentsPerPrincipal`
241
+ * in limits.json). Returns the
242
+ * release; throws, refusing the attachment, when the principal is at its bound.
243
+ */
244
+ claimAttachment(principal) {
245
+ const limit = this.limits.maxAttachmentsPerPrincipal;
246
+ const live = this.attachmentsByPrincipal.get(principal) ?? 0;
247
+ if (Number.isFinite(limit) && limit > 0 && live >= limit) {
248
+ this.log.append({ principal, cap: 'attach', target: '*', outcome: 'rate-limited', detail: `over ${limit} attachments` });
249
+ throw Object.assign(new Error(`this Machine allows ${limit} pane attachments per principal`), { code: 429 });
250
+ }
251
+ this.attachmentsByPrincipal.set(principal, live + 1);
252
+ let released = false;
253
+ return () => {
254
+ if (released) return;
255
+ released = true;
256
+ const now = (this.attachmentsByPrincipal.get(principal) ?? 1) - 1;
257
+ if (now <= 0) this.attachmentsByPrincipal.delete(principal); else this.attachmentsByPrincipal.set(principal, now);
258
+ };
259
+ }
260
+
261
+ #serveHarness(index, entry, stream, accept, refuse) {
262
+ const { admitted, principal } = entry;
263
+ if (admitted.class !== 'operator') {
264
+ this.log.append({ principal, cap: 'harness', target: '*', outcome: 'refused' });
265
+ return refuse('harness.v1 is for operators');
266
+ }
267
+ if (entry.harness) return refuse('one harness stream per connection');
268
+ entry.harness = stream;
269
+ accept();
270
+ this.log.append({ principal, cap: 'harness', target: '*', outcome: 'opened' });
271
+ let buffer = '';
272
+ stream.on('data', (chunk) => {
273
+ buffer += chunk;
274
+ let nl;
275
+ while ((nl = buffer.indexOf('\n')) >= 0) {
276
+ const line = buffer.slice(0, nl);
277
+ buffer = buffer.slice(nl + 1);
278
+ if (!line) continue;
279
+ let message;
280
+ try { message = JSON.parse(line); } catch { continue; }
281
+ if (message.id !== undefined && message.id !== null) message.id = joinId(index, message.id);
282
+ this.serve?.stdin.write(JSON.stringify(message) + '\n');
283
+ }
284
+ });
285
+ stream.on('close', () => { entry.harness = null; });
286
+ }
287
+
288
+ #serveWorkspace(entry, stream, accept, refuse) {
289
+ // A project leaving or arriving (execution.mjs serveWorkspace): the machine's files as
290
+ // this user, so operators only, like exec.
291
+ const { admitted, principal } = entry;
292
+ if (admitted.class !== 'operator') {
293
+ this.log.append({ principal, cap: 'workspace', target: '*', outcome: 'refused' });
294
+ return refuse('workspace is for operators');
295
+ }
296
+ accept();
297
+ const target = `root:${stream.params.path}`;
298
+ const onEnd = (detail) => this.log.append({ principal, cap: 'workspace', target, outcome: stream.params.op, detail });
299
+ // A door that fails closes its stream; it never takes the daemon down with it.
300
+ try { return serveWorkspace(stream, stream.params, { onEnd }); } catch (error) { onEnd(`failed: ${error.message}`); stream.close(error.message); return; }
301
+ }
302
+
303
+ // ---- mail ----------------------------------------------------------------
304
+
305
+ #serveMail(entry, stream, accept, refuse) {
306
+ // One message into a session on this machine, delivered by `supercode message
307
+ // remote-deliver` through the same doors a local send uses. The receiver is the
308
+ // target: `mail:*` reaches every session here, `mail:<address>` one of them; a
309
+ // receiver named rather than addressed needs `mail:*`.
310
+ const { admitted, principal } = entry;
311
+ const request = stream.params?.request;
312
+ const to = typeof request?.to === 'string' ? request.to : '*';
313
+ const target = to.startsWith('sc:') ? `mail:${to}` : 'mail:*';
314
+ if (!request || !['send', 'file', 'list'].includes(request.op)) return refuse('mail needs a send, file or list request');
315
+ if (!holds(admitted, 'mail', target)) {
316
+ this.log.append({ principal, cap: 'mail', target, outcome: 'refused' });
317
+ return refuse(`no mail grant for ${target}`);
318
+ }
319
+ accept();
320
+ const child = spawn(this.supercodeBin, ['message', 'remote-deliver'], { stdio: ['pipe', 'pipe', 'pipe'], env: this.env });
321
+ let out = '';
322
+ let err = '';
323
+ child.stdout.on('data', (chunk) => { out += chunk; });
324
+ child.stderr.on('data', (chunk) => { err += chunk; });
325
+ child.on('error', (error) => { err += error.message; });
326
+ child.on('close', (code) => {
327
+ this.log.append({ principal, cap: 'mail', target, outcome: code === 0 ? request.op : 'failed', detail: err.trim().slice(0, 200) || undefined });
328
+ if (stream.closed) return;
329
+ stream.write(out.trim() || JSON.stringify({ outcome: 'failed', detail: err.trim() || `remote-deliver exited ${code}` }));
330
+ stream.close('delivered');
331
+ });
332
+ // Who asked travels with the request, so the receiving session's envelope can say so.
333
+ child.stdin.end(JSON.stringify({ ...request, via_principal: principal ?? null }));
334
+ }
335
+
336
+ // ---- terminal ------------------------------------------------------------
337
+
338
+ async #attachTerminal(entry, stream, accept, refuse) {
339
+ const { admitted, principal } = entry;
340
+ const { pane, mode = 'observe', cols = 120, rows = 40 } = stream.params;
341
+ const cap = mode === 'control' ? 'control' : 'observe';
342
+ if (!holds(admitted, cap, `pane:${pane}`)) {
343
+ this.log.append({ principal, cap, target: `pane:${pane}`, outcome: 'refused' });
344
+ return refuse(`no ${cap} on pane:${pane}`);
345
+ }
346
+ const session = await this.#paneByContext(pane);
347
+ if (!session) return refuse(`no pane with context ${pane}`);
348
+ let grant;
349
+ let release;
350
+ try {
351
+ release = this.claimAttachment(principal);
352
+ grant = this.host.issueAttachment(session.id, { mode });
353
+ } catch (error) {
354
+ release?.();
355
+ this.log.append({ principal, cap, target: `pane:${pane}`, outcome: 'refused', detail: error.message });
356
+ return refuse(error.message);
357
+ }
358
+ accept();
359
+ const socketLike = this.gateInput(new ChannelWebSocket(stream), pane);
360
+ try {
361
+ const attachment = await this.host.attachSocket(grant.id, socketLike, { cols, rows });
362
+ this.log.append({ principal, cap, target: `pane:${pane}`, outcome: 'attached' });
363
+ stream.on('close', () => { release(); attachment.dispose(); });
364
+ } catch (error) {
365
+ release();
366
+ this.log.append({ principal, cap, target: `pane:${pane}`, outcome: 'failed', detail: error.message });
367
+ stream.close(error.message);
368
+ }
369
+ }
370
+
371
+ /**
372
+ * The environment every launched pane carries: the porthole hook preloaded into
373
+ * each node process the pane runs, the announce directory it writes into, and the pane's
374
+ * own contextKey, so an announced port names the pane that owns it.
375
+ */
376
+ #paneEnv(contextKey, requested) {
377
+ const env = { ...(requested ?? {}) };
378
+ const inherited = env.NODE_OPTIONS ?? this.env.NODE_OPTIONS ?? '';
379
+ env.NODE_OPTIONS = `${inherited} --require ${PORTHOLE_HOOK}`.trim();
380
+ env.SUPERCODE_TEAMS_PORTS_DIR = this.portsDir;
381
+ env.SUPERCODE_TEAMS_PANE = contextKey;
382
+ return env;
383
+ }
384
+
385
+ async #paneByContext(contextKey) {
386
+ const sessions = await this.host.listSessions();
387
+ return sessions.find((s) => s.contextKey === contextKey) ?? null;
388
+ }
389
+
390
+ // ---- attention: the evidence, gathered here ---------------------------
391
+
392
+ /** attention.json, with the rank table filled in from the defaults the first
393
+ * time anyone looks. It is written back so the org has a real file to edit —
394
+ * a table that only exists as a default is a table nobody knows they may
395
+ * change. Re-read on every call: a precedence edit lands on the next call
396
+ * with nothing restarted. */
397
+ attentionDoc() {
398
+ const doc = readJson(this.p.attention, { v: 1, requests: {} });
399
+ const before = JSON.stringify(doc.rank ?? null);
400
+ doc.rank = normalizeRank(doc.rank ?? DEFAULT_RANK);
401
+ if (JSON.stringify(doc.rank) !== before) writeJson(this.p.attention, doc);
402
+ return doc;
403
+ }
404
+
405
+ /** Seconds since this pane's screen last CHANGED, by hashing the capture and
406
+ * remembering when the hash last moved. The transcript rung: a foreground
407
+ * command whose screen has plateaued is what separates `stuck` from
408
+ * `working`, and nothing cheaper can tell them apart. */
409
+ #idleSeconds(sessionId, capture, now) {
410
+ return Math.round(this.#screenUnchangedMs(sessionId, capture, now) / 1000);
411
+ }
412
+
413
+ /** The same plateau in milliseconds, which is the unit a launch's readiness
414
+ * wait needs (its settle floor is a second, not a count of them). One clock,
415
+ * two units: `attention` rounds it to seconds, and a pane sampled by either
416
+ * caller feeds the other's reading rather than keeping a private history. */
417
+ #screenUnchangedMs(sessionId, capture, now) {
418
+ const hash = createHash('sha256').update(capture ?? '').digest('hex');
419
+ const seen = this.paneScreens.get(sessionId);
420
+ if (!seen || seen.hash !== hash) {
421
+ this.paneScreens.set(sessionId, { hash, changedAt: now });
422
+ return 0;
423
+ }
424
+ return Math.max(0, now - seen.changedAt);
425
+ }
426
+
427
+ /** The four attention inputs, for every pane this daemon can see, plus any pane
428
+ * with a standing request whose pane is gone (liveness `ended` — a request
429
+ * outranks `ended`, so a declaration raised at the end of a run survives the
430
+ * pane that raised it).
431
+ *
432
+ * Where each input comes from, and nowhere else:
433
+ * liveness for a pane running a harness session (Claude Code, Codex),
434
+ * the harness's own lifecycle through `harness.v1.sessions.
435
+ * activity_under`: working is `running`, idle is `waiting`,
436
+ * needing input is `waiting` with the `asking` signal
437
+ * (source `cooperative`). Otherwise the terminal host's
438
+ * `activeCommand` — tmux's own
439
+ * `pane_current_command`. The pane's shell in the foreground
440
+ * means the shell is at its prompt (`waiting`); any other
441
+ * command means work is running (`running`); an empty or
442
+ * absent value means tmux named nothing, which is `unknown`
443
+ * and never quietly reported as running.
444
+ * signal `classifyAttentionSignal` from the terminal substrate, over
445
+ * this pane's capture. The classifier is NOT re-derived here:
446
+ * one set of patterns, one precision, every surface.
447
+ * idleSeconds the capture-hash plateau above.
448
+ * requested attention.json — what `attention request` recorded, with the
449
+ * urgency and reason the caller gave, carried through intact.
450
+ */
451
+ async attentionEvidence(now = Date.now()) {
452
+ const doc = this.attentionDoc();
453
+ const sessions = await this.host.listSessions();
454
+ const harness = new Map();
455
+ const pids = sessions.map((session) => session.rootPid).filter(Number.isInteger);
456
+ if (pids.length) {
457
+ try {
458
+ const answer = await this.#serveRequest('harness.v1.sessions.activity_under', { pids });
459
+ for (const { pid, activity } of answer?.activities ?? []) harness.set(pid, activity);
460
+ } catch { /* no harness evidence this tick: the process tree still answers */ }
461
+ }
462
+ const live = new Set();
463
+ const rows = sessions.map((session) => {
464
+ if (session.contextKey) live.add(session.contextKey);
465
+ const capture = this.#safeCapture(session.id);
466
+ const requested = session.contextKey ? doc.requests[session.contextKey] ?? null : null;
467
+ const own = harnessLiveness(harness.get(session.rootPid));
468
+ const evidence = {
469
+ liveness: own?.liveness ?? livenessOf(session.activeCommand),
470
+ signal: classifyAttentionSignal(capture) ?? own?.signal ?? null,
471
+ idleSeconds: this.#idleSeconds(session.id, capture, now),
472
+ requested,
473
+ ...(own ? { livenessSource: 'cooperative' } : {}),
474
+ };
475
+ return { session, ...judged(evidence, doc.rank) };
476
+ });
477
+ for (const [pane, request] of Object.entries(doc.requests)) {
478
+ if (live.has(pane)) continue;
479
+ const evidence = { liveness: 'ended', signal: null, idleSeconds: null, requested: request };
480
+ rows.push({ session: { contextKey: pane, label: null, id: null }, ...judged(evidence, doc.rank) });
481
+ }
482
+ return rows;
483
+ }
484
+
485
+ /** One capture, never fatal: a pane that died between the listing and the read
486
+ * is evidence of nothing, not a failed door. */
487
+ #safeCapture(sessionId) {
488
+ if (!sessionId) return '';
489
+ try {
490
+ return this.host.capture(sessionId, { lines: 120 });
491
+ } catch {
492
+ return '';
493
+ }
494
+ }
495
+
496
+ // ---- launch: the first input ------------------------------------------
497
+
498
+ /**
499
+ * Wait for the pane to be ready for input, then deliver the launch's first
500
+ * message and record what happened. Started by `launch` and deliberately NOT
501
+ * awaited by it: the reply says the pane exists, and this says the message
502
+ * went in.
503
+ *
504
+ * Never rejects. The only caller is a fire-and-forget from the request path,
505
+ * where an unhandled rejection would take the daemon down over one pane.
506
+ */
507
+ #deliverInitialInput({ key, contextKey, session, kind, input, act }) {
508
+ const abort = new AbortController();
509
+ const task = (async () => {
510
+ const readyBy = await this.#awaitInputReady(session, contextKey, kind, abort.signal);
511
+ if (readyBy === 'aborted') return;
512
+ if (readyBy === 'pane-gone') {
513
+ this.launches.record({ key, pane: contextKey, kind, status: 'ready', inputDelivered: false, inputReadyBy: readyBy });
514
+ act('launch', `pane:${contextKey}`, 'failed', 'first input undelivered: the pane ended before it was ready');
515
+ return;
516
+ }
517
+ // `single` stays `single`: one paste, one submit. The repeated Enter of
518
+ // `retry` exists for a composer that swallowed the first one, and pressing
519
+ // Enter again at an agent that already took the message sends an empty
520
+ // turn. The fix for a lost submit is waiting until there is something to
521
+ // submit into, which is what happened above.
522
+ try {
523
+ await this.host.sendInput(session.id, submission(input), { submitMode: 'single' });
524
+ } catch (error) {
525
+ this.launches.record({ key, pane: contextKey, kind, status: 'ready', inputDelivered: false, inputReadyBy: readyBy, inputError: error.message });
526
+ act('launch', `pane:${contextKey}`, 'failed', `first input undelivered: ${error.code ?? error.message}`);
527
+ return;
528
+ }
529
+ // `inputDeliveredLate` is the deadline's own confession: the pane never
530
+ // showed us it was ready, and the message went in on a screen nobody
531
+ // vouched for. A launch that reads `ready` without it was given its
532
+ // message by an input box that existed.
533
+ const late = readyBy === 'deadline';
534
+ this.launches.record({ key, pane: contextKey, kind, status: 'ready', inputDelivered: true, inputReadyBy: readyBy, ...(late ? { inputDeliveredLate: true } : {}) });
535
+ act('launch', `pane:${contextKey}`, 'ok', `first input delivered (${readyBy})`);
536
+ })().catch((error) => {
537
+ // Nothing above is expected to throw; if something does, the launch record
538
+ // is where it is answerable, not a crashed daemon.
539
+ this.launches.record({ key, pane: contextKey, kind, status: 'ready', inputDelivered: false, inputError: error?.message ?? String(error) });
540
+ }).finally(() => {
541
+ if (this.pendingInitialInputs.get(key)?.task === task) this.pendingInitialInputs.delete(key);
542
+ });
543
+ this.pendingInitialInputs.set(key, { abort, task });
544
+ }
545
+
546
+ /** The program a pane runs, as the terminal package names kinds: what it was
547
+ * launched as, else what its foreground command is. */
548
+ #paneKind(contextKey, session) {
549
+ const launched = this.launches.all().find((row) => row.pane === contextKey && row.kind)?.kind;
550
+ const named = String(launched ?? '').trim().toLowerCase();
551
+ if (named === 'claude' || named === 'claude-code') return 'claude-code';
552
+ if (named) return named;
553
+ const command = String(session.activeCommand ?? '').toLowerCase().replace(/\.exe$/u, '');
554
+ return command === 'claude' ? 'claude-code' : command;
555
+ }
556
+
557
+ /**
558
+ * Is this pane ready to be given text, and on what evidence? Polls until it
559
+ * is, the pane is gone, the deadline passes or the daemon stops. Returns the
560
+ * rung that decided it:
561
+ *
562
+ * composer the kind's idle composer is on screen — the program has
563
+ * finished booting and is waiting for a message. The patterns
564
+ * are the terminal package's own (`composerReadyForInput`), the
565
+ * same ones its controller waits on, never a second copy here.
566
+ * settled the kind has no composer pattern, so the terminal host's own
567
+ * settle heuristic decides: the screen has not changed for
568
+ * `INITIAL_INPUT_SETTLE_MS` and the program has printed
569
+ * something — or, on a pane with nothing to print, its shell is
570
+ * the foreground command (`awaiting_me`'s evidence: a prompt,
571
+ * turn over).
572
+ * deadline neither, for `INITIAL_INPUT_READY_DEADLINE_MS`. The caller
573
+ * delivers anyway and says so.
574
+ */
575
+ async #awaitInputReady(session, contextKey, kind, signal) {
576
+ const composerKind = typeof kind === 'string' ? kind.trim().toLowerCase() : '';
577
+ const until = Date.now() + INITIAL_INPUT_READY_DEADLINE_MS;
578
+ while (!signal.aborted && Date.now() < until) {
579
+ const capture = this.#safeCapture(session.id);
580
+ const unchangedMs = this.#screenUnchangedMs(session.id, capture, Date.now());
581
+ const blank = capture.trim() === '';
582
+ // A blank screen is the one reading that can mean the pane is GONE rather
583
+ // than merely quiet, so that is when its existence is worth a tmux call —
584
+ // and the same lookup carries the foreground command, which is the only
585
+ // evidence a shell with nothing to print is nonetheless at its prompt.
586
+ const live = blank ? await this.#paneByContext(contextKey) : null;
587
+ if (blank && !live) return 'pane-gone';
588
+ // `null` from the terminal package means it has no pattern for this kind —
589
+ // never "not yet", or an unrecognized kind would wait out the whole
590
+ // deadline on a screen nobody is reading.
591
+ const composer = composerReadyForInput(composerKind, capture);
592
+ if (composer === true) return 'composer';
593
+ if (composer === null && unchangedMs >= INITIAL_INPUT_SETTLE_MS && (!blank || livenessOf(live.activeCommand) === 'waiting')) return 'settled';
594
+ await new Promise((resolve) => setTimeout(resolve, INITIAL_INPUT_POLL_MS));
595
+ }
596
+ if (signal.aborted) return 'aborted';
597
+ return (await this.#paneByContext(contextKey)) ? 'deadline' : 'pane-gone';
598
+ }
599
+
600
+ /**
601
+ * A host-key-signed statement a service records as its attestation input: which
602
+ * key this machine signs with, the run-spec hash and egress mode it was started
603
+ * under, and the server it is linked to.
604
+ */
605
+ describe() {
606
+ const body = {
607
+ v: MACHINE_VERSION,
608
+ id: this.id,
609
+ hostname: hostname(),
610
+ socket: this.socketPath,
611
+ server: this.link?.server ?? null,
612
+ machineId: this.link?.machineId ?? null,
613
+ node: { pid: process.pid, version: MACHINE_VERSION, protocol: NODE_PROTOCOL, floor: this.floor },
614
+ egress: this.env.SUPERCODE_TEAMS_EGRESS ?? 'unrestricted',
615
+ execution: executionCapabilities(this.env),
616
+ runSpec: this.env.SUPERCODE_TEAMS_RUN_SPEC_SHA256 ?? null,
617
+ at: Date.now(),
618
+ };
619
+ return { ...body, signature: signBytes(this.identity.privateKeyPem, Buffer.from(canonical(body))) };
620
+ }
621
+
622
+ listPorts() { return listAnnouncedPorts(this.portsDir); }
623
+
624
+ /**
625
+ * Rename the tmux window behind an owned pane. The terminal host exposes
626
+ * no rename, so this is `tmux` on the host's own socket; automatic-rename is
627
+ * turned off first, or the shell would take the name straight back. Returns
628
+ * the tmux label renamed, or null if the pane is gone or not ours.
629
+ */
630
+ async #renameWindow(contextKey, title) {
631
+ const session = await this.#paneByContext(contextKey);
632
+ if (!session || !session.owned) return null;
633
+ const socketArgs = this.tmuxSocket ? ['-L', this.tmuxSocket] : [];
634
+ const target = `${session.label}:0`;
635
+ const run = (args) => spawnSync('tmux', [...socketArgs, ...args], { timeout: 5_000, encoding: 'utf8' });
636
+ if (title == null) {
637
+ run(['set-window-option', '-t', target, 'automatic-rename', 'on']);
638
+ return session.label;
639
+ }
640
+ run(['set-window-option', '-t', target, 'automatic-rename', 'off']);
641
+ const result = run(['rename-window', '-t', target, String(title)]);
642
+ if (result.status !== 0) throw new Error(`tmux rename-window failed: ${(result.stderr ?? '').trim() || result.error?.message || 'unknown'}`);
643
+ return session.label;
644
+ }
645
+
646
+ /**
647
+ * Wrap a ws-shaped socket so that while its pane is suspended the
648
+ * peer's `input` frames are dropped with one notice back, and everything
649
+ * else — output, resize, hello, exit — passes through untouched.
650
+ */
651
+ gateInput(socketLike, pane) {
652
+ return new SuspendGate(socketLike, {
653
+ suspended: () => this.suspended.has(pane),
654
+ notice: `pane ${pane} is suspended: remote input is refused until resume`,
655
+ });
656
+ }
657
+
658
+ paneByContext(contextKey) { return this.#paneByContext(contextKey); }
659
+
660
+ // ---- the node stream: request/reply doors -----------------------------------
661
+
662
+ /**
663
+ * frontend.v2 over the channel, with the runtime's receipt resolved here on
664
+ * the machine. The caller's FrontendClient
665
+ * talks to a fetch that rides this stream; the daemon resolves the runtime's
666
+ * live receipt, mints THIS principal its own credential through the loopback
667
+ * mint door (so the lease coordinator sees one RuntimeClientId per remote
668
+ * principal), and forwards each request to the runtime's own server.
669
+ *
670
+ * → { id, method, path, headers, body(base64) }
671
+ * ← { id, status, headers } then { id, chunk(base64) }* then { id, end }
672
+ * → { id, abort }
673
+ */
674
+ async #serveFrontendProxy(entry, stream, accept, refuse) {
675
+ const { admitted, principal } = entry;
676
+ const runtimeId = String(stream.params.runtime ?? '');
677
+ const wantsInteract = admitted.class === 'operator' || holds(admitted, 'interact', `runtime:${runtimeId}`);
678
+ if (!wantsInteract && !holds(admitted, 'observe', `runtime:${runtimeId}`)) {
679
+ this.log.append({ principal, cap: 'observe', target: `runtime:${runtimeId}`, outcome: 'refused' });
680
+ return refuse(`no observe on runtime:${runtimeId}`);
681
+ }
682
+ const receipt = findLiveReceipt(runtimeId, this.env);
683
+ if (!receipt) return refuse(`no live runtime ${runtimeId} on this Machine`);
684
+ // One RuntimeClientId per remote principal. Two runtime servers exist in core: the SDK-hosted
685
+ // one offers the loopback mint door (a credential BOUND to a client id); the frontend server
686
+ // for ACP-backed runtimes does not, and instead takes the client id from
687
+ // `x-supercode-client-id` and clamps rights from `x-supercode-permissions` when the credential
688
+ // is unbound (server.rs coordinated_http_client). So: mint when the door exists, otherwise
689
+ // forward the receipt's own token with this principal's id and a clamped permission header.
690
+ const clientId = `teams-${principal.slice(0, 12)}-${randomBytes(4).toString('hex')}`;
691
+ let credential = { clientId, token: receipt.token, minted: false };
692
+ try {
693
+ const token = await mintFrontendCredential(receipt, { clientId, grant: wantsInteract ? 'interactive' : 'observer' });
694
+ credential = { clientId, token, minted: true };
695
+ } catch (error) {
696
+ if (!/^mint 404/.test(error.message)) {
697
+ this.log.append({ principal, cap: 'observe', target: `runtime:${runtimeId}`, outcome: 'failed', detail: error.message });
698
+ return refuse(`could not mint a credential: ${error.message}`);
699
+ }
700
+ }
701
+ accept();
702
+ this.log.append({ principal, cap: wantsInteract ? 'interact' : 'observe', target: `runtime:${runtimeId}`, outcome: 'attached', detail: credential.minted ? 'channel, minted credential' : 'channel, receipt token + client id' });
703
+ const inflight = new Map(); // id → ClientRequest
704
+ stream.on('data', (chunk) => {
705
+ let message;
706
+ try { message = JSON.parse(chunk); } catch { return; }
707
+ if (message.abort) { inflight.get(message.id)?.destroy(); inflight.delete(message.id); return; }
708
+ // Only a path on the runtime's own server: an absolute URL or a `//host` prefix would turn
709
+ // the daemon into a proxy carrying the runtime's bearer to an arbitrary origin.
710
+ if (typeof message.path !== 'string' || !/^\/(?!\/)/.test(message.path)) {
711
+ if (!stream.closed) stream.write(JSON.stringify({ id: message.id, status: 400, headers: {}, error: 'path must be a root-relative path on the runtime', end: true }));
712
+ return;
713
+ }
714
+ const target = new URL(message.path, receipt.base_url);
715
+ const headers = { ...(message.headers ?? {}) };
716
+ // The principal's own identity replaces whatever the caller sent: the minted credential
717
+ // and its clientId are what the runtime's lease coordinator must see.
718
+ headers.authorization = `Bearer ${credential.token}`;
719
+ headers['x-supercode-client-id'] = credential.clientId;
720
+ // Clamp to the grant: observers get observe; interact holders get the three runtime rungs the
721
+ // grant carries (approve travels with interact); an operator keeps the token's own authorization.
722
+ if (!wantsInteract) headers['x-supercode-permissions'] = 'observe';
723
+ else if (admitted.class !== 'operator') headers['x-supercode-permissions'] = 'observe,interact,approve';
724
+ headers.host = target.host;
725
+ delete headers['content-length'];
726
+ const body = message.body ? Buffer.from(message.body, 'base64') : null;
727
+ if (body) headers['content-length'] = String(body.length);
728
+ const upstream = httpRequest(target, { method: message.method ?? 'GET', headers }, (res) => {
729
+ if (stream.closed) { res.destroy(); return; }
730
+ stream.write(JSON.stringify({ id: message.id, status: res.statusCode, headers: res.headers }));
731
+ res.on('data', (piece) => { if (!stream.closed) stream.write(JSON.stringify({ id: message.id, chunk: Buffer.from(piece).toString('base64') })); });
732
+ res.on('end', () => { inflight.delete(message.id); if (!stream.closed) stream.write(JSON.stringify({ id: message.id, end: true })); });
733
+ });
734
+ upstream.on('error', (error) => { inflight.delete(message.id); if (!stream.closed) stream.write(JSON.stringify({ id: message.id, status: 502, headers: {}, error: error.message, end: true })); });
735
+ inflight.set(message.id, upstream);
736
+ upstream.end(body ?? undefined);
737
+ });
738
+ stream.on('close', () => { for (const req of inflight.values()) req.destroy(); inflight.clear(); });
739
+ }
740
+
741
+ /**
742
+ * A Machine port forwarded to a keyed caller (`port:<pane>/<n>`). One stream is
743
+ * one TCP connection to 127.0.0.1:<port> on this Machine; bytes ride base64. A participant
744
+ * needs `ports` on `port:<pane>/<n>` and the port must be one that pane announced (the live
745
+ * port table); an operator may reach any port.
746
+ */
747
+ /**
748
+ * A pilot's tool process (`exec`) or its view of this machine's files (`sftp`), each one job
749
+ * the daemon owns (execution.mjs). Arbitrary code as this user, so operators only: the owner
750
+ * through the local door, or an `operate` grant through the server.
751
+ */
752
+ #serveExecution(entry, stream, accept, refuse) {
753
+ const { admitted, principal } = entry;
754
+ if (admitted.class !== 'operator') {
755
+ this.log.append({ principal, cap: stream.name, target: '*', outcome: 'refused' });
756
+ return refuse(`${stream.name} is for operators`);
757
+ }
758
+ const endpoint = streamProgram(stream.name, stream.params ?? {}, this.env);
759
+ const served = stream.name === 'exec-server' ? Boolean(endpoint) : executionCapabilities(this.env)[stream.name];
760
+ if (!endpoint || !served) return refuse(stream.name === 'exec-server' ? `this machine serves no exec-server for ${stream.params?.harness}` : `this machine does not serve ${stream.name}`);
761
+ accept();
762
+ try {
763
+ // an exec-server runs its processes in the directories its client names; it starts in the daemon's home
764
+ const job = this.jobs.run(stream, { kind: stream.name, ...endpoint, cwd: this.home });
765
+ this.log.append({ principal, cap: stream.name, target: `job:${job.id}`, outcome: 'started', detail: job.cgroup ? 'cgroup' : 'process-group' });
766
+ } catch (error) {
767
+ this.log.append({ principal, cap: stream.name, target: '*', outcome: 'failed', detail: error.message });
768
+ stream.close(error.message);
769
+ }
770
+ }
771
+
772
+ async #servePortForward(entry, stream, accept, refuse) {
773
+ const { admitted, principal } = entry;
774
+ const port = Number(stream.params.port);
775
+ const pane = stream.params.pane ? String(stream.params.pane) : null;
776
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) return refuse('port must be 1–65535');
777
+ const target = `port:${pane ?? '*'}/${port}`;
778
+ if (admitted.class !== 'operator') {
779
+ if (!holds(admitted, 'ports', target) && !(pane && holds(admitted, 'ports', `pane:${pane}`))) {
780
+ this.log.append({ principal, cap: 'ports', target, outcome: 'refused' });
781
+ return refuse(`no ports on ${target}`);
782
+ }
783
+ const announced = this.listPorts().some((p) => p.port === port && (pane == null || p.pane === pane));
784
+ if (!announced) { this.log.append({ principal, cap: 'ports', target, outcome: 'refused', detail: 'not announced by that pane' }); return refuse(`port ${port} is not announced by pane ${pane}`); }
785
+ }
786
+ const { connect } = await import('node:net');
787
+ const socket = connect({ host: '127.0.0.1', port });
788
+ socket.once('connect', () => {
789
+ accept();
790
+ this.log.append({ principal, cap: 'ports', target, outcome: 'forwarded' });
791
+ socket.on('data', (bytes) => { if (!stream.closed) stream.write(bytes.toString('base64')); });
792
+ socket.on('close', () => stream.close('port closed'));
793
+ stream.on('data', (chunk) => { if (!socket.destroyed) socket.write(Buffer.from(chunk, 'base64')); });
794
+ stream.on('close', () => socket.destroy());
795
+ });
796
+ socket.once('error', (error) => { this.log.append({ principal, cap: 'ports', target, outcome: 'failed', detail: error.code ?? error.message }); refuse(`port ${port}: ${error.code ?? error.message}`); });
797
+ }
798
+
799
+ /**
800
+ * The Machine's live state as a stream ("transition stream"; the local state a
801
+ * caller would otherwise poll): one `snapshot` on open, then `delta`s — panes appearing,
802
+ * changing or vanishing (with attention label, holder, title, suspended), ports announced or
803
+ * gone. Computed from what the daemon already has (listSessions, the label, the port table);
804
+ * nothing new is decided. Participants see only the panes their grants cover; operators all.
805
+ */
806
+ async #serveWatch(entry, stream, accept, refuse) {
807
+ const { admitted, principal } = entry;
808
+ if (admitted.class !== 'operator' && !holds(admitted, 'view', '*') && !observesPanes(admitted)) {
809
+ this.log.append({ principal, cap: 'view', target: '*', outcome: 'refused', detail: 'watch' });
810
+ return refuse('no view on this Machine');
811
+ }
812
+ accept();
813
+ this.log.append({ principal, cap: 'view', target: '*', outcome: 'watching' });
814
+ const visible = (pane) => admitted.class === 'operator' || holds(admitted, 'view', '*') || holds(admitted, 'observe', `pane:${pane}`);
815
+ const intervalMs = Math.max(500, Math.min(Number(stream.params.intervalMs) || 1000, 10_000));
816
+ let last = new Map();
817
+ let lastPorts = '';
818
+ const tick = async () => {
819
+ if (stream.closed) return;
820
+ try {
821
+ const rows = (await this.#dispatch(entry, 'panes.list', {})).filter((r) => r.pane && visible(r.pane));
822
+ const now = new Map(rows.map((r) => [r.pane, r]));
823
+ const ports = this.listPorts().filter((p) => (p.pane ? visible(p.pane) : admitted.class === 'operator' || holds(admitted, 'view', '*') || holds(admitted, 'ports', `port:*/${p.port}`)));
824
+ const portsKey = JSON.stringify(ports);
825
+ if (last.size === 0 && lastPorts === '') {
826
+ stream.write(JSON.stringify({ type: 'snapshot', panes: rows, ports, at: Date.now() }));
827
+ } else {
828
+ const deltas = [];
829
+ for (const [pane, row] of now) {
830
+ const before = last.get(pane);
831
+ if (!before) deltas.push({ kind: 'pane-added', pane, row });
832
+ else if (JSON.stringify(before) !== JSON.stringify(row)) deltas.push({ kind: 'pane-changed', pane, row, was: { attention: before.attention, title: before.title, suspended: before.suspended } });
833
+ }
834
+ for (const pane of last.keys()) if (!now.has(pane)) deltas.push({ kind: 'pane-gone', pane });
835
+ if (portsKey !== lastPorts) deltas.push({ kind: 'ports', ports });
836
+ if (deltas.length) stream.write(JSON.stringify({ type: 'delta', deltas, at: Date.now() }));
837
+ }
838
+ last = now; lastPorts = portsKey;
839
+ } catch (error) {
840
+ if (!stream.closed) stream.write(JSON.stringify({ type: 'error', message: error.message }));
841
+ }
842
+ };
843
+ await tick();
844
+ const timer = setInterval(tick, intervalMs);
845
+ stream.on('close', () => clearInterval(timer));
846
+ }
847
+
848
+ #serveNodeRpc(entry, stream) {
849
+ let buffer = '';
850
+ stream.on('data', async (chunk) => {
851
+ buffer += chunk;
852
+ let nl;
853
+ while ((nl = buffer.indexOf('\n')) >= 0) {
854
+ const line = buffer.slice(0, nl);
855
+ buffer = buffer.slice(nl + 1);
856
+ if (!line) continue;
857
+ let request;
858
+ try { request = JSON.parse(line); } catch { continue; }
859
+ const startedAt = Date.now();
860
+ // A reply to a stream the caller closed mid-door is dropped, never thrown: a throw here
861
+ // would reject the data listener and take the daemon down with it.
862
+ const reply = (envelope) => { if (stream.closed) return; try { stream.write(JSON.stringify(envelope) + '\n'); } catch { /* peer gone */ } };
863
+ try {
864
+ // Every door answers or fails within a bound; a hung substrate call becomes an error, never silence.
865
+ // The caller may shorten the bound, never lengthen it past the daemon's own 30 s.
866
+ const bound = Math.min(Number(request.params?.timeoutMs) || 30_000, 30_000);
867
+ const result = await withTimeout(this.#dispatch(entry, request.method, request.params ?? {}), bound, request.method);
868
+ reply({ id: request.id, result });
869
+ this.emit('door', { method: request.method, ms: Date.now() - startedAt, outcome: 'ok' });
870
+ } catch (error) {
871
+ this.emit('door', { method: request.method, ms: Date.now() - startedAt, outcome: error.code ?? 'error', detail: error.message });
872
+ reply({ id: request.id, error: { code: error.code ?? -1, message: error.message } });
873
+ }
874
+ }
875
+ });
876
+ }
877
+
878
+ async #dispatch(entry, method, params) {
879
+ // A server-forwarded caller is re-admitted on every door: a grant revoked or expired
880
+ // since the stream opened stops mattering NOW, not at the next stream.
881
+ if (entry.verify) {
882
+ const fresh = await entry.verify();
883
+ if (!fresh) {
884
+ this.log.append({ principal: entry.principal, cap: method, target: '*', outcome: 'refused', detail: 'no live machine grant' });
885
+ throw Object.assign(new Error('no live machine grant'), { code: 403 });
886
+ }
887
+ entry.admitted = fresh;
888
+ }
889
+ const { admitted, principal } = entry;
890
+ const operator = admitted.class === 'operator';
891
+ const need = (cap, target) => {
892
+ if (!holds(admitted, cap, target)) {
893
+ this.log.append({ principal, cap, target, outcome: 'refused' });
894
+ throw Object.assign(new Error(`no ${cap} on ${target}`), { code: 403 });
895
+ }
896
+ };
897
+ const act = (cap, target, outcome = 'ok', detail = null) => this.log.append({ principal, cap, target, outcome, detail });
898
+
899
+ switch (method) {
900
+ case 'describe':
901
+ return this.describe();
902
+ case 'panes.list': {
903
+ // A participant lists the panes its grants let it observe; view on the whole
904
+ // machine (or operator) lists them all.
905
+ const whole = operator || holds(admitted, 'view', '*');
906
+ if (!whole && !observesPanes(admitted)) need('view', '*');
907
+ const shown = (pane) => whole || holds(admitted, 'observe', `pane:${pane}`);
908
+ const titles = readJson(this.p.titles, { v: 1, titles: {} }).titles;
909
+ // Every row carries its attention because the evidence is already in
910
+ // hand here: a list that made a caller ask a second door "and is anything
911
+ // wrong with these?" would be a list that knows and will not say.
912
+ const rows = await this.attentionEvidence();
913
+ // `title` is what a viewer should show: the override if one is set, else the tmux label.
914
+ return rows.filter((row) => row.session.id && shown(row.session.contextKey)).map(({ session: s, attention, rank, notify, evidence }) => ({
915
+ pane: s.contextKey, label: s.label, title: titles[`pane:${s.contextKey}`] ?? s.label, cwd: s.cwd, owned: s.owned, activeCommand: s.activeCommand, size: s.size, suspended: this.suspended.has(s.contextKey),
916
+ attention, rank, notify, evidence,
917
+ }));
918
+ }
919
+ case 'launch': {
920
+ // A pane launch. Operators only; the body may carry secrets and is never logged.
921
+ if (!operator) throw Object.assign(new Error('launch is for operators'), { code: 403 });
922
+ const key = params.key ?? `lk_${randomBytes(9).toString('base64url')}`;
923
+ const existing = this.launches.find(key);
924
+ // A key whose launch is in flight, or whose pane still lives, is reused; a failed
925
+ // launch, or one whose pane has ended, may be launched again.
926
+ if (existing && (existing.status === 'issued' || (existing.status === 'ready' && await this.#paneByContext(existing.pane)))) {
927
+ return { key, pane: existing.pane, reused: true, status: existing.status };
928
+ }
929
+ const contextKey = params.pane ?? `p_${randomBytes(9).toString('base64url')}`;
930
+ this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, status: 'issued' });
931
+ let session;
932
+ try {
933
+ session = await this.host.createSession({
934
+ command: { program: params.program, arguments: params.arguments ?? [] },
935
+ contextKey,
936
+ cwd: params.cwd,
937
+ env: this.#paneEnv(contextKey, params.env),
938
+ name: params.name ?? contextKey,
939
+ });
940
+ } catch (error) {
941
+ this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, status: 'failed', error: error.message });
942
+ act('launch', `pane:${contextKey}`, 'failed', error.code ?? error.message);
943
+ throw error;
944
+ }
945
+ this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, status: 'ready' });
946
+ act('launch', `pane:${contextKey}`, 'ok', params.kind ?? params.program);
947
+ // The first input is delivered when the pane can TAKE it, not when the
948
+ // pane exists: a program that is still painting its banner has no
949
+ // input box yet, and text sent into one lands half in the scrollback and
950
+ // half in a composer that never sees the submit. The wait runs past this
951
+ // reply — a cold TUI outlasts the caller's own RPC timeout — and its
952
+ // outcome is recorded against the launch key, so `launches.get|list`
953
+ // answers "did my message go in, and was it late?" without anyone
954
+ // reading the screen.
955
+ if (params.initialInput) this.#deliverInitialInput({ key, contextKey, session, kind: params.kind ?? null, input: params.initialInput, act });
956
+ return { key, pane: contextKey, reused: false, status: 'ready' };
957
+ }
958
+ case 'launches.get': {
959
+ if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
960
+ const found = this.launches.find(params.key);
961
+ if (!found) return null;
962
+ const session = await this.#paneByContext(found.pane);
963
+ return {
964
+ key: found.key, pane: found.pane, live: Boolean(session), label: session?.label ?? null, activeCommand: session?.activeCommand ?? null,
965
+ ...initialInputFacts(found),
966
+ };
967
+ }
968
+ case 'launches.list': {
969
+ // Everything this daemon launched, by key, with whether the pane still lives: how an
970
+ // operator tells its own launches from a person's panes without reading labels.
971
+ if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
972
+ const rows = [];
973
+ for (const found of this.launches.all()) {
974
+ const session = found.pane ? await this.#paneByContext(found.pane) : null;
975
+ rows.push({ key: found.key, pane: found.pane ?? null, kind: found.kind ?? null, status: found.status ?? null, at: found.t, live: Boolean(session), ...initialInputFacts(found) });
976
+ }
977
+ return rows;
978
+ }
979
+ case 'panes.close': {
980
+ need(operator ? 'control' : 'control', `pane:${params.pane}`);
981
+ const session = await this.#paneByContext(params.pane);
982
+ if (!session) throw new Error(`no pane with context ${params.pane}`);
983
+ this.host.closeSession(session.id);
984
+ act('close', `pane:${params.pane}`);
985
+ return { closed: params.pane };
986
+ }
987
+ case 'panes.suspend': case 'panes.resume': {
988
+ // a holder of `control` (an operator always) stops REMOTE input on a
989
+ // pane — attachments keep streaming output and `panes.capture` keeps
990
+ // working, only `{input}` is refused — until `resume`, which may carry
991
+ // the input that hands the pane back. Local tmux input is never touched.
992
+ need('control', `pane:${params.pane}`);
993
+ const session = await this.#paneByContext(params.pane);
994
+ if (!session) throw new Error(`no pane with context ${params.pane}`);
995
+ const doc = readJson(this.p.suspended, { v: 1, panes: {} });
996
+ if (method === 'panes.suspend') {
997
+ this.suspended.add(params.pane);
998
+ doc.panes[params.pane] = { at: Date.now(), by: principal };
999
+ if (params.interrupt) await this.host.sendInput(session.id, '\u0003', { submitMode: 'single' });
1000
+ } else {
1001
+ this.suspended.delete(params.pane);
1002
+ delete doc.panes[params.pane];
1003
+ if (params.input) await this.host.sendInput(session.id, submission(params.input), { submitMode: params.submitMode ?? 'single' });
1004
+ }
1005
+ writeJson(this.p.suspended, doc);
1006
+ act(method, `pane:${params.pane}`, 'ok', method === 'panes.resume' && params.input ? 'with input' : null);
1007
+ return { pane: params.pane, suspended: method === 'panes.suspend' };
1008
+ }
1009
+ case 'panes.input': {
1010
+ // Input as a request (the terminal host's own sendInput, with its paste/settle/submit
1011
+ // mechanics) for callers that are services rather than terminals. Same gate as an
1012
+ // attachment in control mode, same suspend rule as remote input everywhere.
1013
+ need('control', `pane:${params.pane}`);
1014
+ const session = await this.#paneByContext(params.pane);
1015
+ if (!session) throw Object.assign(new Error(`no pane with context ${params.pane}`), { code: 404 });
1016
+ if (this.suspended.has(params.pane)) throw Object.assign(new Error(`pane ${params.pane} is suspended: remote input is refused until resume`), { code: 423 });
1017
+ // `deadlineMs` is how long the caller will wait (default 10 s, which covers `retry`'s 5.5 s of
1018
+ // submit presses; at most 30 s); the terminal host takes an absolute deadline.
1019
+ const delivery = await this.host.sendInput(session.id, submission(params.text, params.submit !== false), { submitMode: params.submit === false ? 'single' : 'retry', deadlineMs: Date.now() + Math.min(Number(params.deadlineMs) || 10_000, 30_000) });
1020
+ act('control', `pane:${params.pane}`, 'input');
1021
+ return { delivered: true, delivery };
1022
+ }
1023
+ case 'panes.turn': {
1024
+ // A user's own turn, as its own submitted turn: typed only into an empty
1025
+ // composer, read twice so a person who just started typing is not typed
1026
+ // over. A draft, or a screen with no composer (a menu, a dialog), is an
1027
+ // answer, not an error: the caller keeps the turn and asks again. Its own
1028
+ // method, so a daemon without it refuses instead of typing into a draft.
1029
+ need('control', `pane:${params.pane}`);
1030
+ const session = await this.#paneByContext(params.pane);
1031
+ if (!session) throw Object.assign(new Error(`no pane with context ${params.pane}`), { code: 404 });
1032
+ if (this.suspended.has(params.pane)) throw Object.assign(new Error(`pane ${params.pane} is suspended: remote input is refused until resume`), { code: 423 });
1033
+ const kind = this.#paneKind(params.pane, session);
1034
+ const draftOf = () => composerHoldsDraft(kind, this.host.capture(session.id, { lines: 200, preserveEscapes: true }));
1035
+ const first = draftOf();
1036
+ await new Promise((resolve) => setTimeout(resolve, COMPOSER_RECHECK_MS));
1037
+ const second = draftOf();
1038
+ if (first !== false || second !== false) {
1039
+ return { delivered: false, reason: first === true || second === true ? 'draft' : 'no-composer' };
1040
+ }
1041
+ const delivery = await this.host.sendInput(session.id, submission(params.text, true), { submitMode: 'single', deadlineMs: Date.now() + 10_000 });
1042
+ act('control', `pane:${params.pane}`, 'input');
1043
+ return { delivered: true, delivery };
1044
+ }
1045
+ case 'panes.capture': {
1046
+ need('observe', `pane:${params.pane}`);
1047
+ const session = await this.#paneByContext(params.pane);
1048
+ if (!session) throw new Error(`no pane with context ${params.pane}`);
1049
+ return { text: this.host.capture(session.id, { lines: params.lines ?? 200 }) };
1050
+ }
1051
+ case 'files.stat': case 'files.list': case 'files.read': case 'files.write': case 'files.mkdir': case 'files.rename': case 'files.delete': case 'files.search': {
1052
+ // Authorize the REAL path: a symlink under a granted root must not reach outside it. Reading (stat, list,
1053
+ // read, search) is `files` or `observe` on the root, as observing a pane is reading it; changing is `files`.
1054
+ const path = realOrResolved(params.path);
1055
+ const reads = method === 'files.stat' || method === 'files.list' || method === 'files.read' || method === 'files.search';
1056
+ if (!(reads && holds(admitted, 'observe', `root:${path}`))) need('files', `root:${path}`);
1057
+ // A rename's destination is a write to a second path: it is authorized on its own.
1058
+ const to = params.to ? realOrResolved(params.to) : null;
1059
+ if (to) need('files', `root:${to}`);
1060
+ const outcome = await filesOp(method, { ...params, path }, to);
1061
+ act('files', `root:${path}`, 'ok', method);
1062
+ return outcome;
1063
+ }
1064
+ case 'ports.list': {
1065
+ // Operators see every port; a participant sees the ports its grants reach.
1066
+ if (operator) return this.listPorts();
1067
+ const reach = (p) => holds(admitted, 'ports', `port:${p.pane ?? '*'}/${p.port}`) || (p.pane && holds(admitted, 'ports', `pane:${p.pane}`));
1068
+ const shown = this.listPorts().filter(reach);
1069
+ if (!shown.length && !admitted.grants.some((g) => g.caps.includes('ports'))) need('ports', 'port:*');
1070
+ return shown;
1071
+ }
1072
+ case 'ports.resolve': {
1073
+ // One pane's announced port, for a caller holding `ports` on that pane.
1074
+ if (typeof params.pane !== 'string' || !params.pane) throw Object.assign(new Error('ports.resolve needs a pane'), { code: 400 });
1075
+ if (!operator) need('ports', `pane:${params.pane}`);
1076
+ return this.listPorts().find((entry) => entry.pane === params.pane) ?? null;
1077
+ }
1078
+ case 'attention.request': case 'attention.clear': {
1079
+ // The declaration rung: somebody SAYS this pane needs a human. The
1080
+ // urgency and the reason ride with the record and reach the evidence
1081
+ // verbatim — the daemon never grades a reason, and `now` versus `whenever`
1082
+ // is the caller's claim about their own work, not a label we infer.
1083
+ need('interact', `pane:${params.pane}`);
1084
+ const doc = this.attentionDoc();
1085
+ if (method === 'attention.request') doc.requests[params.pane] = { urgency: params.urgency ?? 'soon', reason: params.reason ?? null, at: Date.now(), by: principal };
1086
+ else delete doc.requests[params.pane];
1087
+ writeJson(this.p.attention, doc);
1088
+ act(method, `pane:${params.pane}`, 'ok', method === 'attention.request' ? (params.urgency ?? 'soon') : null);
1089
+ return { ok: true };
1090
+ }
1091
+ case 'attention.list': {
1092
+ // The standing declarations, as recorded. The computed view is
1093
+ // `attention.snapshot`; this door stays the raw map so a caller asking
1094
+ // "what did anyone ASK for" is never handed an inference as an answer.
1095
+ if (!operator) need('view', '*');
1096
+ return this.attentionDoc().requests;
1097
+ }
1098
+ case 'attention.snapshot': {
1099
+ // The label for every pane, from the evidence and the table, computed
1100
+ // here on the Machine. It carries `notify` — whether this label is
1101
+ // the kind that warrants interrupting a human — and stops there: who is
1102
+ // paged, and whether they already know, is the consumer's.
1103
+ if (!operator) need('view', '*');
1104
+ const rows = await this.attentionEvidence();
1105
+ return rows.map(({ session, attention, rank, notify, evidence }) => ({ pane: session.contextKey, attention, rank, notify, evidence }));
1106
+ }
1107
+ case 'attention.rank': {
1108
+ // The rank table as this Machine reads it — the configuration the label
1109
+ // is a function of, so a caller surprised by a label can see the
1110
+ // precedence that produced it rather than guess at a default.
1111
+ if (!operator) need('view', '*');
1112
+ return this.attentionDoc().rank;
1113
+ }
1114
+ case 'title.set': {
1115
+ // The title is a teams file, never a store in core. A pane the daemon
1116
+ // owns also gets the name in tmux, so the rename is visible to anyone
1117
+ // looking at the tmux server rather than only through this door.
1118
+ need('control', params.target);
1119
+ const doc = readJson(this.p.titles, { v: 1, titles: {} });
1120
+ if (params.title == null) delete doc.titles[params.target]; else doc.titles[params.target] = params.title;
1121
+ writeJson(this.p.titles, doc);
1122
+ const renamed = params.target.startsWith('pane:') ? await this.#renameWindow(params.target.slice(5), params.title) : null;
1123
+ act('title', params.target, 'ok', renamed);
1124
+ return { ok: true, target: params.target, title: params.title ?? null, window: renamed };
1125
+ }
1126
+ case 'jobs.list': {
1127
+ // The execution jobs this daemon owns now (execution.mjs): a pilot changes its binding
1128
+ // only when the target it leaves and the one it takes run none.
1129
+ if (!operator) throw Object.assign(new Error('jobs are for operators'), { code: 403 });
1130
+ return { jobs: [...(this.jobs?.live.values() ?? [])].map(({ id, kind, cgroup, at }) => ({ id, kind, contained: Boolean(cgroup), at })) };
1131
+ }
1132
+ case 'title.list': {
1133
+ // Operators and whole-machine viewers see every title; others see the titles of targets they observe.
1134
+ const titles = readJson(this.p.titles, { v: 1, titles: {} }).titles;
1135
+ if (operator || holds(admitted, 'view', '*')) return titles;
1136
+ if (!observesPanes(admitted)) need('view', '*');
1137
+ return Object.fromEntries(Object.entries(titles).filter(([target]) => holds(admitted, 'observe', target)));
1138
+ }
1139
+ default:
1140
+ throw Object.assign(new Error(`unknown method ${method}`), { code: -32601 });
1141
+ }
1142
+ }
1143
+ }
1144
+
1145
+ // ---- helpers ------------------------------------------------------------------
1146
+
1147
+ /** The shells a pane may be sitting in. `activeCommand` is tmux's
1148
+ * `pane_current_command`: the name of the process in the foreground of the
1149
+ * pane's tty. When that is the pane's own shell, no command is running and the
1150
+ * shell is at its prompt; when it is anything else, that thing is the work. The
1151
+ * list is names, not paths, because that is the form tmux reports. */
1152
+ const PANE_SHELLS = new Set(['ash', 'bash', 'csh', 'dash', 'fish', 'ksh', 'ksh93', 'nu', 'pwsh', 'sh', 'tcsh', 'xonsh', 'zsh']);
1153
+
1154
+ /** Liveness of a live pane from its foreground command (attention.mjs LIVENESS).
1155
+ * A missing or empty value is `unknown`, the honest floor: tmux named nothing,
1156
+ * which is absence of evidence and never reported as `running`. */
1157
+ /** A pane's liveness from the harness session running in it, or null when
1158
+ * the harness has no trustworthy turn state (the process tree answers then). */
1159
+ function harnessLiveness(activity) {
1160
+ if (!activity || activity.presence !== 'running') return null;
1161
+ if (activity.turn === 'working') return { liveness: 'running' };
1162
+ if (activity.turn === 'idle') return { liveness: 'waiting' };
1163
+ if (activity.turn === 'needs_input') return { liveness: 'waiting', signal: 'asking' };
1164
+ return null;
1165
+ }
1166
+
1167
+ function livenessOf(activeCommand) {
1168
+ if (typeof activeCommand !== 'string' || activeCommand.trim() === '') return 'unknown';
1169
+ const name = activeCommand.trim().replace(/^-/, '').split('/').pop();
1170
+ return PANE_SHELLS.has(name) ? 'waiting' : 'running';
1171
+ }
1172
+
1173
+ /** The label, its rank and its notify flag, with the evidence tagged by the rung
1174
+ * that decided it — the shape every attention-bearing row carries. */
1175
+ function judged(evidence, rankTable) {
1176
+ const verdict = judge(evidence, rankTable);
1177
+ return {
1178
+ attention: verdict.attention,
1179
+ rank: verdict.rank,
1180
+ notify: verdict.notify,
1181
+ evidence: { ...evidence, source: verdict.source },
1182
+ };
1183
+ }
1184
+
1185
+ /** Text a door delivers as one message: the terminal host submits (a paste, then Enter) only
1186
+ * text that ends in a newline and types anything else as keystrokes, so a door that promises
1187
+ * a submission ends the text with exactly one, and `submit: false` leaves it without. */
1188
+ function submission(text, submit = true) {
1189
+ const body = String(text ?? '').replace(/(?:\r\n|\n|\r)+$/u, '');
1190
+ return submit ? `${body}\n` : body;
1191
+ }
1192
+
1193
+ /** Characters of a harness line sent per channel frame. A frame is capped at 8 MiB,
1194
+ * and one answer (a whole machine's `sessions.discover`) can be larger; the reader
1195
+ * joins a stream's chunks back into lines, so a long line travels as several frames.
1196
+ * 512 Ki characters stay under the cap even when every one needs four bytes. */
1197
+ const LINE_CHUNK = 512 * 1024;
1198
+
1199
+ /** Write one JSON line to a harness stream, in as many frames as it needs. */
1200
+ function writeLine(stream, line) {
1201
+ const text = line + '\n';
1202
+ for (let at = 0; at < text.length; at += LINE_CHUNK) stream.write(text.slice(at, at + LINE_CHUNK));
1203
+ }
1204
+
1205
+ /** What a launch record says about its first input, for the rows `launches.get`
1206
+ * and `launches.list` return. Absent from a launch that carried no
1207
+ * `initialInput`, and absent while one is still waiting for its pane — a
1208
+ * launch that has not answered yet must not read as one that answered "no". */
1209
+ function initialInputFacts(record) {
1210
+ if (record?.inputDelivered === undefined) return {};
1211
+ return {
1212
+ inputDelivered: Boolean(record.inputDelivered),
1213
+ inputReadyBy: record.inputReadyBy ?? null,
1214
+ ...(record.inputDeliveredLate ? { inputDeliveredLate: true } : {}),
1215
+ ...(record.inputError ? { inputError: record.inputError } : {}),
1216
+ };
1217
+ }
1218
+
1219
+ /**
1220
+ * The path a `files.*` door authorizes: the real path when it exists (so a symlink under a granted
1221
+ * root cannot point outside it), else the resolved path with its deepest existing ancestor made real.
1222
+ */
1223
+ function realOrResolved(candidate) {
1224
+ const resolved = resolve(String(candidate ?? ''));
1225
+ try { return realpathSync(resolved); } catch { /* not there yet: a write or mkdir target */ }
1226
+ let dir = dirname(resolved);
1227
+ while (dir !== dirname(dir)) {
1228
+ try { return join(realpathSync(dir), resolved.slice(dir.length)); } catch { dir = dirname(dir); }
1229
+ }
1230
+ return resolved;
1231
+ }
1232
+
1233
+ function withTimeout(promise, ms, label) {
1234
+ let timer;
1235
+ const bound = new Promise((_, reject) => {
1236
+ timer = setTimeout(() => reject(Object.assign(new Error(`${label} did not answer within ${ms} ms`), { code: 504 })), ms);
1237
+ });
1238
+ return Promise.race([promise, bound]).finally(() => clearTimeout(timer));
1239
+ }
1240
+
1241
+ /** Connection id of the daemon's own requests to its `harness serve`. */
1242
+ const INTERNAL_CONNECTION = -1;
1243
+
1244
+ function joinId(conn, id) { return `${conn}:${typeof id === 'string' ? 's' : 'n'}:${id}`; }
1245
+ function splitId(id) {
1246
+ if (typeof id !== 'string') return [null, id];
1247
+ const first = id.indexOf(':');
1248
+ const second = id.indexOf(':', first + 1);
1249
+ if (first < 0 || second < 0) return [null, id];
1250
+ const conn = Number(id.slice(0, first));
1251
+ const kind = id.slice(first + 1, second);
1252
+ const rest = id.slice(second + 1);
1253
+ return [conn, kind === 'n' ? Number(rest) : rest];
1254
+ }
1255
+
1256
+ /** A `ws`-shaped socket over a channel stream, for the terminal substrate. */
1257
+ class ChannelWebSocket extends EventEmitter {
1258
+ constructor(stream) {
1259
+ super();
1260
+ this.OPEN = 1;
1261
+ this.CLOSED = 3;
1262
+ this.readyState = 1;
1263
+ this.stream = stream;
1264
+ stream.on('data', (chunk) => {
1265
+ // Inbound from the caller: JSON {input} | {resize}. The substrate wants {type:'input',data} | {type:'resize',cols,rows}.
1266
+ let message;
1267
+ try { message = JSON.parse(chunk); } catch { return; }
1268
+ if (message.input !== undefined) this.emit('message', Buffer.from(JSON.stringify({ type: 'input', data: message.input })));
1269
+ else if (message.resize) this.emit('message', Buffer.from(JSON.stringify({ type: 'resize', cols: message.resize.cols, rows: message.resize.rows })));
1270
+ });
1271
+ stream.on('close', () => { this.readyState = 3; this.emit('close'); });
1272
+ }
1273
+ send(data) {
1274
+ if (this.readyState !== 1) return;
1275
+ const bytes = Buffer.isBuffer(data) ? data : Buffer.from(String(data), 'utf8');
1276
+ this.stream.write(bytes.toString('base64'));
1277
+ }
1278
+ close(code, reason) {
1279
+ if (this.readyState === 3) return;
1280
+ this.readyState = 3;
1281
+ this.stream.close(reason ? String(reason) : null);
1282
+ this.emit('close');
1283
+ }
1284
+ }
1285
+
1286
+ /**
1287
+ * The suspend gate. It is a socket in front of a socket: the substrate
1288
+ * (tmux-stream) sees an ordinary ws-shaped peer, and the only difference is
1289
+ * that an `input` message arriving while the pane is suspended never reaches
1290
+ * it — the sender gets one `notice` frame instead.
1291
+ */
1292
+ class SuspendGate extends EventEmitter {
1293
+ constructor(inner, { suspended, notice }) {
1294
+ super();
1295
+ this.inner = inner;
1296
+ this.OPEN = 1;
1297
+ this.CLOSED = 3;
1298
+ inner.on('message', (raw) => {
1299
+ let type = null;
1300
+ try { type = JSON.parse(raw.toString()).type; } catch { /* not ours to parse */ }
1301
+ if (type === 'input' && suspended()) {
1302
+ this.send(JSON.stringify({ type: 'notice', message: notice }));
1303
+ return;
1304
+ }
1305
+ this.emit('message', raw);
1306
+ });
1307
+ inner.on('close', () => this.emit('close'));
1308
+ }
1309
+ get readyState() { return this.inner.readyState; }
1310
+ send(data) { this.inner.send(data); }
1311
+ close(code, reason) { this.inner.close(code, reason); }
1312
+ }
1313
+
1314
+ async function filesOp(method, params, to) {
1315
+ const path = params.path;
1316
+ switch (method) {
1317
+ case 'files.stat': {
1318
+ const s = statSync(path);
1319
+ return { path, size: s.size, mode: s.mode, mtimeMs: s.mtimeMs, kind: s.isDirectory() ? 'directory' : s.isFile() ? 'file' : 'other' };
1320
+ }
1321
+ case 'files.list':
1322
+ return readdirSync(path, { withFileTypes: true }).map((d) => ({ name: d.name, kind: d.isDirectory() ? 'directory' : d.isFile() ? 'file' : 'other' }));
1323
+ case 'files.read': {
1324
+ const fd = openSync(path, 'r');
1325
+ try {
1326
+ // One read is at most 8 MiB (RH2's transfer chunks at 8 MiB); a caller asks again for more.
1327
+ const length = Math.min(Number(params.length) || statSync(path).size, 8 * 1024 * 1024);
1328
+ const buffer = Buffer.alloc(length);
1329
+ const read = readSync(fd, buffer, 0, length, params.offset ?? 0);
1330
+ return { bytes: buffer.subarray(0, read).toString('base64'), read };
1331
+ } finally { closeSync(fd); }
1332
+ }
1333
+ case 'files.write': {
1334
+ const bytes = Buffer.from(params.bytes ?? '', 'base64');
1335
+ mkdirSync(dirname(path), { recursive: true });
1336
+ if (params.truncate || params.offset == null) writeFileSync(path, bytes, { mode: params.mode ?? 0o644 });
1337
+ else {
1338
+ const fd = openSync(path, 'r+');
1339
+ try { writeSync(fd, bytes, 0, bytes.length, params.offset); } finally { closeSync(fd); }
1340
+ }
1341
+ return { written: bytes.length };
1342
+ }
1343
+ case 'files.mkdir':
1344
+ mkdirSync(path, { recursive: true, mode: params.mode ?? 0o755 });
1345
+ return { ok: true };
1346
+ case 'files.rename':
1347
+ renameSync(path, to);
1348
+ return { ok: true };
1349
+ case 'files.delete':
1350
+ rmSync(path, { recursive: Boolean(params.recursive), force: false });
1351
+ return { ok: true };
1352
+ case 'files.search': {
1353
+ const needle = String(params.query ?? '').toLowerCase();
1354
+ const out = [];
1355
+ const walk = (dir, depth) => {
1356
+ // Bounded walk: depth ≤ 16, results ≤ 1000, whatever the caller asks.
1357
+ if (depth > Math.min(Number(params.maxDepth) || 8, 16) || out.length >= Math.min(Number(params.limit) || 200, 1000)) return;
1358
+ for (const d of readdirSync(dir, { withFileTypes: true })) {
1359
+ const full = join(dir, d.name);
1360
+ if (d.name.toLowerCase().includes(needle)) out.push({ path: full, kind: d.isDirectory() ? 'directory' : 'file' });
1361
+ if (d.isDirectory() && d.name !== 'node_modules' && d.name !== '.git') walk(full, depth + 1);
1362
+ }
1363
+ };
1364
+ walk(path, 0);
1365
+ return out;
1366
+ }
1367
+ default:
1368
+ throw new Error(`unknown files op ${method}`);
1369
+ }
1370
+ }
1371
+ /**
1372
+ * The live port table: one JSON file per announced port under <home>/ports.
1373
+ * A hook that was killed before its exit handler ran leaves its file behind, so the
1374
+ * table is swept here — an entry whose pid is gone is neither returned nor kept.
1375
+ */
1376
+ function listAnnouncedPorts(dir) {
1377
+ if (!existsSync(dir)) return [];
1378
+ const out = [];
1379
+ for (const name of readdirSync(dir)) {
1380
+ if (!name.endsWith('.json')) continue;
1381
+ const file = join(dir, name);
1382
+ try {
1383
+ const entry = JSON.parse(readFileSync(file, 'utf8'));
1384
+ if (!alive(entry.pid)) { rmSync(file, { force: true }); continue; }
1385
+ out.push({ port: entry.port, pid: entry.pid, pane: entry.pane ?? null, label: entry.label ?? null, kind: entry.kind ?? 'http', at: entry.at ?? null });
1386
+ } catch { /* a half-written announce file is skipped */ }
1387
+ }
1388
+ return out;
1389
+ }
1390
+
1391
+ /** A socket file left by a daemon that is gone is removed; one a live daemon answers on is not ours to take. */
1392
+ /** Signal 0: does this pid still exist? EPERM means it does, under another uid. */
1393
+ function alive(pid) {
1394
+ if (!Number.isInteger(pid) || pid <= 0) return false;
1395
+ try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
1396
+ }
1397
+
1398
+ export { listAnnouncedPorts };