@volter/supercode-teams 0.3.87 → 0.3.89
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/teams.mjs +1 -0
- package/bin/workspace.mjs +1 -0
- package/live-sessions.mjs +85 -12
- package/machine.mjs +85 -52
- package/mail/agents.mjs +52 -19
- package/mail/reads.mjs +11 -4
- package/mail/relay.mjs +39 -8
- package/mail/send.mjs +134 -7
- package/mail/store.mjs +41 -1
- package/mail/verbs.mjs +19 -10
- package/package.json +2 -2
package/bin/teams.mjs
CHANGED
|
@@ -174,6 +174,7 @@ async function main() {
|
|
|
174
174
|
// --verbose narrates every door with its latency, so a slow machine is diagnosed from its own log.
|
|
175
175
|
machine.on('door', (d) => { if (flags.verbose) process.stderr.write(`door ${d.method} ${d.outcome} ${d.ms}ms${d.detail ? ` ${d.detail}` : ''}\n`); });
|
|
176
176
|
machine.on('serve-stderr', (t) => process.stderr.write(t));
|
|
177
|
+
machine.on('mail-log', (line) => process.stderr.write(`mail: ${line}\n`));
|
|
177
178
|
machine.on('serve-exit', (e) => process.stderr.write(`harness serve exited: ${JSON.stringify(e)}\n`));
|
|
178
179
|
machine.on('unrouted-notification', (m) => { if (flags.verbose) process.stderr.write(`unrouted: ${m.method}\n`); });
|
|
179
180
|
const socket = await machine.start();
|
package/bin/workspace.mjs
CHANGED
|
@@ -1135,6 +1135,7 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
|
|
|
1135
1135
|
// what ends the daemon's harness serve is said in the connector's log, as `machine start` says it
|
|
1136
1136
|
machine.on('serve-exit', (exit) => process.stderr.write(`supercode teams: harness serve exited: ${JSON.stringify(exit)}\n`));
|
|
1137
1137
|
machine.on('serve-stderr', (text) => process.stderr.write(`supercode teams: harness serve: ${String(text).trimEnd().slice(-2000)}\n`));
|
|
1138
|
+
machine.on('mail-log', (line) => process.stderr.write(`supercode teams: mail: ${line}\n`));
|
|
1138
1139
|
// its mail goes through the daemon's own send, in this process
|
|
1139
1140
|
machineEvents.send = (request) => machine.sendOwn(request);
|
|
1140
1141
|
// the Claude worker homes the roots declare: their live sessions are this machine's too (the live-session map)
|
package/live-sessions.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { execFile } from 'node:child_process';
|
|
|
17
17
|
import { mkdirSync, readFileSync, readdirSync, renameSync, watch, writeFileSync } from 'node:fs';
|
|
18
18
|
import { homedir } from 'node:os';
|
|
19
19
|
import { dirname, join } from 'node:path';
|
|
20
|
+
import { FrontendClient } from '@volter/supercode-frontend';
|
|
20
21
|
|
|
21
22
|
/** A relay registers in Claude's registry under this prefix; it is not a session. */
|
|
22
23
|
const RELAY_PREFIX = 'sc-';
|
|
@@ -49,6 +50,27 @@ function linuxStart(pid) {
|
|
|
49
50
|
} catch { return null; }
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
/** When the process on `pid` started, in ms (Linux's /proc, or one `ps` on macOS), or null when it cannot be read. */
|
|
54
|
+
function startedMs(pid) {
|
|
55
|
+
if (process.platform === 'linux') { const started = linuxStart(pid); return Promise.resolve(started ? Date.parse(`${started.date} UTC`) : null); }
|
|
56
|
+
if (process.platform !== 'darwin') return Promise.resolve(null);
|
|
57
|
+
return new Promise((resolve) => execFile('ps', ['-o', 'lstart=', '-p', String(pid)], { env: { ...process.env, TZ: 'UTC', LC_ALL: 'C' }, timeout: 10_000 }, (error, stdout) => {
|
|
58
|
+
const started = error ? null : String(stdout).replace(/\s+/g, ' ').trim();
|
|
59
|
+
resolve(started ? Date.parse(`${started} UTC`) : null);
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Whether the runtime a receipt names answers at its address as that runtime (its own identity, with the receipt's
|
|
64
|
+
* token): the proof a start read off the wall clock cannot give, as the clock may have stepped since the receipt. */
|
|
65
|
+
async function answersAs(receipt) {
|
|
66
|
+
try {
|
|
67
|
+
const client = new FrontendClient({ baseUrl: receipt.base_url, token: receipt.token, clientId: `live-${receipt.receipt_id}`, permissions: ['observe'] });
|
|
68
|
+
let timer;
|
|
69
|
+
const bound = new Promise((resolve) => { timer = setTimeout(() => resolve(null), 2000); });
|
|
70
|
+
try { return (await Promise.race([client.describe(), bound]))?.session_id === receipt.runtime_session_id; } finally { clearTimeout(timer); }
|
|
71
|
+
} catch { return false; }
|
|
72
|
+
}
|
|
73
|
+
|
|
52
74
|
/** Why a claim names no caller. */
|
|
53
75
|
export class CallerRefused extends Error {
|
|
54
76
|
constructor(message) { super(message); this.code = 403; }
|
|
@@ -284,8 +306,44 @@ export class LiveSessions extends EventEmitter {
|
|
|
284
306
|
if (this.runtimes.delete(id)) this.#changed();
|
|
285
307
|
return;
|
|
286
308
|
}
|
|
287
|
-
this
|
|
309
|
+
this.#holdRuntime(id, receipt);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* A live-runtime receipt held in the map, its process's start checked once (on Linux from /proc, on macOS by one `ps`,
|
|
314
|
+
* as a Claude entry's is): a process that started after its receipt was written is its pid used again, and the
|
|
315
|
+
* receipt is let go, never named as that runtime.
|
|
316
|
+
*/
|
|
317
|
+
#holdRuntime(id, receipt) {
|
|
318
|
+
const source = receipt.source;
|
|
319
|
+
const entry = { pid: receipt.pid, harness: source.harness, sessionId: source.session_id, runtimeSessionId: typeof receipt.runtime_session_id === 'string' ? receipt.runtime_session_id : null, cwd: typeof source.workspace === 'string' ? source.workspace : null,
|
|
320
|
+
// what a later check of the same pid needs: when the receipt was written, and how to ask the runtime itself
|
|
321
|
+
created: Number(receipt.created_at_ms), probe: { base_url: receipt.base_url, token: receipt.token, receipt_id: id, runtime_session_id: receipt.runtime_session_id } };
|
|
322
|
+
const known = this.runtimes.get(id);
|
|
323
|
+
if (known && known.pid === entry.pid && known.startChecked) entry.startChecked = true;
|
|
324
|
+
this.runtimes.set(id, entry);
|
|
288
325
|
this.#changed();
|
|
326
|
+
if (entry.startChecked || !Number.isFinite(entry.created)) return;
|
|
327
|
+
entry.startChecked = true;
|
|
328
|
+
void this.#samePid(entry).then((same) => {
|
|
329
|
+
if (same || this.runtimes.get(id) !== entry) return;
|
|
330
|
+
this.runtimes.delete(id);
|
|
331
|
+
this.log(`live sessions: the runtime receipt ${id} names pid ${entry.pid}, but that process started after the receipt was written and does not answer as the runtime: the pid was used again; the receipt is let go`);
|
|
332
|
+
this.#changed();
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Whether the process on a held runtime's pid is still the runtime its receipt names: one that started no later than
|
|
338
|
+
* the receipt was written (ps and /proc say a start to the second, so within 2 s), or, when its start reads later,
|
|
339
|
+
* one that answers as the runtime at the receipt's address (a wall clock that stepped forward makes a live runtime's
|
|
340
|
+
* start read late; a pid used again does not answer as it).
|
|
341
|
+
*/
|
|
342
|
+
async #samePid(entry) {
|
|
343
|
+
if (!alive(entry.pid)) return false;
|
|
344
|
+
const started = await startedMs(entry.pid);
|
|
345
|
+
if (!Number.isFinite(started) || !Number.isFinite(entry.created) || started <= entry.created + 2000) return true;
|
|
346
|
+
return answersAs(entry.probe);
|
|
289
347
|
}
|
|
290
348
|
|
|
291
349
|
/** One messaging-tools marker: the session process named by its file, the server pid in it. */
|
|
@@ -425,10 +483,7 @@ export class LiveSessions extends EventEmitter {
|
|
|
425
483
|
if (typeof receiptId !== 'string' || !/^[0-9a-f]+$/i.test(receiptId)) return null;
|
|
426
484
|
const receipt = readReceiptFile(join(this.receiptsDir, `${receiptId}.json`));
|
|
427
485
|
if (!receipt || receipt.receipt_id !== receiptId || typeof receipt.source?.harness !== 'string' || typeof receipt.source?.session_id !== 'string') return null;
|
|
428
|
-
if (!this.runtimes.has(receiptId))
|
|
429
|
-
this.runtimes.set(receiptId, { pid: receipt.pid, harness: receipt.source.harness, sessionId: receipt.source.session_id, runtimeSessionId: typeof receipt.runtime_session_id === 'string' ? receipt.runtime_session_id : null, cwd: typeof receipt.source.workspace === 'string' ? receipt.source.workspace : null });
|
|
430
|
-
this.#changed();
|
|
431
|
-
}
|
|
486
|
+
if (!this.runtimes.has(receiptId)) this.#holdRuntime(receiptId, receipt);
|
|
432
487
|
return receipt;
|
|
433
488
|
}
|
|
434
489
|
|
|
@@ -453,7 +508,15 @@ export class LiveSessions extends EventEmitter {
|
|
|
453
508
|
}
|
|
454
509
|
|
|
455
510
|
/** The session a client runs in, from its claim: its parent's current session, else its environment's id through the map. */
|
|
456
|
-
#sessionOfClaim({ pid, ppid, session }) {
|
|
511
|
+
async #sessionOfClaim({ pid, ppid, session, runtime }) {
|
|
512
|
+
// a runtime supercode hosts (the native agent) says so in its claim: it is named by its own process first, never by
|
|
513
|
+
// the session whose shell started it; one with no receipt is named by the rest of its claim, as any client is
|
|
514
|
+
if (runtime === true) {
|
|
515
|
+
// a pid several receipts name is no one runtime: the rest of the claim names it, never a refusal here
|
|
516
|
+
let own = null;
|
|
517
|
+
try { own = await this.#runtimeOfPid(Number(pid)); } catch (error) { if (!(error instanceof CallerRefused)) throw error; }
|
|
518
|
+
if (own) return own;
|
|
519
|
+
}
|
|
457
520
|
const parent = this.claude.get(Number(ppid));
|
|
458
521
|
if (parent && alive(parent.pid)) return this.#address('claude-code', parent.sessionId);
|
|
459
522
|
if (typeof session === 'string' && session) {
|
|
@@ -465,20 +528,30 @@ export class LiveSessions extends EventEmitter {
|
|
|
465
528
|
// only when exactly one live runtime's receipt names that process. A process several receipts name (harness serve
|
|
466
529
|
// records its own pid on each runtime it hosts with no child of its own) is no one runtime, and is refused.
|
|
467
530
|
for (const candidate of [Number(pid), Number(ppid)]) {
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
if (held.length === 1) return this.#address(held[0].harness, held[0].sessionId);
|
|
471
|
-
if (held.length > 1) throw new CallerRefused(`process ${candidate} hosts ${held.length} runtimes, so which one is calling is not known; nothing was done`);
|
|
531
|
+
const found = await this.#runtimeOfPid(candidate);
|
|
532
|
+
if (found) return found;
|
|
472
533
|
}
|
|
473
534
|
return null;
|
|
474
535
|
}
|
|
475
536
|
|
|
537
|
+
/** The runtime whose live receipt alone names process `pid`, or null; several receipts naming it are no one runtime, refused. */
|
|
538
|
+
async #runtimeOfPid(pid) {
|
|
539
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
540
|
+
const held = [...this.runtimes.values()].filter((runtime) => runtime.pid === pid && alive(runtime.pid));
|
|
541
|
+
if (held.length > 1) throw new CallerRefused(`process ${pid} hosts ${held.length} runtimes, so which one is calling is not known; nothing was done`);
|
|
542
|
+
if (held.length !== 1) return null;
|
|
543
|
+
// a caller's identity: checked again now, with no process (the runtime asked at its own address), as a runtime that
|
|
544
|
+
// ended may have left its pid to another process before the sweep lets its receipt go
|
|
545
|
+
if (!(await answersAs(held[0].probe))) return null;
|
|
546
|
+
return this.#address(held[0].harness, held[0].sessionId);
|
|
547
|
+
}
|
|
548
|
+
|
|
476
549
|
/**
|
|
477
550
|
* Who a local client is, from its claim `{pid, ppid, session, named}` (docs/architecture/overview.md, "Who is
|
|
478
551
|
* calling"): a session's address, a board's address, or null for a person. Throws CallerRefused when the claim names
|
|
479
552
|
* a caller the map does not hold.
|
|
480
553
|
*/
|
|
481
|
-
callerOf(claim) {
|
|
554
|
+
async callerOf(claim) {
|
|
482
555
|
const { pid, owner, named } = claim ?? {};
|
|
483
556
|
const board = typeof named === 'string' ? new RegExp(`^sc:${this.machine.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:board:([^:\\s]+)$`).exec(named) : null;
|
|
484
557
|
// a board's owner, by the pid it registered with: the client itself, or the owner that started it as the board
|
|
@@ -487,7 +560,7 @@ export class LiveSessions extends EventEmitter {
|
|
|
487
560
|
if (registered && [pid, Number(owner)].includes(registered) && alive(registered)) return named;
|
|
488
561
|
// A board's name from anything but its owner is not taken (a card's session, a pane run for the board): the caller
|
|
489
562
|
// is the session the claim resolves to, or a person, as before (a board's name stood only under its own process).
|
|
490
|
-
const session = this.#sessionOfClaim(claim ?? {});
|
|
563
|
+
const session = await this.#sessionOfClaim(claim ?? {});
|
|
491
564
|
if (session) return session;
|
|
492
565
|
if (board) return null;
|
|
493
566
|
// a verb a pane runs for the session that launched it names that session: accepted while it is live
|
package/machine.mjs
CHANGED
|
@@ -36,6 +36,7 @@ import { RecordHolder, RecordProducer, RECENT_PER_MAILBOX, RECORDS_NOT_GRANTED }
|
|
|
36
36
|
import { LiveSessions, ACTING_VARIABLES } from './live-sessions.mjs';
|
|
37
37
|
import { MailStore } from './mail/store.mjs';
|
|
38
38
|
import { MailReads, panePrompt } from './mail/reads.mjs';
|
|
39
|
+
import { render } from './mail/envelope.mjs';
|
|
39
40
|
import { Agents } from './mail/agents.mjs';
|
|
40
41
|
import { Relays } from './mail/relay.mjs';
|
|
41
42
|
import { MailSend } from './mail/send.mjs';
|
|
@@ -635,6 +636,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
635
636
|
homes: { get claudeProjects() { return [...live.claudeDirs.keys()].map((dir) => join(dirname(dir), 'projects')); }, mailRoot },
|
|
636
637
|
paneQuestion: async (pane) => { const session = await this.#paneByContext(pane); return session ? panePrompt(await this.host.capture(session.id, { lines: 60 })) : null; },
|
|
637
638
|
askMachine: (machine, request) => this.#askMailDoor(machine, request),
|
|
639
|
+
log: (line) => this.emit('mail-log', line),
|
|
638
640
|
});
|
|
639
641
|
// a send to a session, for every entry (3b-1): its routing on the live map, an agent's plan and its doors
|
|
640
642
|
const machine = machineName(this.env);
|
|
@@ -642,14 +644,26 @@ export class TeamsMachine extends MachineCore {
|
|
|
642
644
|
discover: (params) => this.#serveRequest('harness.v1.sessions.discover', params, 10_000),
|
|
643
645
|
recordedConfig: (locator) => this.#serveRequest('harness.v1.sessions.recorded_config', { locator }, 15_000) });
|
|
644
646
|
this.relays = new Relays({ mailRoot, machine, supercodeBin: this.supercodeBin, serve: (method, params, timeoutMs) => this.#serveRequest(method, params, timeoutMs),
|
|
645
|
-
runtimeOf: (harness, id) => live.runtimeReceipt(harness, id), runtimeNamed: (receiptId) => live.receiptNamed(receiptId)
|
|
647
|
+
runtimeOf: (harness, id) => live.runtimeReceipt(harness, id), runtimeNamed: (receiptId) => live.receiptNamed(receiptId),
|
|
648
|
+
log: (line) => this.emit('mail-log', line) });
|
|
646
649
|
this.mailSend = new MailSend({ machine, mailRoot, live, store: this.mailStore, agents: this.agents, relays: this.relays,
|
|
647
650
|
// a send waits on its receiver's door there (a relay's receipt takes up to 90 s), never past MAIL_SEND_MS
|
|
648
651
|
askMachine: (to, request) => this.#askMailDoor(to, request, MAIL_SEND_MS),
|
|
649
|
-
deliverRuntime: deliverToRuntime, codexAnswer: (request) => codexQuestions(request)
|
|
652
|
+
deliverRuntime: deliverToRuntime, codexAnswer: (request) => codexQuestions(request),
|
|
653
|
+
// a user's own turn typed into its pane, in process (as `panes.turn` types one)
|
|
654
|
+
typeTurn: async (pane, harness, text, messageIds) => {
|
|
655
|
+
const session = await this.#paneByContext(pane);
|
|
656
|
+
if (!session) throw new Error(`no pane with context ${pane}`);
|
|
657
|
+
const result = await this.#paneTurn(session, pane, text, { messageIds, harness });
|
|
658
|
+
if (result.refused) throw new Error(result.refused.reason);
|
|
659
|
+
return result.waits ?? result;
|
|
660
|
+
},
|
|
661
|
+
log: (line) => this.emit('mail-log', line) });
|
|
650
662
|
// an agent's verbs (3b-2): its threads, a delegation, its record
|
|
651
663
|
this.agentVerbs = new AgentVerbs({ machine, store: this.mailStore, agents: this.agents, live, supercodeBin: this.supercodeBin, env: this.env,
|
|
652
|
-
paneOf: (session) => this.mail.paneOf(session), mailRoot
|
|
664
|
+
paneOf: (session) => this.mail.paneOf(session), mailRoot,
|
|
665
|
+
// a delegation's pane that never became its holder is closed, as `panes.close` closes one
|
|
666
|
+
closePane: async (pane) => { const session = await this.#paneByContext(pane); if (session) { await this.host.closeSession(session.id); this.#panesChanged(); } } });
|
|
653
667
|
// the names sessions ran under (`<mail>/names.json`): a name an agent was given reaches the same session after it
|
|
654
668
|
// runs under another, kept from the map's changes (at most once a second)
|
|
655
669
|
let naming = null;
|
|
@@ -939,6 +953,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
939
953
|
this.mailWatch?.kill('SIGTERM');
|
|
940
954
|
this.stoppingServe = true;
|
|
941
955
|
clearTimeout(this.serveRestart);
|
|
956
|
+
// said, so a stop reads as one in the log and never as a serve that broke
|
|
957
|
+
if (this.serve) this.emit('serve-stderr', 'stopping harness serve: the daemon is stopping\n');
|
|
942
958
|
this.serve?.kill('SIGTERM');
|
|
943
959
|
this.host.dispose();
|
|
944
960
|
}
|
|
@@ -985,7 +1001,9 @@ export class TeamsMachine extends MachineCore {
|
|
|
985
1001
|
};
|
|
986
1002
|
// a write into a serve that has gone (EPIPE, ECONNRESET) is its pipe's error: the serve is over, the daemon is not
|
|
987
1003
|
child.stdin.on('error', (error) => {
|
|
988
|
-
|
|
1004
|
+
// a write that races a stop's SIGTERM is the stop's, already said
|
|
1005
|
+
if (this.stoppingServe) return;
|
|
1006
|
+
this.emit('serve-stderr', `harness serve's input: ${error.message} (pid ${child.pid}, up ${Math.round((Date.now() - started) / 1000)} s)\n`);
|
|
989
1007
|
try { child.kill('SIGTERM'); } catch { /* gone */ }
|
|
990
1008
|
// one that does not end on SIGTERM within 5 s is killed: a serve that no longer reads is never left behind
|
|
991
1009
|
const grace = setTimeout(() => { if (child.exitCode === null && child.signalCode === null) { try { child.kill('SIGKILL'); } catch { /* gone */ } } }, 5000);
|
|
@@ -997,7 +1015,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
997
1015
|
child.stderr.on('error', () => {});
|
|
998
1016
|
child.on('error', (error) => end(`could not run (${error.message})`));
|
|
999
1017
|
child.on('exit', (code, signal) => {
|
|
1000
|
-
this.emit('serve-exit', { code, signal });
|
|
1018
|
+
this.emit('serve-exit', { code, signal, pid: child.pid, up_s: Math.round((Date.now() - started) / 1000), stopping: this.stoppingServe === true });
|
|
1001
1019
|
end(`exited (${signal ?? `code ${code}`})`);
|
|
1002
1020
|
});
|
|
1003
1021
|
this.serve = child;
|
|
@@ -1260,7 +1278,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1260
1278
|
|
|
1261
1279
|
#noticeTarget(address){
|
|
1262
1280
|
return sessionNoticeTarget(address,{machine:machineName(this.env),
|
|
1263
|
-
// liveness is the live-session map's, read in-process (no process
|
|
1281
|
+
// liveness is the live-session map's, read in-process (no process)
|
|
1264
1282
|
mail:async()=>({code:0,sessions:this.live.sessions()}),
|
|
1265
1283
|
discover:params=>this.#serveRequest('harness.v1.sessions.discover',params,10000),
|
|
1266
1284
|
recordedConfig:locator=>this.#serveRequest('harness.v1.sessions.recorded_config',{locator},15000)});
|
|
@@ -1274,9 +1292,10 @@ export class TeamsMachine extends MachineCore {
|
|
|
1274
1292
|
if(status.state==='running')return;
|
|
1275
1293
|
if(!status.resumable)throw Object.assign(new Error(status.reason??'native session cannot resume'),{noticeCode:'session_resume_unavailable'});
|
|
1276
1294
|
const code=await new Promise((resolve,reject)=>{
|
|
1277
|
-
// Explicit native approval and sandbox flags suppress open's unattended defaults.
|
|
1278
|
-
|
|
1279
|
-
|
|
1295
|
+
// Explicit native approval and sandbox flags suppress open's unattended defaults. A resume made for a notice opens
|
|
1296
|
+
// no window on the machine's screen: `--window=false` is the launch's own choice, which the daemon honours.
|
|
1297
|
+
const child=spawn(this.supercodeBin,['open',address,'--detach','--window=false','--',...status.resume.args],
|
|
1298
|
+
{cwd:status.resume.cwd,env:this.env,stdio:'ignore'});
|
|
1280
1299
|
const timer=setTimeout(()=>{child.kill('SIGTERM');resolve(null);},15000);
|
|
1281
1300
|
child.once('error',error=>{clearTimeout(timer);reject(error);});child.once('close',code=>{clearTimeout(timer);resolve(code);});
|
|
1282
1301
|
});
|
|
@@ -1358,7 +1377,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1358
1377
|
})();
|
|
1359
1378
|
}
|
|
1360
1379
|
|
|
1361
|
-
/** A send through the mail door, for a session here
|
|
1380
|
+
/** A send through the mail door, for a session here: one for another machine is
|
|
1362
1381
|
* refused when it came through a link (never forwarded on this machine's own credential), and this machine's own
|
|
1363
1382
|
* daemon sends on through the one way out. */
|
|
1364
1383
|
async #sendHere(request, link) {
|
|
@@ -1371,7 +1390,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1371
1390
|
}, { link });
|
|
1372
1391
|
}
|
|
1373
1392
|
|
|
1374
|
-
/** A message another machine's mailbox carried here, filed in a mailbox here
|
|
1393
|
+
/** A message another machine's mailbox carried here, filed in a mailbox here: the
|
|
1375
1394
|
* sender is resolved here from the link it came through, never from the filer's own claim; a wake it carries is
|
|
1376
1395
|
* requested once, for a message new here. */
|
|
1377
1396
|
#fileHere(request, link) {
|
|
@@ -1502,8 +1521,9 @@ export class TeamsMachine extends MachineCore {
|
|
|
1502
1521
|
return refuse(`no interact grant for ${target}`);
|
|
1503
1522
|
}
|
|
1504
1523
|
accept();
|
|
1505
|
-
// The user-turn door every supercode has (
|
|
1506
|
-
// its pane's empty composer, or its mailbox until the composer is free. The sender is named after the
|
|
1524
|
+
// The user-turn door every supercode has (sessions.message as_user, this daemon's own: mail/send.mjs): the session's
|
|
1525
|
+
// runtime input, its pane's empty composer, or its mailbox until the composer is free. The sender is named after the
|
|
1526
|
+
// member.
|
|
1507
1527
|
const answer = (value) => { if (!stream.closed) { stream.write(JSON.stringify(value)); stream.close('delivered'); } };
|
|
1508
1528
|
// A keyed line is typed once: the server retries a line whose open outlived its bound, and the attempt it gave up
|
|
1509
1529
|
// on may still be accepted here. A repeat of a key answers what its first attempt answers.
|
|
@@ -1526,12 +1546,12 @@ export class TeamsMachine extends MachineCore {
|
|
|
1526
1546
|
: request.text;
|
|
1527
1547
|
const note = { harness, session, text, at: Date.now(), source: { via: 'room', from: { principalId: principal ?? null, name: entry.name ?? null } } };
|
|
1528
1548
|
try { this.onTurn?.(note); } catch { /* naming is best-effort; the turn goes either way */ }
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
text,
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1549
|
+
const confirmed = new Promise((resolve, reject) => {
|
|
1550
|
+
const timer = setTimeout(() => reject(new Error(`no answer in ${TURN_CONFIRM_MS} ms`)), TURN_CONFIRM_MS);
|
|
1551
|
+
this.mailSend.message({ locator: { harness, session_id: session }, text, from_name: entry.name ?? principal ?? 'a Room member', as_user: true })
|
|
1552
|
+
.then(resolve, reject).finally(() => clearTimeout(timer));
|
|
1553
|
+
});
|
|
1554
|
+
confirmed.then((result) => {
|
|
1535
1555
|
const delivered = result?.message_id && !result.refusal;
|
|
1536
1556
|
this.log.append({ principal, cap: 'interact', target, outcome: delivered ? 'turn' : 'failed', detail: result?.refusal?.message?.slice(0, 200) });
|
|
1537
1557
|
if (!delivered) try { this.onTurnWithdrawn?.(note); } catch { /* nothing to withdraw */ }
|
|
@@ -1707,8 +1727,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1707
1727
|
return Number((await run('ps', ['-o', 'ppid=', '-p', String(holder)])).trim()) === pid;
|
|
1708
1728
|
}
|
|
1709
1729
|
|
|
1710
|
-
/** A filing's sender as this machine knows it, with no process and no request (
|
|
1711
|
-
* mailbox.rs sender_identity): this filing door is local, so a sender from this machine's address is a session here
|
|
1730
|
+
/** A filing's sender as this machine knows it, with no process and no request (mailbox.rs `sender_identity`'s rule): this filing door is local, so a sender from this machine's address is a session here
|
|
1712
1731
|
* (`local`, the same user, D186), and one claiming another machine's address is unresolved: another machine's mail
|
|
1713
1732
|
* reaches this one only through its link, so a return address is never an identity. */
|
|
1714
1733
|
#observeSender(from) {
|
|
@@ -2122,6 +2141,41 @@ export class TeamsMachine extends MachineCore {
|
|
|
2122
2141
|
|
|
2123
2142
|
/** The program a pane runs, as the terminal package names kinds: what it was
|
|
2124
2143
|
* launched as, else what its foreground command is. */
|
|
2144
|
+
/**
|
|
2145
|
+
* A user's own turn typed into `pane` (its `session`) as its own submitted turn, only into an empty composer (read twice,
|
|
2146
|
+
* so a person who just started typing is not typed over): `panes.turn`, and the daemon's own user turns (mail/send.mjs
|
|
2147
|
+
* `userTurn`). Answers `#deliverPaneText`'s result; `{ refused }` for a pane running another harness than `harness`
|
|
2148
|
+
* names; `{ waits }` for a draft or a screen with no composer (the caller keeps the turn and asks again).
|
|
2149
|
+
*/
|
|
2150
|
+
async #paneTurn(session, pane, text, { messageIds, harness } = {}) {
|
|
2151
|
+
if (this.suspended.has(pane)) throw Object.assign(new Error(`pane ${pane} is suspended: remote input is refused until resume`), { code: 423 });
|
|
2152
|
+
const kind = this.#paneKind(pane, session);
|
|
2153
|
+
// Mail is typed only into its own harness's pane. A process inherits SUPERCODE_TEAMS_PANE from whatever ran it,
|
|
2154
|
+
// so a headless `codex exec` a Claude pane's tool started once took that pane for its own, and the Codex's mail
|
|
2155
|
+
// was typed into Claude's composer (m-72bc65eb). The addressed harness names the program the text is for. Only a
|
|
2156
|
+
// pane known to run another harness is refused: an unrecorded pane's kind is its command's name, which a native
|
|
2157
|
+
// Claude install shows as its version.
|
|
2158
|
+
const addressed = harness == null ? null : String(harness).trim().toLowerCase().replace(/^claude$/u, 'claude-code');
|
|
2159
|
+
if (addressed && addressed !== kind && ['claude-code', 'codex', 'grok', 'gemini', 'goose', 'opencode', 'pi', 'hermes', 'openclaw'].includes(kind)) {
|
|
2160
|
+
return { refused: { delivered: false, reason: `harness-mismatch: mail for ${addressed} is not typed into a ${kind || 'unknown'} pane` } };
|
|
2161
|
+
}
|
|
2162
|
+
if (kind === 'claude-code') {
|
|
2163
|
+
// A usage-limit wait would queue this turn until the limit's reset: esc ends the wait first. A limit that
|
|
2164
|
+
// still holds refuses the turn at once and the session waits again, for the next turn to try.
|
|
2165
|
+
const queued = [...this.terminalMailboxes.values()].some(queue => queue.session?.id === session.id && (queue.state.active || queue.state.pending.length));
|
|
2166
|
+
if (!this.inputInFlight.has(session.id) && !queued && claudeUsageLimitWait(await this.host.capture(session.id, { lines: 60 }))) {
|
|
2167
|
+
await this.host.sendInput(session.id, '\u001b', { submitMode: 'single', deadlineMs: Date.now() + 5_000 });
|
|
2168
|
+
await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
|
|
2169
|
+
}
|
|
2170
|
+
const draftOf = async () => composerHoldsDraft(kind, await this.host.capture(session.id, { lines: 200, preserveEscapes: true }));
|
|
2171
|
+
const first = await draftOf();
|
|
2172
|
+
await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
|
|
2173
|
+
const second = await draftOf();
|
|
2174
|
+
if (first !== false || second !== false) return { waits: { delivered: false, reason: first === true || second === true ? 'draft' : 'no-composer' } };
|
|
2175
|
+
}
|
|
2176
|
+
return this.#deliverPaneText(session, pane, text, { deadlineMs: Date.now() + 10_000, messageIds });
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2125
2179
|
#paneKind(contextKey, session) {
|
|
2126
2180
|
const launched = this.launches.all().find((row) => row.pane === contextKey && row.kind)?.kind;
|
|
2127
2181
|
const named = String(launched ?? '').trim().toLowerCase();
|
|
@@ -3119,34 +3173,9 @@ export class TeamsMachine extends MachineCore {
|
|
|
3119
3173
|
if (sessionTurn?.refused) { act('control', `pane:${params.pane}`, 'refused', JSON.stringify(sessionTurn.refused)); return sessionTurn.refused; }
|
|
3120
3174
|
const session = await this.#paneByContext(params.pane);
|
|
3121
3175
|
if (!session) throw Object.assign(new Error(`no pane with context ${params.pane}`), { code: 404 });
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
// so a headless `codex exec` a Claude pane's tool started once took that pane for its own, and the Codex's mail
|
|
3126
|
-
// was typed into Claude's composer (m-72bc65eb). The addressed harness names the program the text is for. Only a
|
|
3127
|
-
// pane known to run another harness is refused: an unrecorded pane's kind is its command's name, which a native
|
|
3128
|
-
// Claude install shows as its version.
|
|
3129
|
-
const addressed = params.harness == null ? null : String(params.harness).trim().toLowerCase().replace(/^claude$/u, 'claude-code');
|
|
3130
|
-
if (addressed && addressed !== kind && ['claude-code', 'codex', 'grok', 'gemini', 'goose', 'opencode', 'pi', 'hermes', 'openclaw'].includes(kind)) {
|
|
3131
|
-
const refused = { delivered: false, reason: `harness-mismatch: mail for ${addressed} is not typed into a ${kind || 'unknown'} pane` };
|
|
3132
|
-
act('control', `pane:${params.pane}`, 'refused', JSON.stringify(refused));
|
|
3133
|
-
return refused;
|
|
3134
|
-
}
|
|
3135
|
-
if (kind === 'claude-code') {
|
|
3136
|
-
// A usage-limit wait would queue this turn until the limit's reset: esc ends the wait first. A limit that
|
|
3137
|
-
// still holds refuses the turn at once and the session waits again, for the next turn to try.
|
|
3138
|
-
const queued = [...this.terminalMailboxes.values()].some(queue => queue.session?.id === session.id && (queue.state.active || queue.state.pending.length));
|
|
3139
|
-
if (!this.inputInFlight.has(session.id) && !queued && claudeUsageLimitWait(await this.host.capture(session.id, { lines: 60 }))) {
|
|
3140
|
-
await this.host.sendInput(session.id, '\u001b', { submitMode: 'single', deadlineMs: Date.now() + 5_000 });
|
|
3141
|
-
await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
|
|
3142
|
-
}
|
|
3143
|
-
const draftOf = async () => composerHoldsDraft(kind, await this.host.capture(session.id, { lines: 200, preserveEscapes: true }));
|
|
3144
|
-
const first = await draftOf();
|
|
3145
|
-
await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
|
|
3146
|
-
const second = await draftOf();
|
|
3147
|
-
if (first !== false || second !== false) return { delivered: false, reason: first === true || second === true ? 'draft' : 'no-composer' };
|
|
3148
|
-
}
|
|
3149
|
-
let result = await this.#deliverPaneText(session, params.pane, params.text, { deadlineMs: Date.now() + 10_000, messageIds: params.message_ids });
|
|
3176
|
+
let result = await this.#paneTurn(session, params.pane, params.text, { messageIds: params.message_ids, harness: params.harness });
|
|
3177
|
+
if (result.refused) { act('control', `pane:${params.pane}`, 'refused', JSON.stringify(result.refused)); return result.refused; }
|
|
3178
|
+
if (result.waits) return result.waits;
|
|
3150
3179
|
if ((result.delivered || result.queued) && !params.message_ids?.length && !turnBy) { const unrecorded = await this.#recordInput(entry, params.pane, session, params.text); if (unrecorded) result = { ...result, unrecorded }; }
|
|
3151
3180
|
act('control', `pane:${params.pane}`, result.delivered ? 'input' : result.queued ? 'input-pending' : 'failed', JSON.stringify(result));
|
|
3152
3181
|
return result;
|
|
@@ -3318,7 +3347,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
3318
3347
|
// 3a): this machine's own processes only, answered here.
|
|
3319
3348
|
case 'harness.v1.mail.list': case 'harness.v1.mail.show': case 'harness.v1.mail.inbox': case 'harness.v1.mail.acknowledge': {
|
|
3320
3349
|
if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('mail is read here by this machine\'s own processes'), { code: 403 });
|
|
3321
|
-
|
|
3350
|
+
// `local`: this machine's sessions only (`message mcp`'s list_agents, as the native one listed them)
|
|
3351
|
+
if (method === 'harness.v1.mail.list') return { rows: params.local === true ? await this.mail.localRows() : await this.mail.list() };
|
|
3322
3352
|
if (method === 'harness.v1.mail.show') {
|
|
3323
3353
|
const ids = (Array.isArray(params.ids) ? params.ids : []).filter((id) => typeof id === 'string');
|
|
3324
3354
|
return params.replies ? this.mail.showReplies(ids) : await this.mail.show(ids);
|
|
@@ -3350,7 +3380,9 @@ export class TeamsMachine extends MachineCore {
|
|
|
3350
3380
|
// acknowledged once it has printed it (harness.v1.mail.acknowledge)
|
|
3351
3381
|
const caller = await this.#callerOf(entry, params);
|
|
3352
3382
|
if (typeof caller !== 'string' || /^sc:[^:]+:board:/.test(caller)) return { code: 2, text: "Can't tell which session is running this command, so replies would have nowhere to go. Nothing was sent. Run it from your agent session's own shell tool." };
|
|
3353
|
-
|
|
3383
|
+
// `via`: the door the claims record (`inbox`, or `read_messages` for `message mcp`'s tool)
|
|
3384
|
+
const via = params.via === 'read_messages' ? 'read_messages' : 'inbox';
|
|
3385
|
+
const read = await this.mail.inbox(caller, { all: params.all === true, last, pid: Number(params.claim?.pid) || process.pid, via });
|
|
3354
3386
|
let token = null;
|
|
3355
3387
|
if (read.claimed.length) {
|
|
3356
3388
|
token = randomBytes(12).toString('hex');
|
|
@@ -3360,7 +3392,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
3360
3392
|
for (const [old] of [...this.inboxClaims].slice(0, Math.max(0, this.inboxClaims.size - 255))) this.inboxClaims.delete(old);
|
|
3361
3393
|
this.inboxClaims.set(token, { address: caller, claimed: read.claimed });
|
|
3362
3394
|
}
|
|
3363
|
-
|
|
3395
|
+
// `plain`: each message as its reader is shown it (the envelope rendered), with no listing's line about when
|
|
3396
|
+
return { code: 0, address: caller, rendered: read.rendered, plain: read.stored.map((item) => render(item.envelope)), rows: read.rows, token };
|
|
3364
3397
|
}
|
|
3365
3398
|
// Who a local client is, from its claim (live-sessions.mjs callerOf): what a command acting on another machine sends
|
|
3366
3399
|
// there as its caller, resolved here, where the map that holds it is. Local only; never the app door's.
|
package/mail/agents.mjs
CHANGED
|
@@ -12,6 +12,8 @@ import { cameThroughLink, parseAddress, threadOf } from './compose.mjs';
|
|
|
12
12
|
|
|
13
13
|
/** The pseudo-harness of an agent address. */
|
|
14
14
|
export const AGENT_HARNESS = 'agent';
|
|
15
|
+
/** How recent a folder time is not trusted to say its folder is unchanged (a coarse clock's tick, a 1 s filesystem). */
|
|
16
|
+
const RACY_MS = 2000;
|
|
15
17
|
/** How long a send waits for a resumed session to be reachable again. */
|
|
16
18
|
export const RESUME_WAIT_MS = 45_000;
|
|
17
19
|
|
|
@@ -63,34 +65,64 @@ export class Agents {
|
|
|
63
65
|
thread(id) { return validName(id) ? readJson(join(this.threadsDir, `${id}.json`)) : null; }
|
|
64
66
|
|
|
65
67
|
/** Every thread, oldest first. */
|
|
66
|
-
threads() {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
threads() { return this.#records().list.map((thread) => structuredClone(thread)); }
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The thread records, read once and kept while their folder is unchanged, with an index of them by holder and by
|
|
72
|
+
* channel marker. Every writer (this daemon, and mail_agent.rs's in the native watch until step 4) renames a whole
|
|
73
|
+
* record into the folder, which moves its time: one stat a call, never a read of every record, though a send's plan
|
|
74
|
+
* asks on every send and an agent's threads grow with its history. A folder time within RACY_MS of now is not trusted
|
|
75
|
+
* (a write in the same tick of a coarse clock leaves it unmoved): the folder is listed again, each record checked by
|
|
76
|
+
* its inode, until its time is older.
|
|
77
|
+
*/
|
|
78
|
+
#records() {
|
|
70
79
|
let changed;
|
|
71
|
-
try { changed = statSync(this.threadsDir).mtimeMs; } catch { return []; }
|
|
72
|
-
|
|
80
|
+
try { changed = statSync(this.threadsDir).mtimeMs; } catch { return { list: [], byHolder: new Map(), byMarker: new Map() }; }
|
|
81
|
+
const cache = this.threadsCache;
|
|
82
|
+
if (cache?.changed === changed && Date.now() - changed > RACY_MS) return cache;
|
|
83
|
+
// The folder changed, or changed too recently for its time to be trusted: its names are listed and each record's
|
|
84
|
+
// file is stat'ed, and only a record whose inode moved (every writer renames a whole new file into place) is read
|
|
85
|
+
// again. A burst of writes costs a listing and a stat a record, not a read of every record.
|
|
73
86
|
let names = [];
|
|
74
|
-
try { names = readdirSync(this.threadsDir); } catch { return []; }
|
|
75
|
-
const
|
|
87
|
+
try { names = readdirSync(this.threadsDir); } catch { return { list: [], byHolder: new Map(), byMarker: new Map() }; }
|
|
88
|
+
const files = new Map();
|
|
89
|
+
for (const name of names) {
|
|
90
|
+
if (!name.endsWith('.json')) continue;
|
|
91
|
+
// its inode, time and size together (an inode can be used again once freed): a rename moves at least one
|
|
92
|
+
let stamp;
|
|
93
|
+
try { const stat = statSync(join(this.threadsDir, name)); stamp = `${stat.ino}:${stat.mtimeMs}:${stat.size}`; } catch { continue; }
|
|
94
|
+
const known = cache?.files?.get(name);
|
|
95
|
+
const thread = known && known.stamp === stamp ? known.thread : readJson(join(this.threadsDir, name));
|
|
96
|
+
if (thread?.id) files.set(name, { stamp, thread });
|
|
97
|
+
}
|
|
98
|
+
const list = [...files.values()].map((file) => file.thread)
|
|
76
99
|
.sort((a, b) => (a.created_at_ms - b.created_at_ms) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
77
|
-
|
|
78
|
-
|
|
100
|
+
const byHolder = new Map(), byMarker = new Map();
|
|
101
|
+
for (const thread of list) {
|
|
102
|
+
byHolder.set(thread.holder, [...(byHolder.get(thread.holder) ?? []), thread]);
|
|
103
|
+
for (const [marker, id] of Object.entries(thread.markers ?? {})) if (!byMarker.has(marker)) byMarker.set(marker, { thread, id });
|
|
104
|
+
}
|
|
105
|
+
const records = { changed, list, byHolder, byMarker, files };
|
|
106
|
+
// kept either way (its records are each current by inode); trusted without a listing only once its time is old
|
|
107
|
+
this.threadsCache = records;
|
|
108
|
+
return records;
|
|
79
109
|
}
|
|
80
110
|
|
|
111
|
+
|
|
81
112
|
/** The thread `address` holds for an agent other than as its main session (mail_agent.rs `thread_held_by`). */
|
|
82
113
|
heldBy(address) {
|
|
83
|
-
|
|
84
|
-
if (thread.
|
|
114
|
+
const held = [...(this.#records().byHolder.get(address) ?? [])].reverse().find((thread) => {
|
|
115
|
+
if (thread.parent != null) return false;
|
|
85
116
|
const agent = this.load(thread.agent);
|
|
86
117
|
return agent != null && agent.main_session !== address;
|
|
87
|
-
})
|
|
118
|
+
});
|
|
119
|
+
return held ? structuredClone(held) : null;
|
|
88
120
|
}
|
|
89
121
|
|
|
90
122
|
/** The thread whose channel message has the platform id `marker`, with that message's id. */
|
|
91
123
|
ofMarker(marker) {
|
|
92
|
-
|
|
93
|
-
return null;
|
|
124
|
+
const found = this.#records().byMarker.get(marker);
|
|
125
|
+
return found ? { thread: structuredClone(found.thread), id: found.id } : null;
|
|
94
126
|
}
|
|
95
127
|
|
|
96
128
|
/** The agent `address` is a session of: its main, or a thread's holder. */
|
|
@@ -163,7 +195,7 @@ export class Agents {
|
|
|
163
195
|
}
|
|
164
196
|
|
|
165
197
|
/** The threads of the agent `name`, oldest first. */
|
|
166
|
-
threadsOf(name) { return this
|
|
198
|
+
threadsOf(name) { return this.#records().list.filter((thread) => thread.agent === name).map((thread) => structuredClone(thread)); }
|
|
167
199
|
|
|
168
200
|
/**
|
|
169
201
|
* A line that reached an agent's main session through its own input (its terminal, its DM) is a root of the agent:
|
|
@@ -310,8 +342,8 @@ export class Agents {
|
|
|
310
342
|
return (await this.#resumeArguments(address)) != null;
|
|
311
343
|
}
|
|
312
344
|
|
|
313
|
-
/** Resume the stopped session at `address` in a daemon pane, by `supercode open <session> --detach --
|
|
314
|
-
* mode>`: never open's unattended defaults (mail_agent.rs `resume`). */
|
|
345
|
+
/** Resume the stopped session at `address` in a daemon pane, by `supercode open <session> --detach --window=false --
|
|
346
|
+
* <its recorded mode>`: never open's unattended defaults (mail_agent.rs `resume`), and no window on the screen. */
|
|
315
347
|
async resume(address) {
|
|
316
348
|
const recorded = await this.#resumeArguments(address);
|
|
317
349
|
if (!recorded) throw new Error(`${address} has no recorded permissions to resume with; its mail waits`);
|
|
@@ -324,7 +356,8 @@ export class Agents {
|
|
|
324
356
|
}
|
|
325
357
|
writeAtomically(join(this.agentsDir, 'resumed', blake3Hex(address).slice(0, 24)), String(Date.now()));
|
|
326
358
|
const { code, stderr } = await new Promise((resolve) => {
|
|
327
|
-
|
|
359
|
+
// a holder resumed by mail runs in the background: no window opens on the machine's screen for it
|
|
360
|
+
const child = spawn(this.supercodeBin, ['open', parsed.session, '--detach', '--window=false', '--', ...recorded], { stdio: ['ignore', 'ignore', 'pipe'], env: this.env });
|
|
328
361
|
let err = '';
|
|
329
362
|
child.stderr.on('data', (chunk) => { err += chunk; });
|
|
330
363
|
child.on('error', (error) => resolve({ code: 1, stderr: error.message }));
|
package/mail/reads.mjs
CHANGED
|
@@ -116,8 +116,8 @@ function pendingFromTranscript(path) {
|
|
|
116
116
|
export class MailReads {
|
|
117
117
|
/** `records()` answers `{ records, unread, self }`: the records this daemon holds (machine-records.mjs `read`), why
|
|
118
118
|
* it holds none when it follows no machine, and this machine's name. */
|
|
119
|
-
constructor({ machine, live, store, records, descriptors, homes, paneQuestion = async () => null, askMachine }) {
|
|
120
|
-
Object.assign(this, { machine, live, store, records, descriptors, homes, paneQuestion, askMachine });
|
|
119
|
+
constructor({ machine, live, store, records, descriptors, homes, paneQuestion = async () => null, askMachine, log = () => {} }) {
|
|
120
|
+
Object.assign(this, { machine, live, store, records, descriptors, homes, paneQuestion, askMachine, log });
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
// ---- list ------------------------------------------------------------------------------------------------------
|
|
@@ -248,9 +248,12 @@ export class MailReads {
|
|
|
248
248
|
* `{ rows, code }`.
|
|
249
249
|
*/
|
|
250
250
|
async show(ids) {
|
|
251
|
+
const began = performance.now();
|
|
251
252
|
const rows = await this.showRows(ids);
|
|
253
|
+
const here = performance.now();
|
|
252
254
|
const wanted = rows.filter((row) => row.found === false && !row.ambiguous).map((row) => row.id);
|
|
253
255
|
const unsearched = [];
|
|
256
|
+
const timing = {};
|
|
254
257
|
if (wanted.length) {
|
|
255
258
|
const held = heldRecords(this.records());
|
|
256
259
|
const holds = (record, id) => Object.values(record?.mail ?? {}).some((box) => ['unread', 'recent'].some((part) => (Array.isArray(box?.[part]) ? box[part] : []).some((header) => typeof header?.id === 'string' && (header.id === id || header.id.startsWith(id)))));
|
|
@@ -267,6 +270,7 @@ export class MailReads {
|
|
|
267
270
|
if (short.length) shorts.push([entry.machine, short]);
|
|
268
271
|
}
|
|
269
272
|
if (wanted.some((id) => id.length < FULL_ID_LEN || !placed.has(id))) for (const [machine, short] of shorts) unsearched.push([machine, `its mail window may be incomplete: ${short.join('; ')}`]);
|
|
273
|
+
timing.records = performance.now();
|
|
270
274
|
const answers = await Promise.all([...asks].map(async ([machine, asked]) => {
|
|
271
275
|
try { return [machine, await this.askMachine(machine, { op: 'show', ids: asked })]; } catch (error) { return [machine, error]; }
|
|
272
276
|
}));
|
|
@@ -285,6 +289,9 @@ export class MailReads {
|
|
|
285
289
|
unsearched.push(['*', `${older.join(', ')} ${older.length === 1 ? 'is' : 'are'} in no pushed mail window (each machine's record holds its unread mail and the last ${RECENT_PER_MAILBOX} messages of each mailbox): an older message is shown by \`supercode message show\` on the machine that holds it${followed.length ? ` (${followed.join(', ')})` : ''}`]);
|
|
286
290
|
}
|
|
287
291
|
}
|
|
292
|
+
// a slow show says where its time went (this machine's mailboxes, the held records, the machines it asked)
|
|
293
|
+
const total = performance.now() - began;
|
|
294
|
+
if (total > 1000) this.log(`show of ${ids.join(' ')} took ${Math.round(total)} ms: here ${Math.round(here - began)} ms${timing.records ? `, held records ${Math.round(timing.records - here)} ms, other machines ${Math.round(performance.now() - timing.records)} ms` : ''}`);
|
|
288
295
|
const missing = rows.some((row) => row.found !== true);
|
|
289
296
|
const notSearched = unsearched.map(([machine, why]) => (machine === '*' ? why : `${machine}: ${why}`));
|
|
290
297
|
if (notSearched.length) for (const row of rows) if (row.found === false && !row.ambiguous) { row.found = null; row.unsearched = notSearched; }
|
|
@@ -314,8 +321,8 @@ export class MailReads {
|
|
|
314
321
|
* mail with when each reached the model, from its transcript; the newest `last` of the read only (`inbox`). Answers
|
|
315
322
|
* `{ stored, deliveries, claimed }`; the caller acknowledges each claimed one once it is printed (`acknowledge`).
|
|
316
323
|
*/
|
|
317
|
-
async inbox(address, { all = false, last = null, pid = process.pid } = {}) {
|
|
318
|
-
const claimed = this.store.claimUnread(address,
|
|
324
|
+
async inbox(address, { all = false, last = null, pid = process.pid, via = 'inbox' } = {}) {
|
|
325
|
+
const claimed = this.store.claimUnread(address, via, address, pid).map((stored) => this.#whole(stored) ?? stored);
|
|
319
326
|
const transcript = all ? await readTranscript(address, this.homes) : null;
|
|
320
327
|
let stored;
|
|
321
328
|
if (all) {
|
package/mail/relay.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// doors, run by the relay's Claude (docs/architecture/overview.md, "Pinned native doors"). Its folder is the native
|
|
8
8
|
// sender's own (`<mail>/relays/<blake3(name)[..24]>/`, its record, settings and send log), so the native `message
|
|
9
9
|
// watch`, which retires relays until step 4, reads what the daemon writes.
|
|
10
|
-
import { mkdirSync, readFileSync, rmSync,
|
|
10
|
+
import { mkdirSync, readFileSync, rmSync, watch, writeFileSync } from 'node:fs';
|
|
11
11
|
import { connect } from 'node:net';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
import { blake3Hex } from './envelope.mjs';
|
|
@@ -18,8 +18,13 @@ const RELAY_MODEL = 'haiku';
|
|
|
18
18
|
export const RELAY_NAME_PREFIX = 'sc-';
|
|
19
19
|
/** How long a send waits for Claude's own receipt. */
|
|
20
20
|
const SEND_TIMEOUT_MS = 90_000;
|
|
21
|
+
/** A relay send slower than this says where its time went. */
|
|
22
|
+
const SLOW_SEND_MS = 2000;
|
|
21
23
|
/** Largest message relayed into a Claude session: the relay copies it into a model turn byte for byte. */
|
|
22
24
|
export const MAX_RELAYED_BYTES = 100_000;
|
|
25
|
+
/** How many relays run at once, the most recently used (a relay runtime is some hundreds of MB; 10 held 2.1 GB on one
|
|
26
|
+
* machine): beyond them a relay's runtime is closed (its folder kept: it starts again when next needed). */
|
|
27
|
+
export const RELAYS_RUNNING = 4;
|
|
23
28
|
|
|
24
29
|
const alive = (pid) => { if (!Number.isInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
|
|
25
30
|
const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
|
|
@@ -56,9 +61,23 @@ export class Relays {
|
|
|
56
61
|
* id)` the live-runtime receipt the live-session map holds for a runtime id (its file read whole), or null;
|
|
57
62
|
* `runtimeNamed(receiptId)` one receipt read by its own name.
|
|
58
63
|
*/
|
|
59
|
-
constructor({ mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed = () => null }) {
|
|
60
|
-
Object.assign(this, { mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed });
|
|
64
|
+
constructor({ mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed = () => null, log = () => {} }) {
|
|
65
|
+
Object.assign(this, { mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed, log });
|
|
61
66
|
this.sending = new Map(); // relay folder → the send in progress through it (one at a time per relay)
|
|
67
|
+
this.running = new Map(); // relay folder → { connection, runtime_id, usedAt }: the relays this daemon runs
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The relays beyond the RELAYS_RUNNING most recently used have their runtimes closed, none carrying a send: this
|
|
71
|
+
* daemon starts every relay and holds every relay's send lock, so it alone closes one (a closer that did not hold the
|
|
72
|
+
* lock closed relays mid-send, and every send after paid a relay's start). */
|
|
73
|
+
#cap() {
|
|
74
|
+
const kept = [...this.running].sort(([, a], [, b]) => b.usedAt - a.usedAt);
|
|
75
|
+
for (const [directory, relay] of kept.slice(RELAYS_RUNNING)) {
|
|
76
|
+
if (this.sending.has(directory)) continue;
|
|
77
|
+
this.running.delete(directory);
|
|
78
|
+
void this.serve('harness.v1.runtimes.close', { connection: relay.connection, runtime_id: relay.runtime_id }, 15_000)
|
|
79
|
+
.catch((error) => this.log(`relay ${directory.split(/[\\/]/).pop()} beyond the ${RELAYS_RUNNING} most recent was not closed: ${error.message}`));
|
|
80
|
+
}
|
|
62
81
|
}
|
|
63
82
|
|
|
64
83
|
/** The relay that speaks for `represented`, known as `representedName`: its name and its files. */
|
|
@@ -187,21 +206,24 @@ export class Relays {
|
|
|
187
206
|
this.sending.set(spec.directory, turn);
|
|
188
207
|
await previous;
|
|
189
208
|
try {
|
|
190
|
-
// its record is the relay's last use: the native sweep that keeps the most recently used relays running reads its
|
|
191
|
-
// time, and a relay carrying a send is the most recent
|
|
192
|
-
try { const now = new Date(); utimesSync(spec.record, now, now); } catch { /* none yet */ }
|
|
193
209
|
const queued = { to: receiver.socket ? `uds:${receiver.socket}` : receiver.name, message };
|
|
210
|
+
const steps = [], began = performance.now();
|
|
211
|
+
let mark = began;
|
|
212
|
+
const step = (name) => { const now = performance.now(); steps.push(`${name} ${Math.round(now - mark)} ms`); mark = now; };
|
|
213
|
+
step('lock');
|
|
194
214
|
let { runtime, connection } = await this.#ensure(spec);
|
|
215
|
+
step('runtime (started or reused)');
|
|
216
|
+
this.#used(spec, connection);
|
|
195
217
|
rmSync(spec.receipt, { force: true });
|
|
196
218
|
writeFileSync(spec.queue, JSON.stringify(queued));
|
|
197
219
|
let receipt;
|
|
198
220
|
for (let attempt = 0; ; attempt++) {
|
|
199
|
-
try { await this.serve('harness.v1.runtimes.send_input', { connection, text: sendTurn(queued) }, 30_000); receipt = await this.#receipt(spec, this.#pid(spec, runtime)); }
|
|
221
|
+
try { await this.serve('harness.v1.runtimes.send_input', { connection, text: sendTurn(queued) }, 30_000); step('turn handed in (send_input)'); receipt = await this.#receipt(spec, this.#pid(spec, runtime)); step('Claude\'s receipt'); }
|
|
200
222
|
catch (error) { receipt = { delivered: false, detail: `could not reach the Claude relay: ${error.message}` }; }
|
|
201
223
|
const pid = this.#pid(spec, runtime);
|
|
202
224
|
// only a relay proven dead is started again; one alive but slow may still deliver
|
|
203
225
|
if (receipt.delivered || attempt === 1 || pid == null || alive(pid)) break;
|
|
204
|
-
try { ({ runtime, connection } = await this.#ensure(spec)); } catch (error) { receipt = { delivered: false, detail: error.message }; break; }
|
|
226
|
+
try { ({ runtime, connection } = await this.#ensure(spec)); this.#used(spec, connection); } catch (error) { receipt = { delivered: false, detail: error.message }; break; }
|
|
205
227
|
rmSync(spec.receipt, { force: true });
|
|
206
228
|
}
|
|
207
229
|
rmSync(spec.queue, { force: true });
|
|
@@ -210,6 +232,8 @@ export class Relays {
|
|
|
210
232
|
sent[receiver.sessionId] = messageId;
|
|
211
233
|
writeFileSync(spec.sent, JSON.stringify(sent));
|
|
212
234
|
}
|
|
235
|
+
// a slow relay send says where its time went, in the daemon's log (what a person reads of the connector)
|
|
236
|
+
if (performance.now() - began > SLOW_SEND_MS) this.log(`relay send to ${receiver.name || receiver.sessionId} took ${Math.round(performance.now() - began)} ms: ${steps.join(', ')}`);
|
|
213
237
|
return receipt;
|
|
214
238
|
} catch (error) {
|
|
215
239
|
rmSync(spec.queue, { force: true });
|
|
@@ -217,8 +241,15 @@ export class Relays {
|
|
|
217
241
|
} finally {
|
|
218
242
|
release();
|
|
219
243
|
if (this.sending.get(spec.directory) === turn) this.sending.delete(spec.directory);
|
|
244
|
+
this.#cap();
|
|
220
245
|
}
|
|
221
246
|
}
|
|
247
|
+
|
|
248
|
+
/** A relay this daemon runs, used now. */
|
|
249
|
+
#used(spec, connection) {
|
|
250
|
+
const runtimeId = readJson(spec.record)?.runtime_id;
|
|
251
|
+
if (typeof runtimeId === 'string') this.running.set(spec.directory, { connection, runtime_id: runtimeId, usedAt: Date.now() });
|
|
252
|
+
}
|
|
222
253
|
}
|
|
223
254
|
|
|
224
255
|
/** Whether a local port answers a connection within 300 ms. */
|
package/mail/send.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { join } from 'node:path';
|
|
|
9
9
|
import { blake3Hex, render, shortId } from './envelope.mjs';
|
|
10
10
|
import { AGENT_HARNESS, RESUME_WAIT_MS } from './agents.mjs';
|
|
11
11
|
import { MAX_RELAYED_BYTES, RELAY_NAME_PREFIX } from './relay.mjs';
|
|
12
|
-
import { FULL_ID_LEN, cameThroughLink, envelope as makeEnvelope, makeAddress, messageIdFor, normalMachineName, parseAddress, senderIdentity, subjectLine, threadOf } from './compose.mjs';
|
|
12
|
+
import { FULL_ID_LEN, cameThroughLink, envelope as makeEnvelope, makeAddress, messageIdFor, newMessageId, normalMachineName, parseAddress, senderIdentity, subjectLine, threadOf } from './compose.mjs';
|
|
13
13
|
|
|
14
14
|
export const EXIT_REFUSED = 2;
|
|
15
15
|
export const EXIT_UNKNOWN = 3;
|
|
@@ -32,6 +32,8 @@ const NAME_SEEN_EVERY_MS = 60 * 60 * 1000;
|
|
|
32
32
|
|
|
33
33
|
const outcome = (code, text, receipt = null) => ({ code, text, receipt });
|
|
34
34
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
35
|
+
/** The daemon pane a live session runs in, or null (mail_route.rs `daemon_pane`). */
|
|
36
|
+
const paneOf = (session) => { const pane = typeof session.pane === 'string' ? session.pane.split(':')[0] : null; return pane && /^p_[A-Za-z0-9_-]+$/.test(pane) ? pane : null; };
|
|
35
37
|
const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
|
|
36
38
|
|
|
37
39
|
/** A delivery a door refused before anything was sent. */
|
|
@@ -42,10 +44,11 @@ export class MailSend {
|
|
|
42
44
|
* `live` the live-session map; `store` the mail store; `agents` an agent's records and plan; `relays` the Claude relay
|
|
43
45
|
* sender; `askMachine(machine, request)` one request to another machine's mail door; `deliverRuntime(receipt, text,
|
|
44
46
|
* wake)` one hosted runtime's input ('steered', 'started' or 'not_woken'); `codexAnswer(request)` a native Codex
|
|
45
|
-
* question's answer through its app-server
|
|
47
|
+
* question's answer through its app-server; `typeTurn(pane, harness, text, ids)` a user's own turn typed into a daemon
|
|
48
|
+
* pane's empty composer (machine.mjs `#paneTurn`: `{ delivered }`, `{ queued }`, or neither when it waits).
|
|
46
49
|
*/
|
|
47
|
-
constructor({ machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, log = () => {} }) {
|
|
48
|
-
Object.assign(this, { machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, log });
|
|
50
|
+
constructor({ machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, typeTurn, log = () => {} }) {
|
|
51
|
+
Object.assign(this, { machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, typeTurn, log });
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
// ---- one send ----------------------------------------------------------------------------------------------------
|
|
@@ -424,9 +427,10 @@ export class MailSend {
|
|
|
424
427
|
// ---- harness serve's sessions.message ----------------------------------------------------------------------------
|
|
425
428
|
|
|
426
429
|
/**
|
|
427
|
-
* `harness.v1.sessions.message`
|
|
428
|
-
*
|
|
429
|
-
* an error: `{ delivered_to_bus: false, refusal: { reason, message } }`.
|
|
430
|
+
* `harness.v1.sessions.message` (harness_service.rs `message_live_session`): `text` from an operator mailbox
|
|
431
|
+
* (`from_name`, `supercode` by default) into the session or agent `locator` names; with `as_user`, as that session's
|
|
432
|
+
* own user turn. A refusal is a result, not an error: `{ delivered_to_bus: false, refusal: { reason, message } }`.
|
|
433
|
+
* harness serve adds its own inbound controls.
|
|
430
434
|
*/
|
|
431
435
|
async message(params) {
|
|
432
436
|
const refused = (reason, message) => ({ delivered_to_bus: false, refusal: { reason, message } });
|
|
@@ -448,6 +452,8 @@ export class MailSend {
|
|
|
448
452
|
const short = target.name.split('@')[0];
|
|
449
453
|
if (sessions.filter((row) => row.door === 'native' && row.name.split('@')[0] === short).length !== 1) return refused('identity_mismatch', `the registry name \`${short}\` no longer resolves to session \`${sessionId}\` alone; refusing rather than delivering into another session`);
|
|
450
454
|
}
|
|
455
|
+
// the session's own user speaking (a Room member's line, a voice bridge, a board the owner types in)
|
|
456
|
+
if (params.as_user === true) return this.#userTurn(params, sender, fromName, receiver, text);
|
|
451
457
|
const door = harness === AGENT_HARNESS ? null : this.#door(receiver, sessions);
|
|
452
458
|
const subject = subjectLine(params.subject ?? null);
|
|
453
459
|
let id = null;
|
|
@@ -488,6 +494,127 @@ export class MailSend {
|
|
|
488
494
|
};
|
|
489
495
|
}
|
|
490
496
|
|
|
497
|
+
// ---- the user's own turn ------------------------------------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* `sessions.message` with `as_user`: user mail, through the one door that carries
|
|
501
|
+
* the user's authority (a hosted runtime's input, or the session's pane when its composer is empty; otherwise it waits
|
|
502
|
+
* in the mailbox). A line to an agent is typed into the session its plan wakes (main for a root, a thread's holder for
|
|
503
|
+
* a reply in it), its other participants filed a record of it unread.
|
|
504
|
+
*/
|
|
505
|
+
async #userTurn(params, sender, fromName, receiver, text) {
|
|
506
|
+
const refused = (reason, message) => ({ delivered_to_bus: false, refusal: { reason, message } });
|
|
507
|
+
const sent = makeEnvelope({ id: newMessageId(), from: sender, from_name: `${fromName}@${this.machine}`, sender_identity: senderIdentity(sender, null, this.machine), kind: 'user', reply_via: { mode: 'none' }, body: text });
|
|
508
|
+
const target = { harness: params.locator.harness, session_id: params.locator.session_id };
|
|
509
|
+
let to = receiver;
|
|
510
|
+
const agentAddress = parseAddress(receiver).harness === AGENT_HARNESS ? receiver : null;
|
|
511
|
+
if (agentAddress) {
|
|
512
|
+
// The owner writing to an agent types into its main session (docs/adr/0008-agent-mailbox.md, decisions 3 and 12): a
|
|
513
|
+
// typed line there is the owner's, and main answers or delegates it.
|
|
514
|
+
const agent = this.agents.load(parseAddress(receiver).session);
|
|
515
|
+
if (!agent) return refused('delivery_failed', `no agent named ${parseAddress(receiver).session} is declared on this machine`);
|
|
516
|
+
to = agent.main_session;
|
|
517
|
+
// A line to an agent is a root (or a reply in its thread): the agent's mailbox files its thread, with the owner a
|
|
518
|
+
// participant, before it is typed
|
|
519
|
+
const inReplyTo = typeof params.in_reply_to === 'string' ? params.in_reply_to : null;
|
|
520
|
+
sent.in_reply_to = inReplyTo;
|
|
521
|
+
sent.thread = inReplyTo ? (this.#filedThread(inReplyTo) ?? inReplyTo) : null;
|
|
522
|
+
let plan;
|
|
523
|
+
try { plan = await this.agents.plan(sent, agentAddress, {}); } catch (error) { return refused('delivery_failed', error.message); }
|
|
524
|
+
if (plan) {
|
|
525
|
+
const typedTo = plan.recipients.find((item) => item.wake && parseAddress(item.address)?.harness !== 'operator')?.address;
|
|
526
|
+
if (typedTo) to = typedTo;
|
|
527
|
+
// the agent's own mailbox keeps the record of what was addressed to it
|
|
528
|
+
if (plan.agent) { try { this.store.deliverRead(plan.agent, sent); } catch { /* the record is best kept */ } }
|
|
529
|
+
// a copy of the owner's line is a record of a line typed elsewhere (as decision 12's copies are), never a turn
|
|
530
|
+
// of the copy's session: pending user mail would be typed into it
|
|
531
|
+
const copy = { ...sent, kind: 'typed' };
|
|
532
|
+
for (const other of plan.recipients.filter((item) => item.address !== to && item.address !== sender)) {
|
|
533
|
+
try { this.#fileUnread(other.address, copy); } catch { /* a copy is best kept */ }
|
|
534
|
+
}
|
|
535
|
+
// every owner line to any agent is the owner's account manager's as CC (decision 17), unless it is that agent's own
|
|
536
|
+
const accountManager = this.agents.ownersAccountManager();
|
|
537
|
+
if (accountManager && accountManager.name !== parseAddress(agentAddress).session) {
|
|
538
|
+
const main = accountManager.main_session;
|
|
539
|
+
if (main !== to && !plan.recipients.some((item) => item.address === main)) { try { this.#fileUnread(main, copy); } catch { /* best kept */ } }
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
// A thread's holder the mailbox launched that has since closed (decision 9) is resumed the way it was launched, and
|
|
543
|
+
// takes the line once its door is back (decision 5); the line is never a new root.
|
|
544
|
+
if (!this.live.sessions().some((row) => row.address === to) && await this.agents.resumable(to)) {
|
|
545
|
+
try { await this.agents.resume(to); } catch (error) { return refused('no_user_door', `${to} was not resumed: ${error.message}`); }
|
|
546
|
+
for (const started = Date.now(); !this.live.sessions().some((row) => row.address === to) && Date.now() - started < RESUME_WAIT_MS;) await sleep(1000);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
let turn;
|
|
550
|
+
try { turn = await this.#deliverUserTurn(sent, to); }
|
|
551
|
+
catch (error) {
|
|
552
|
+
// `no_user_door` says only that the session has no door for its user's turn (a caller may then reach it as a
|
|
553
|
+
// peer); a door that failed is `delivery_failed`, so the line is never sent twice
|
|
554
|
+
return refused(this.#hasUserDoor(to) ? 'delivery_failed' : 'no_user_door', error.message);
|
|
555
|
+
}
|
|
556
|
+
return { delivered_to_bus: turn !== 'waiting', message_id: sent.id, target, delivery: { door: turn === 'steered' || turn === 'started' ? 'runtime' : 'pane', how: turn } };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Whether `to` runs here with a door that carries its user's own turn: a hosted runtime's input or a daemon pane. */
|
|
560
|
+
#hasUserDoor(to) {
|
|
561
|
+
const session = this.live.sessions().find((row) => row.address === to);
|
|
562
|
+
return Boolean(session && (session.door === 'runtime' || paneOf(session)));
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* The user's own turn into `to`: a hosted runtime's input ('steered' or 'started'),
|
|
567
|
+
* or filed in its mailbox and typed into its pane once the composer is empty ('typed', or 'waiting' until then). A
|
|
568
|
+
* session with neither (running outside supercode) is refused, never reached as a peer instead.
|
|
569
|
+
*/
|
|
570
|
+
async #deliverUserTurn(sent, to) {
|
|
571
|
+
const session = this.live.sessions().find((row) => row.address === to);
|
|
572
|
+
if (!session) throw new Error(`${to} is not running; nothing was sent`);
|
|
573
|
+
this.store.open(to);
|
|
574
|
+
if (session.door === 'runtime') {
|
|
575
|
+
const parsed = parseAddress(to);
|
|
576
|
+
const receipt = this.live.runtimeReceipt(parsed.harness, parsed.session);
|
|
577
|
+
if (!receipt) throw new Error('the live runtime\'s receipt is gone');
|
|
578
|
+
const how = await this.deliverRuntime(receipt, sent.body, true);
|
|
579
|
+
this.store.deliverRead(to, sent);
|
|
580
|
+
return how === 'steered' ? 'steered' : 'started';
|
|
581
|
+
}
|
|
582
|
+
const pane = paneOf(session);
|
|
583
|
+
if (!pane) {
|
|
584
|
+
const name = session.name.split('@')[0];
|
|
585
|
+
throw new Error(`${name} runs outside supercode, where nothing can speak as its user. Open it in a pane with \`supercode open ${name}\`; nothing was sent.`);
|
|
586
|
+
}
|
|
587
|
+
this.store.deliver(to, sent);
|
|
588
|
+
const typed = await this.typeUserTurns(to, pane);
|
|
589
|
+
return typed.includes(sent.id) ? 'typed' : 'waiting';
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* The user's waiting turns of `address` typed into `pane`, oldest first, each only when its composer is empty; stops
|
|
594
|
+
* at the first that has to wait. Each is claimed before it is typed: the native watch types waiting turns too (until
|
|
595
|
+
* step 4), and two typers filling one composer sent a turn's text twice in one prompt. Answers the ids typed
|
|
596
|
+
* (mail_route.rs `type_user_turns`).
|
|
597
|
+
*/
|
|
598
|
+
async typeUserTurns(address, pane) {
|
|
599
|
+
const typed = [];
|
|
600
|
+
const harness = parseAddress(address).harness;
|
|
601
|
+
for (const waiting of this.store.userTurns(address)) {
|
|
602
|
+
const claimed = this.store.claimUserTurn(address, waiting);
|
|
603
|
+
if (!claimed) break;
|
|
604
|
+
let result;
|
|
605
|
+
try { result = await this.typeTurn(pane, harness, claimed.envelope.body, [claimed.envelope.id]); }
|
|
606
|
+
catch (error) {
|
|
607
|
+
try { this.store.release(address, claimed); } catch { /* recovered when this process ends */ }
|
|
608
|
+
this.log(`mail: the user's turn ${claimed.envelope.id} for ${address} waits: ${error.message}`);
|
|
609
|
+
break;
|
|
610
|
+
}
|
|
611
|
+
// held: the daemon's queue for the session types it when the composer frees, and is not asked again
|
|
612
|
+
if (result?.delivered || result?.queued) { try { this.store.acknowledge(address, claimed); } catch { /* typed */ } typed.push(claimed.envelope.id); }
|
|
613
|
+
else { try { this.store.release(address, claimed); } catch { /* recovered when this process ends */ } break; }
|
|
614
|
+
}
|
|
615
|
+
return typed;
|
|
616
|
+
}
|
|
617
|
+
|
|
491
618
|
// ---- a filed message handed to its door --------------------------------------------------------------------------
|
|
492
619
|
|
|
493
620
|
/**
|
package/mail/store.mjs
CHANGED
|
@@ -124,7 +124,9 @@ export class MailStore extends EventEmitter {
|
|
|
124
124
|
if (!envelope || typeof envelope.id !== 'string') { box.skipped.add(`${part}/${file}`); return; }
|
|
125
125
|
// its body's first line is kept beside the header (never in it): a listing of threads names each root by it with no
|
|
126
126
|
// body read
|
|
127
|
-
|
|
127
|
+
// the line whole, then capped by characters (a surrogate pair is never split), never a slice of the body first
|
|
128
|
+
const end = typeof envelope.body === 'string' ? envelope.body.indexOf('\n') : -1;
|
|
129
|
+
const firstLine = typeof envelope.body === 'string' ? Array.from(end < 0 ? envelope.body : envelope.body.slice(0, end)).slice(0, 400).join('') : '';
|
|
128
130
|
box.files.set(`${part}/${file}`, { header: headerOf(envelope), state: STATES[part], path, part, firstLine });
|
|
129
131
|
}
|
|
130
132
|
|
|
@@ -207,6 +209,44 @@ export class MailStore extends EventEmitter {
|
|
|
207
209
|
return claimed;
|
|
208
210
|
}
|
|
209
211
|
|
|
212
|
+
/** The user's own turns of `address` still waiting for their door, whole, oldest first (mailbox.rs `user_turns`). */
|
|
213
|
+
userTurns(address) {
|
|
214
|
+
const box = this.boxOf(address) ?? this.#named(address);
|
|
215
|
+
if (!box) return [];
|
|
216
|
+
let names = [];
|
|
217
|
+
try { names = readdirSync(join(box.dir, 'new')); } catch { return []; }
|
|
218
|
+
const turns = [];
|
|
219
|
+
for (const file of names) {
|
|
220
|
+
if (!file.endsWith('.json')) continue;
|
|
221
|
+
const path = join(box.dir, 'new', file);
|
|
222
|
+
const envelope = readEnvelope(path);
|
|
223
|
+
if (envelope?.kind !== 'user' || String(envelope.in_reply_to ?? '').startsWith('q-')) continue;
|
|
224
|
+
turns.push({ envelope, state: 'unread', path, part: 'new' });
|
|
225
|
+
}
|
|
226
|
+
return turns.sort(order);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** One of the user's waiting turns taken for typing, so a second typer (the native watch beside this daemon) does not
|
|
230
|
+
* type it too: moved into `claimed/` under this process's pid; null when another typer took it first (mailbox.rs
|
|
231
|
+
* `claim_user_turn`). Hand it on and acknowledge it, or release it to wait again. */
|
|
232
|
+
claimUserTurn(address, waiting) {
|
|
233
|
+
const box = this.boxOf(address) ?? this.#named(address);
|
|
234
|
+
if (!box) return null;
|
|
235
|
+
this.#recoverAbandoned(box);
|
|
236
|
+
const target = join(box.dir, 'claimed', `${process.pid}.${waiting.path.split(/[\\/]/).pop()}`);
|
|
237
|
+
try { renameSync(waiting.path, target); }
|
|
238
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
239
|
+
return { ...waiting, path: target, part: 'claimed' };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** A claimed envelope back to waiting, as it was before it was claimed (mailbox.rs `release`). */
|
|
243
|
+
release(address, claimed) {
|
|
244
|
+
const box = this.boxOf(address) ?? this.#named(address);
|
|
245
|
+
if (!box) return;
|
|
246
|
+
const name = claimed.path.split(/[\\/]/).pop();
|
|
247
|
+
renameSync(claimed.path, join(box.dir, 'new', name.includes('.') ? name.slice(name.indexOf('.') + 1) : name));
|
|
248
|
+
}
|
|
249
|
+
|
|
210
250
|
/** A claimed envelope reached its reader: moved to `cur/` under its own name (mailbox.rs `acknowledge`). */
|
|
211
251
|
acknowledge(address, claimed) {
|
|
212
252
|
const box = this.boxes.get(this.byAddress.get(address));
|
package/mail/verbs.mjs
CHANGED
|
@@ -19,8 +19,8 @@ export class AgentVerbs {
|
|
|
19
19
|
/** `machine` this machine's name; `store` the mail store; `agents` an agent's records; `live` the live-session map;
|
|
20
20
|
* `supercodeBin` the native binary (`supercode open` starts a session in a pane); `paneOf(session)` the daemon pane a
|
|
21
21
|
* live session runs in; `mailRoot` the mail folder. */
|
|
22
|
-
constructor({ machine, store, agents, live, supercodeBin, env = process.env, paneOf, mailRoot }) {
|
|
23
|
-
Object.assign(this, { machine, store, agents, live, supercodeBin, env, paneOf, mailRoot });
|
|
22
|
+
constructor({ machine, store, agents, live, supercodeBin, env = process.env, paneOf, mailRoot, closePane = async () => {} }) {
|
|
23
|
+
Object.assign(this, { machine, store, agents, live, supercodeBin, env, paneOf, mailRoot, closePane });
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
#whole(stored) { const envelope = this.store.body(stored); return envelope ? { ...stored, envelope } : null; }
|
|
@@ -126,17 +126,26 @@ export class AgentVerbs {
|
|
|
126
126
|
child.on('close', (code) => resolve({ code, out, err }));
|
|
127
127
|
});
|
|
128
128
|
if (run.code !== 0) throw new Error(`the session did not start: ${run.err.trim()}`);
|
|
129
|
+
let pane = null, reused = false;
|
|
130
|
+
try { const printed = JSON.parse(run.out); pane = printed.pane; reused = printed.reused === true; } catch { /* not reported */ }
|
|
129
131
|
if (known) return known;
|
|
130
|
-
let pane = null;
|
|
131
|
-
try { pane = JSON.parse(run.out).pane; } catch { /* not reported */ }
|
|
132
132
|
if (typeof pane !== 'string') throw new Error('the new pane was not reported');
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
133
|
+
// a launch that fails once the pane it opened is there closes that pane: nothing it started is left running, holding
|
|
134
|
+
// what was copied to it, after its thread goes back. A pane its key named already (`reused`, a live pane an earlier
|
|
135
|
+
// launch opened) is not this launch's, and is never closed by it.
|
|
136
|
+
try {
|
|
137
|
+
const started = Date.now();
|
|
138
|
+
while (Date.now() - started < 60_000) {
|
|
139
|
+
const session = this.live.sessions().find((row) => this.paneOf(row) === pane);
|
|
140
|
+
if (session) { await ready(session.address); return session.address; }
|
|
141
|
+
await sleep(500);
|
|
142
|
+
}
|
|
143
|
+
throw new Error(`${program} started in pane ${pane}, but no session appeared there within a minute; its pane is closed`);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (reused) { error.message = error.message.replace('; its pane is closed', `; its pane ${pane} was opened before, and is left open`); throw error; }
|
|
146
|
+
try { await this.closePane(pane); } catch (closing) { error.message += ` (its pane ${pane} could not be closed: ${closing.message})`; }
|
|
147
|
+
throw error;
|
|
138
148
|
}
|
|
139
|
-
throw new Error(`${program} started in pane ${pane}, but no session appeared there within a minute`);
|
|
140
149
|
}
|
|
141
150
|
|
|
142
151
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.89",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A machine daemon serving this machine's doors, and a local or remote Teams server for shared session discovery, OpenTelemetry ingestion and access to enrolled machines.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"@volter/supercode-health": "^0.1.0",
|
|
71
71
|
"@volter/supercode-terminal": "^0.2.54",
|
|
72
72
|
"@volter/supercode-trace": "^0.1.0",
|
|
73
|
-
"@volter/teams": "0.2.
|
|
73
|
+
"@volter/teams": "0.2.33",
|
|
74
74
|
"@xterm/addon-serialize": "^0.14.0",
|
|
75
75
|
"@xterm/headless": "^6.0.0",
|
|
76
76
|
"protobufjs": "8.8.0",
|