@volter/supercode-teams 0.3.88 → 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 +73 -12
- package/machine.mjs +82 -51
- package/mail/agents.mjs +26 -8
- package/mail/reads.mjs +11 -4
- package/mail/relay.mjs +39 -8
- package/mail/send.mjs +134 -7
- package/mail/store.mjs +38 -0
- package/mail/verbs.mjs +6 -4
- 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,11 +508,13 @@ 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, runtime }) {
|
|
511
|
+
async #sessionOfClaim({ pid, ppid, session, runtime }) {
|
|
457
512
|
// a runtime supercode hosts (the native agent) says so in its claim: it is named by its own process first, never by
|
|
458
513
|
// the session whose shell started it; one with no receipt is named by the rest of its claim, as any client is
|
|
459
514
|
if (runtime === true) {
|
|
460
|
-
|
|
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; }
|
|
461
518
|
if (own) return own;
|
|
462
519
|
}
|
|
463
520
|
const parent = this.claude.get(Number(ppid));
|
|
@@ -471,18 +528,22 @@ export class LiveSessions extends EventEmitter {
|
|
|
471
528
|
// only when exactly one live runtime's receipt names that process. A process several receipts name (harness serve
|
|
472
529
|
// records its own pid on each runtime it hosts with no child of its own) is no one runtime, and is refused.
|
|
473
530
|
for (const candidate of [Number(pid), Number(ppid)]) {
|
|
474
|
-
const found = this.#runtimeOfPid(candidate);
|
|
531
|
+
const found = await this.#runtimeOfPid(candidate);
|
|
475
532
|
if (found) return found;
|
|
476
533
|
}
|
|
477
534
|
return null;
|
|
478
535
|
}
|
|
479
536
|
|
|
480
537
|
/** The runtime whose live receipt alone names process `pid`, or null; several receipts naming it are no one runtime, refused. */
|
|
481
|
-
#runtimeOfPid(pid) {
|
|
538
|
+
async #runtimeOfPid(pid) {
|
|
482
539
|
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
483
540
|
const held = [...this.runtimes.values()].filter((runtime) => runtime.pid === pid && alive(runtime.pid));
|
|
484
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`);
|
|
485
|
-
|
|
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);
|
|
486
547
|
}
|
|
487
548
|
|
|
488
549
|
/**
|
|
@@ -490,7 +551,7 @@ export class LiveSessions extends EventEmitter {
|
|
|
490
551
|
* calling"): a session's address, a board's address, or null for a person. Throws CallerRefused when the claim names
|
|
491
552
|
* a caller the map does not hold.
|
|
492
553
|
*/
|
|
493
|
-
callerOf(claim) {
|
|
554
|
+
async callerOf(claim) {
|
|
494
555
|
const { pid, owner, named } = claim ?? {};
|
|
495
556
|
const board = typeof named === 'string' ? new RegExp(`^sc:${this.machine.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:board:([^:\\s]+)$`).exec(named) : null;
|
|
496
557
|
// a board's owner, by the pid it registered with: the client itself, or the owner that started it as the board
|
|
@@ -499,7 +560,7 @@ export class LiveSessions extends EventEmitter {
|
|
|
499
560
|
if (registered && [pid, Number(owner)].includes(registered) && alive(registered)) return named;
|
|
500
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
|
|
501
562
|
// is the session the claim resolves to, or a person, as before (a board's name stood only under its own process).
|
|
502
|
-
const session = this.#sessionOfClaim(claim ?? {});
|
|
563
|
+
const session = await this.#sessionOfClaim(claim ?? {});
|
|
503
564
|
if (session) return session;
|
|
504
565
|
if (board) return null;
|
|
505
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,11 +644,21 @@ 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
664
|
paneOf: (session) => this.mail.paneOf(session), mailRoot,
|
|
@@ -941,6 +953,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
941
953
|
this.mailWatch?.kill('SIGTERM');
|
|
942
954
|
this.stoppingServe = true;
|
|
943
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');
|
|
944
958
|
this.serve?.kill('SIGTERM');
|
|
945
959
|
this.host.dispose();
|
|
946
960
|
}
|
|
@@ -987,7 +1001,9 @@ export class TeamsMachine extends MachineCore {
|
|
|
987
1001
|
};
|
|
988
1002
|
// a write into a serve that has gone (EPIPE, ECONNRESET) is its pipe's error: the serve is over, the daemon is not
|
|
989
1003
|
child.stdin.on('error', (error) => {
|
|
990
|
-
|
|
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`);
|
|
991
1007
|
try { child.kill('SIGTERM'); } catch { /* gone */ }
|
|
992
1008
|
// one that does not end on SIGTERM within 5 s is killed: a serve that no longer reads is never left behind
|
|
993
1009
|
const grace = setTimeout(() => { if (child.exitCode === null && child.signalCode === null) { try { child.kill('SIGKILL'); } catch { /* gone */ } } }, 5000);
|
|
@@ -999,7 +1015,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
999
1015
|
child.stderr.on('error', () => {});
|
|
1000
1016
|
child.on('error', (error) => end(`could not run (${error.message})`));
|
|
1001
1017
|
child.on('exit', (code, signal) => {
|
|
1002
|
-
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 });
|
|
1003
1019
|
end(`exited (${signal ?? `code ${code}`})`);
|
|
1004
1020
|
});
|
|
1005
1021
|
this.serve = child;
|
|
@@ -1262,7 +1278,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1262
1278
|
|
|
1263
1279
|
#noticeTarget(address){
|
|
1264
1280
|
return sessionNoticeTarget(address,{machine:machineName(this.env),
|
|
1265
|
-
// 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)
|
|
1266
1282
|
mail:async()=>({code:0,sessions:this.live.sessions()}),
|
|
1267
1283
|
discover:params=>this.#serveRequest('harness.v1.sessions.discover',params,10000),
|
|
1268
1284
|
recordedConfig:locator=>this.#serveRequest('harness.v1.sessions.recorded_config',{locator},15000)});
|
|
@@ -1276,9 +1292,10 @@ export class TeamsMachine extends MachineCore {
|
|
|
1276
1292
|
if(status.state==='running')return;
|
|
1277
1293
|
if(!status.resumable)throw Object.assign(new Error(status.reason??'native session cannot resume'),{noticeCode:'session_resume_unavailable'});
|
|
1278
1294
|
const code=await new Promise((resolve,reject)=>{
|
|
1279
|
-
// Explicit native approval and sandbox flags suppress open's unattended defaults.
|
|
1280
|
-
|
|
1281
|
-
|
|
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'});
|
|
1282
1299
|
const timer=setTimeout(()=>{child.kill('SIGTERM');resolve(null);},15000);
|
|
1283
1300
|
child.once('error',error=>{clearTimeout(timer);reject(error);});child.once('close',code=>{clearTimeout(timer);resolve(code);});
|
|
1284
1301
|
});
|
|
@@ -1360,7 +1377,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1360
1377
|
})();
|
|
1361
1378
|
}
|
|
1362
1379
|
|
|
1363
|
-
/** 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
|
|
1364
1381
|
* refused when it came through a link (never forwarded on this machine's own credential), and this machine's own
|
|
1365
1382
|
* daemon sends on through the one way out. */
|
|
1366
1383
|
async #sendHere(request, link) {
|
|
@@ -1373,7 +1390,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1373
1390
|
}, { link });
|
|
1374
1391
|
}
|
|
1375
1392
|
|
|
1376
|
-
/** 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
|
|
1377
1394
|
* sender is resolved here from the link it came through, never from the filer's own claim; a wake it carries is
|
|
1378
1395
|
* requested once, for a message new here. */
|
|
1379
1396
|
#fileHere(request, link) {
|
|
@@ -1504,8 +1521,9 @@ export class TeamsMachine extends MachineCore {
|
|
|
1504
1521
|
return refuse(`no interact grant for ${target}`);
|
|
1505
1522
|
}
|
|
1506
1523
|
accept();
|
|
1507
|
-
// The user-turn door every supercode has (
|
|
1508
|
-
// 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.
|
|
1509
1527
|
const answer = (value) => { if (!stream.closed) { stream.write(JSON.stringify(value)); stream.close('delivered'); } };
|
|
1510
1528
|
// A keyed line is typed once: the server retries a line whose open outlived its bound, and the attempt it gave up
|
|
1511
1529
|
// on may still be accepted here. A repeat of a key answers what its first attempt answers.
|
|
@@ -1528,12 +1546,12 @@ export class TeamsMachine extends MachineCore {
|
|
|
1528
1546
|
: request.text;
|
|
1529
1547
|
const note = { harness, session, text, at: Date.now(), source: { via: 'room', from: { principalId: principal ?? null, name: entry.name ?? null } } };
|
|
1530
1548
|
try { this.onTurn?.(note); } catch { /* naming is best-effort; the turn goes either way */ }
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
text,
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
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) => {
|
|
1537
1555
|
const delivered = result?.message_id && !result.refusal;
|
|
1538
1556
|
this.log.append({ principal, cap: 'interact', target, outcome: delivered ? 'turn' : 'failed', detail: result?.refusal?.message?.slice(0, 200) });
|
|
1539
1557
|
if (!delivered) try { this.onTurnWithdrawn?.(note); } catch { /* nothing to withdraw */ }
|
|
@@ -1709,8 +1727,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1709
1727
|
return Number((await run('ps', ['-o', 'ppid=', '-p', String(holder)])).trim()) === pid;
|
|
1710
1728
|
}
|
|
1711
1729
|
|
|
1712
|
-
/** A filing's sender as this machine knows it, with no process and no request (
|
|
1713
|
-
* 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
|
|
1714
1731
|
* (`local`, the same user, D186), and one claiming another machine's address is unresolved: another machine's mail
|
|
1715
1732
|
* reaches this one only through its link, so a return address is never an identity. */
|
|
1716
1733
|
#observeSender(from) {
|
|
@@ -2124,6 +2141,41 @@ export class TeamsMachine extends MachineCore {
|
|
|
2124
2141
|
|
|
2125
2142
|
/** The program a pane runs, as the terminal package names kinds: what it was
|
|
2126
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
|
+
|
|
2127
2179
|
#paneKind(contextKey, session) {
|
|
2128
2180
|
const launched = this.launches.all().find((row) => row.pane === contextKey && row.kind)?.kind;
|
|
2129
2181
|
const named = String(launched ?? '').trim().toLowerCase();
|
|
@@ -3121,34 +3173,9 @@ export class TeamsMachine extends MachineCore {
|
|
|
3121
3173
|
if (sessionTurn?.refused) { act('control', `pane:${params.pane}`, 'refused', JSON.stringify(sessionTurn.refused)); return sessionTurn.refused; }
|
|
3122
3174
|
const session = await this.#paneByContext(params.pane);
|
|
3123
3175
|
if (!session) throw Object.assign(new Error(`no pane with context ${params.pane}`), { code: 404 });
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
// so a headless `codex exec` a Claude pane's tool started once took that pane for its own, and the Codex's mail
|
|
3128
|
-
// was typed into Claude's composer (m-72bc65eb). The addressed harness names the program the text is for. Only a
|
|
3129
|
-
// pane known to run another harness is refused: an unrecorded pane's kind is its command's name, which a native
|
|
3130
|
-
// Claude install shows as its version.
|
|
3131
|
-
const addressed = params.harness == null ? null : String(params.harness).trim().toLowerCase().replace(/^claude$/u, 'claude-code');
|
|
3132
|
-
if (addressed && addressed !== kind && ['claude-code', 'codex', 'grok', 'gemini', 'goose', 'opencode', 'pi', 'hermes', 'openclaw'].includes(kind)) {
|
|
3133
|
-
const refused = { delivered: false, reason: `harness-mismatch: mail for ${addressed} is not typed into a ${kind || 'unknown'} pane` };
|
|
3134
|
-
act('control', `pane:${params.pane}`, 'refused', JSON.stringify(refused));
|
|
3135
|
-
return refused;
|
|
3136
|
-
}
|
|
3137
|
-
if (kind === 'claude-code') {
|
|
3138
|
-
// A usage-limit wait would queue this turn until the limit's reset: esc ends the wait first. A limit that
|
|
3139
|
-
// still holds refuses the turn at once and the session waits again, for the next turn to try.
|
|
3140
|
-
const queued = [...this.terminalMailboxes.values()].some(queue => queue.session?.id === session.id && (queue.state.active || queue.state.pending.length));
|
|
3141
|
-
if (!this.inputInFlight.has(session.id) && !queued && claudeUsageLimitWait(await this.host.capture(session.id, { lines: 60 }))) {
|
|
3142
|
-
await this.host.sendInput(session.id, '\u001b', { submitMode: 'single', deadlineMs: Date.now() + 5_000 });
|
|
3143
|
-
await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
|
|
3144
|
-
}
|
|
3145
|
-
const draftOf = async () => composerHoldsDraft(kind, await this.host.capture(session.id, { lines: 200, preserveEscapes: true }));
|
|
3146
|
-
const first = await draftOf();
|
|
3147
|
-
await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
|
|
3148
|
-
const second = await draftOf();
|
|
3149
|
-
if (first !== false || second !== false) return { delivered: false, reason: first === true || second === true ? 'draft' : 'no-composer' };
|
|
3150
|
-
}
|
|
3151
|
-
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;
|
|
3152
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 }; }
|
|
3153
3180
|
act('control', `pane:${params.pane}`, result.delivered ? 'input' : result.queued ? 'input-pending' : 'failed', JSON.stringify(result));
|
|
3154
3181
|
return result;
|
|
@@ -3320,7 +3347,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
3320
3347
|
// 3a): this machine's own processes only, answered here.
|
|
3321
3348
|
case 'harness.v1.mail.list': case 'harness.v1.mail.show': case 'harness.v1.mail.inbox': case 'harness.v1.mail.acknowledge': {
|
|
3322
3349
|
if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('mail is read here by this machine\'s own processes'), { code: 403 });
|
|
3323
|
-
|
|
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() };
|
|
3324
3352
|
if (method === 'harness.v1.mail.show') {
|
|
3325
3353
|
const ids = (Array.isArray(params.ids) ? params.ids : []).filter((id) => typeof id === 'string');
|
|
3326
3354
|
return params.replies ? this.mail.showReplies(ids) : await this.mail.show(ids);
|
|
@@ -3352,7 +3380,9 @@ export class TeamsMachine extends MachineCore {
|
|
|
3352
3380
|
// acknowledged once it has printed it (harness.v1.mail.acknowledge)
|
|
3353
3381
|
const caller = await this.#callerOf(entry, params);
|
|
3354
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." };
|
|
3355
|
-
|
|
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 });
|
|
3356
3386
|
let token = null;
|
|
3357
3387
|
if (read.claimed.length) {
|
|
3358
3388
|
token = randomBytes(12).toString('hex');
|
|
@@ -3362,7 +3392,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
3362
3392
|
for (const [old] of [...this.inboxClaims].slice(0, Math.max(0, this.inboxClaims.size - 255))) this.inboxClaims.delete(old);
|
|
3363
3393
|
this.inboxClaims.set(token, { address: caller, claimed: read.claimed });
|
|
3364
3394
|
}
|
|
3365
|
-
|
|
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 };
|
|
3366
3397
|
}
|
|
3367
3398
|
// Who a local client is, from its claim (live-sessions.mjs callerOf): what a command acting on another machine sends
|
|
3368
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
|
@@ -72,26 +72,43 @@ export class Agents {
|
|
|
72
72
|
* channel marker. Every writer (this daemon, and mail_agent.rs's in the native watch until step 4) renames a whole
|
|
73
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
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):
|
|
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.
|
|
76
77
|
*/
|
|
77
78
|
#records() {
|
|
78
79
|
let changed;
|
|
79
80
|
try { changed = statSync(this.threadsDir).mtimeMs; } catch { return { list: [], byHolder: new Map(), byMarker: new Map() }; }
|
|
80
|
-
|
|
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.
|
|
81
86
|
let names = [];
|
|
82
87
|
try { names = readdirSync(this.threadsDir); } catch { return { list: [], byHolder: new Map(), byMarker: new Map() }; }
|
|
83
|
-
const
|
|
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)
|
|
84
99
|
.sort((a, b) => (a.created_at_ms - b.created_at_ms) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
85
100
|
const byHolder = new Map(), byMarker = new Map();
|
|
86
101
|
for (const thread of list) {
|
|
87
102
|
byHolder.set(thread.holder, [...(byHolder.get(thread.holder) ?? []), thread]);
|
|
88
103
|
for (const [marker, id] of Object.entries(thread.markers ?? {})) if (!byMarker.has(marker)) byMarker.set(marker, { thread, id });
|
|
89
104
|
}
|
|
90
|
-
const records = { changed, list, byHolder, byMarker };
|
|
91
|
-
|
|
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;
|
|
92
108
|
return records;
|
|
93
109
|
}
|
|
94
110
|
|
|
111
|
+
|
|
95
112
|
/** The thread `address` holds for an agent other than as its main session (mail_agent.rs `thread_held_by`). */
|
|
96
113
|
heldBy(address) {
|
|
97
114
|
const held = [...(this.#records().byHolder.get(address) ?? [])].reverse().find((thread) => {
|
|
@@ -325,8 +342,8 @@ export class Agents {
|
|
|
325
342
|
return (await this.#resumeArguments(address)) != null;
|
|
326
343
|
}
|
|
327
344
|
|
|
328
|
-
/** Resume the stopped session at `address` in a daemon pane, by `supercode open <session> --detach --
|
|
329
|
-
* 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. */
|
|
330
347
|
async resume(address) {
|
|
331
348
|
const recorded = await this.#resumeArguments(address);
|
|
332
349
|
if (!recorded) throw new Error(`${address} has no recorded permissions to resume with; its mail waits`);
|
|
@@ -339,7 +356,8 @@ export class Agents {
|
|
|
339
356
|
}
|
|
340
357
|
writeAtomically(join(this.agentsDir, 'resumed', blake3Hex(address).slice(0, 24)), String(Date.now()));
|
|
341
358
|
const { code, stderr } = await new Promise((resolve) => {
|
|
342
|
-
|
|
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 });
|
|
343
361
|
let err = '';
|
|
344
362
|
child.stderr.on('data', (chunk) => { err += chunk; });
|
|
345
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
|
@@ -209,6 +209,44 @@ export class MailStore extends EventEmitter {
|
|
|
209
209
|
return claimed;
|
|
210
210
|
}
|
|
211
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
|
+
|
|
212
250
|
/** A claimed envelope reached its reader: moved to `cur/` under its own name (mailbox.rs `acknowledge`). */
|
|
213
251
|
acknowledge(address, claimed) {
|
|
214
252
|
const box = this.boxes.get(this.byAddress.get(address));
|
package/mail/verbs.mjs
CHANGED
|
@@ -126,12 +126,13 @@ 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;
|
|
130
|
-
try {
|
|
129
|
+
let pane = null, reused = false;
|
|
130
|
+
try { const printed = JSON.parse(run.out); pane = printed.pane; reused = printed.reused === true; } catch { /* not reported */ }
|
|
131
131
|
if (known) return known;
|
|
132
132
|
if (typeof pane !== 'string') throw new Error('the new pane was not reported');
|
|
133
|
-
// a launch that fails once
|
|
134
|
-
// copied to it, after its thread goes back
|
|
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.
|
|
135
136
|
try {
|
|
136
137
|
const started = Date.now();
|
|
137
138
|
while (Date.now() - started < 60_000) {
|
|
@@ -141,6 +142,7 @@ export class AgentVerbs {
|
|
|
141
142
|
}
|
|
142
143
|
throw new Error(`${program} started in pane ${pane}, but no session appeared there within a minute; its pane is closed`);
|
|
143
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; }
|
|
144
146
|
try { await this.closePane(pane); } catch (closing) { error.message += ` (its pane ${pane} could not be closed: ${closing.message})`; }
|
|
145
147
|
throw error;
|
|
146
148
|
}
|
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",
|