@volter/supercode-teams 0.3.90 → 0.3.92
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/connector/index.mjs +24 -7
- package/live-sessions.mjs +53 -11
- package/machine.mjs +4 -1
- package/mail/relay.mjs +32 -4
- package/mail/send.mjs +18 -3
- package/package.json +1 -1
package/connector/index.mjs
CHANGED
|
@@ -19,6 +19,22 @@ import { NativeEffects } from './native-effects.mjs';
|
|
|
19
19
|
import { consumeAgentClosures } from './agent-closures.mjs';
|
|
20
20
|
|
|
21
21
|
const SYNC_PROBLEM_LIMIT=20;
|
|
22
|
+
// How long after a session is first seen a change has its launch agent read again (a launch record written late).
|
|
23
|
+
const LAUNCH_AGENT_WINDOW_MS=120000;
|
|
24
|
+
/**
|
|
25
|
+
* The agent variables a session's launch recorded in its harness home (`<home>/supercode/launches/<session id>.json`,
|
|
26
|
+
* crates/harness/src/launch_agent.rs `read`), read here with no request: a Codex rollout's home is the folder above its
|
|
27
|
+
* `sessions/`, a Claude transcript's the folder above its `projects/`. Null when its launch named no agent.
|
|
28
|
+
*/
|
|
29
|
+
export function readLaunchAgent(locator){
|
|
30
|
+
const id=locator?.session_id,path=locator?.storage?.kind==='file'?locator.storage.path:null;
|
|
31
|
+
const store={codex:'sessions','claude-code':'projects'}[locator?.harness];
|
|
32
|
+
if(typeof path!=='string'||!store||typeof id!=='string'||!/^[A-Za-z0-9_-]+$/.test(id))return null;
|
|
33
|
+
let home=null;for(let dir=dirname(path);dirname(dir)!==dir;dir=dirname(dir))if(basename(dir)===store){home=dirname(dir);break;}
|
|
34
|
+
if(!home)return null;
|
|
35
|
+
try{const environment=JSON.parse(readFileSync(join(home,'supercode','launches',`${id}.json`),'utf8'))?.environment;return environment&&typeof environment==='object'&&!Array.isArray(environment)?environment:null;}
|
|
36
|
+
catch{return null;}
|
|
37
|
+
}
|
|
22
38
|
const CAPACITY_CODE='SUBSCRIPTION_OPEN_CAPACITY';
|
|
23
39
|
// Whether a root covers a session's folder: the root's own, or (unless the root is exact-only) one under it, by real path.
|
|
24
40
|
const realOr=path=>{try{return realpathSync(path);}catch{return path;}};
|
|
@@ -638,8 +654,11 @@ export class TeamsConnector {
|
|
|
638
654
|
// the binding is written only while this work is still the session's (checked again after its awaits)
|
|
639
655
|
const bindingRead=this.clock.nowMs();
|
|
640
656
|
try{await this.nativeEffects.discover(descriptor,ruleId,()=>this.sessionLive(key,ruleId,epoch)&&!((this.runtimeEnded?.get(key)??-Infinity)>=bindingRead));}catch(error){this.lastError=error;}
|
|
641
|
-
// The agent this session's launch named, read when first seen here and
|
|
642
|
-
|
|
657
|
+
// The agent this session's launch named, read (in this process, one file) when first seen here, and on a change in
|
|
658
|
+
// the LAUNCH_AGENT_WINDOW_MS after (a launch record written late), until queued for binding. A launch's agent is
|
|
659
|
+
// fixed at its launch: read on every change it was a call into harness serve's loop per transcript write.
|
|
660
|
+
const firstSeen=this.launchChecked?.get(key),now=this.clock.nowMs();
|
|
661
|
+
if(captured&&!this.state.agent_sessions?.[key]&&(firstSeen===undefined||(changed&&now-firstSeen<LAUNCH_AGENT_WINDOW_MS))){if(firstSeen===undefined)(this.launchChecked??=new Map()).set(key,now);this.noteLaunchAgent(locator,()=>this.sessionLive(key,ruleId,epoch));}
|
|
643
662
|
// A session seen with its native bundle still owed (from before this start) waits for its file to go quiet; one
|
|
644
663
|
// captured now has its wait armed by the commit that records it (flushOutbox).
|
|
645
664
|
if(!changed)this.armNativeIdle(key);
|
|
@@ -1209,11 +1228,9 @@ export class TeamsConnector {
|
|
|
1209
1228
|
if(error.status!==409&&error.status!==404)throw error;
|
|
1210
1229
|
}
|
|
1211
1230
|
}
|
|
1212
|
-
/** The agent a session's launch named, as its home recorded it (
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
let environment;
|
|
1216
|
-
try{environment=await this.daemon.launchAgent(locator);}catch(error){this.lastError=error;return;}
|
|
1231
|
+
/** The agent a session's launch named, as its home recorded it (read here, readLaunchAgent): queued for binding. */
|
|
1232
|
+
noteLaunchAgent(locator,live=()=>true){
|
|
1233
|
+
const environment=readLaunchAgent(locator);
|
|
1217
1234
|
if(!live())return;
|
|
1218
1235
|
if(environment?.SUPERCODE_AGENT_ID)this.queueAgentSession(`${locator.harness}:${locator.session_id}`,environment);
|
|
1219
1236
|
}
|
package/live-sessions.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// - Messaging tools: the marker a `message mcp` server writes for its session process, with the server alive.
|
|
15
15
|
import { EventEmitter } from 'node:events';
|
|
16
16
|
import { execFile } from 'node:child_process';
|
|
17
|
-
import { mkdirSync, readFileSync, readdirSync, renameSync, watch, writeFileSync } from 'node:fs';
|
|
17
|
+
import { mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, watch, writeFileSync } from 'node:fs';
|
|
18
18
|
import { homedir } from 'node:os';
|
|
19
19
|
import { dirname, join } from 'node:path';
|
|
20
20
|
import { FrontendClient } from '@volter/supercode-frontend';
|
|
@@ -30,6 +30,9 @@ const HISTORY_KEPT = 64;
|
|
|
30
30
|
/** The argument a Codex hook file carries when supercode's mail hook is installed. */
|
|
31
31
|
const CODEX_HOOK = 'message hook codex';
|
|
32
32
|
|
|
33
|
+
/** How long the runtime probes of one claim may take in all (a host stalled by load answers late, not never). */
|
|
34
|
+
const CLAIM_PROBE_MS = 10_000;
|
|
35
|
+
|
|
33
36
|
const alive = (pid) => {
|
|
34
37
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
35
38
|
try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
|
|
@@ -50,6 +53,9 @@ function linuxStart(pid) {
|
|
|
50
53
|
} catch { return null; }
|
|
51
54
|
}
|
|
52
55
|
|
|
56
|
+
/** `path` with its symlinks resolved, or null when it cannot be (gone, or not a string). */
|
|
57
|
+
function realPath(path) { try { return typeof path === 'string' ? realpathSync(path) : null; } catch { return null; } }
|
|
58
|
+
|
|
53
59
|
/** When the process on `pid` started, in ms (Linux's /proc, or one `ps` on macOS), or null when it cannot be read. */
|
|
54
60
|
function startedMs(pid) {
|
|
55
61
|
if (process.platform === 'linux') { const started = linuxStart(pid); return Promise.resolve(started ? Date.parse(`${started.date} UTC`) : null); }
|
|
@@ -62,11 +68,11 @@ function startedMs(pid) {
|
|
|
62
68
|
|
|
63
69
|
/** Whether the runtime a receipt names answers at its address as that runtime (its own identity, with the receipt's
|
|
64
70
|
* token): the proof a start read off the wall clock cannot give, as the clock may have stepped since the receipt.
|
|
65
|
-
* true or false, or null when it did not answer within
|
|
66
|
-
async function answersAs(receipt) {
|
|
71
|
+
* true or false, or null when it did not answer within `boundMs` (the request is aborted then): not known either way. */
|
|
72
|
+
async function answersAs(receipt, boundMs = 2000) {
|
|
67
73
|
try {
|
|
68
74
|
const client = new FrontendClient({ baseUrl: receipt.base_url, token: receipt.token, clientId: `live-${receipt.receipt_id}`, permissions: ['observe'] });
|
|
69
|
-
return (await client.describe({ signal: AbortSignal.timeout(
|
|
75
|
+
return (await client.describe({ signal: AbortSignal.timeout(boundMs) }))?.session_id === receipt.runtime_session_id;
|
|
70
76
|
} catch (error) {
|
|
71
77
|
if (error?.name === 'TimeoutError' || error?.name === 'AbortError') return null;
|
|
72
78
|
return false;
|
|
@@ -99,6 +105,9 @@ export class LiveSessions extends EventEmitter {
|
|
|
99
105
|
this.runtimes = new Map(); // receipt id → { pid, harness, sessionId, runtimeSessionId, cwd }
|
|
100
106
|
this.markers = new Map(); // session pid → server pid
|
|
101
107
|
this.codexRows = new Map(); // session id → { cwd, updatedAt }
|
|
108
|
+
// whether the Codex sessions are known yet: the index's first answer read, then their activity followed once after
|
|
109
|
+
// it (until then a Codex session missing from the map is not yet known, never not running)
|
|
110
|
+
this.codexIndexRead = false; this.codexKnown = false; this.codexWaiters = [];
|
|
102
111
|
this.codexRunning = new Map(); // session id → turn (from activity)
|
|
103
112
|
this.codexPaneOf = new Map(); // session id → pane
|
|
104
113
|
this.projectHooks = new Map(); // directory → whether its .codex/hooks.json carries the hook
|
|
@@ -320,7 +329,8 @@ export class LiveSessions extends EventEmitter {
|
|
|
320
329
|
const source = receipt.source;
|
|
321
330
|
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,
|
|
322
331
|
// what a later check of the same pid needs: when the receipt was written, and how to ask the runtime itself
|
|
323
|
-
|
|
332
|
+
// its workspace as a real path too, so a relay folder named through a symlinked home still matches it
|
|
333
|
+
realCwd: realPath(source.workspace), 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 } };
|
|
324
334
|
const known = this.runtimes.get(id);
|
|
325
335
|
if (known && known.pid === entry.pid && known.startChecked) entry.startChecked = true;
|
|
326
336
|
this.runtimes.set(id, entry);
|
|
@@ -418,6 +428,26 @@ export class LiveSessions extends EventEmitter {
|
|
|
418
428
|
|
|
419
429
|
#codexChanged() { this.emit('codex-followed'); }
|
|
420
430
|
|
|
431
|
+
/** The session index's first answer is read: its Codex sessions' activity is followed next (even when it has none). */
|
|
432
|
+
codexIndexed() { this.codexIndexRead = true; this.#codexChanged(); }
|
|
433
|
+
|
|
434
|
+
/** The Codex sessions' activity was followed: after the index's first answer, the Codex sessions are known. */
|
|
435
|
+
codexFollowedNow() {
|
|
436
|
+
if (!this.codexIndexRead || this.codexKnown) return;
|
|
437
|
+
this.codexKnown = true;
|
|
438
|
+
for (const resolve of this.codexWaiters.splice(0)) resolve(true);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Resolves true once the Codex sessions are known, or false when `boundMs` passes first. */
|
|
442
|
+
whenCodexKnown(boundMs) {
|
|
443
|
+
if (this.codexKnown) return Promise.resolve(true);
|
|
444
|
+
return new Promise((resolve) => {
|
|
445
|
+
const timer = setTimeout(() => { this.codexWaiters = this.codexWaiters.filter((waiter) => waiter !== done); resolve(false); }, boundMs);
|
|
446
|
+
const done = (value) => { clearTimeout(timer); resolve(value); };
|
|
447
|
+
this.codexWaiters.push(done);
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
421
451
|
#changed() { this.emit('changed'); }
|
|
422
452
|
|
|
423
453
|
// ---- what it holds ------------------------------------------------------------------------------------------------
|
|
@@ -467,6 +497,13 @@ export class LiveSessions extends EventEmitter {
|
|
|
467
497
|
* file and checked as live_runtime.rs `resolve_live_runtime` checks it (its schema, a loopback address, its process
|
|
468
498
|
* alive, the source it names): the bearer token a delivery uses is read here, at the delivery, never kept in the map.
|
|
469
499
|
*/
|
|
500
|
+
/** The live runtimes of `harness` whose workspace is `cwd`, each by the ids it may be closed by (in-memory, no read). */
|
|
501
|
+
runtimesIn(harness, cwd) {
|
|
502
|
+
const real = realPath(cwd);
|
|
503
|
+
return [...this.runtimes.values()].filter((runtime) => runtime.harness === harness && (runtime.cwd === cwd || (real && runtime.realCwd === real)) && alive(runtime.pid))
|
|
504
|
+
.map((runtime) => [...new Set([runtime.sessionId, runtime.runtimeSessionId].filter((id) => typeof id === 'string' && id))]);
|
|
505
|
+
}
|
|
506
|
+
|
|
470
507
|
runtimeReceipt(harness, id) {
|
|
471
508
|
for (const [receiptId, runtime] of this.runtimes) {
|
|
472
509
|
if (runtime.harness !== harness || (runtime.sessionId !== id && runtime.runtimeSessionId !== id) || !alive(runtime.pid)) continue;
|
|
@@ -511,12 +548,14 @@ export class LiveSessions extends EventEmitter {
|
|
|
511
548
|
|
|
512
549
|
/** The session a client runs in, from its claim: its parent's current session, else its environment's id through the map. */
|
|
513
550
|
async #sessionOfClaim({ pid, ppid, session, runtime }) {
|
|
551
|
+
// the probes of one claim share one bound (CLAIM_PROBE_MS), however many of its processes are runtimes
|
|
552
|
+
const deadline = Date.now() + CLAIM_PROBE_MS;
|
|
514
553
|
// a runtime supercode hosts (the native agent) says so in its claim: it is named by its own process first, never by
|
|
515
554
|
// the session whose shell started it; one with no receipt is named by the rest of its claim, as any client is
|
|
516
555
|
if (runtime === true) {
|
|
517
556
|
// a pid several receipts name is no one runtime: the rest of the claim names it, never a refusal here
|
|
518
557
|
let own = null;
|
|
519
|
-
try { own = await this.#runtimeOfPid(Number(pid)); } catch (error) { if (!(error instanceof CallerRefused) || error.unconfirmed) throw error; }
|
|
558
|
+
try { own = await this.#runtimeOfPid(Number(pid), deadline); } catch (error) { if (!(error instanceof CallerRefused) || error.unconfirmed) throw error; }
|
|
520
559
|
if (own) return own;
|
|
521
560
|
}
|
|
522
561
|
const parent = this.claude.get(Number(ppid));
|
|
@@ -530,24 +569,27 @@ export class LiveSessions extends EventEmitter {
|
|
|
530
569
|
// only when exactly one live runtime's receipt names that process. A process several receipts name (harness serve
|
|
531
570
|
// records its own pid on each runtime it hosts with no child of its own) is no one runtime, and is refused.
|
|
532
571
|
for (const candidate of [Number(pid), Number(ppid)]) {
|
|
533
|
-
const found = await this.#runtimeOfPid(candidate);
|
|
572
|
+
const found = await this.#runtimeOfPid(candidate, deadline);
|
|
534
573
|
if (found) return found;
|
|
535
574
|
}
|
|
536
575
|
return null;
|
|
537
576
|
}
|
|
538
577
|
|
|
539
578
|
/** The runtime whose live receipt alone names process `pid`, or null; several receipts naming it are no one runtime, refused. */
|
|
540
|
-
async #runtimeOfPid(pid) {
|
|
579
|
+
async #runtimeOfPid(pid, deadline = Date.now() + CLAIM_PROBE_MS) {
|
|
541
580
|
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
542
581
|
const held = [...this.runtimes.values()].filter((runtime) => runtime.pid === pid && alive(runtime.pid));
|
|
543
582
|
if (held.length > 1) throw new CallerRefused(`process ${pid} hosts ${held.length} runtimes, so which one is calling is not known; nothing was done`);
|
|
544
583
|
if (held.length !== 1) return null;
|
|
545
584
|
// a caller's identity: checked again now, with no process (the runtime asked at its own address), as a runtime that
|
|
546
585
|
// ended may have left its pid to another process before the sweep lets its receipt go
|
|
547
|
-
|
|
548
|
-
//
|
|
586
|
+
// a host stalled by load answers late, not never: one that did not answer in 2 s is asked once more, for what is left
|
|
587
|
+
// of the claim's bound
|
|
588
|
+
let answered = await answersAs(held[0].probe, Math.max(1, Math.min(2000, deadline - Date.now())));
|
|
589
|
+
if (answered === null && deadline - Date.now() > 250) answered = await answersAs(held[0].probe, deadline - Date.now());
|
|
590
|
+
// one that did not answer then is not known either way: refused, never taken for its parent's session or its
|
|
549
591
|
// environment's (which would name another caller)
|
|
550
|
-
if (answered === null) throw Object.assign(new CallerRefused(`process ${pid} is a runtime that did not answer within
|
|
592
|
+
if (answered === null) throw Object.assign(new CallerRefused(`process ${pid} is a runtime that did not answer within ${CLAIM_PROBE_MS / 1000} s, so which session is calling was not confirmed; nothing was done`), { unconfirmed: true });
|
|
551
593
|
if (!answered) return null;
|
|
552
594
|
return this.#address(held[0].harness, held[0].sessionId);
|
|
553
595
|
}
|
package/machine.mjs
CHANGED
|
@@ -594,7 +594,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
594
594
|
const followNow = async () => {
|
|
595
595
|
const locators = this.live.codexFollowed();
|
|
596
596
|
const key = locators.map((locator) => locator.session_id).sort().join('\n');
|
|
597
|
-
if (key === followed) return;
|
|
597
|
+
if (key === followed) { this.live.codexFollowedNow(); return; }
|
|
598
598
|
const previous = this.liveActivity;
|
|
599
599
|
try {
|
|
600
600
|
if (locators.length) {
|
|
@@ -603,6 +603,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
603
603
|
this.live.codexActivity(opened?.initial ?? [], { replace: true });
|
|
604
604
|
} else { this.liveActivity = null; this.live.codexActivity([], { replace: true }); }
|
|
605
605
|
followed = key; backoffMs = 0;
|
|
606
|
+
this.live.codexFollowedNow();
|
|
606
607
|
if (previous) void this.#serveRequest('harness.v1.sessions.activity.unsubscribe', { subscription: previous }, 30_000).catch(() => {});
|
|
607
608
|
} catch (error) {
|
|
608
609
|
backoffMs = Math.min(300_000, Math.max(2000, (backoffMs || 1000) * 2));
|
|
@@ -645,6 +646,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
645
646
|
recordedConfig: (locator) => this.#serveRequest('harness.v1.sessions.recorded_config', { locator }, 15_000) });
|
|
646
647
|
this.relays = new Relays({ mailRoot, machine, supercodeBin: this.supercodeBin, serve: (method, params, timeoutMs) => this.#serveRequest(method, params, timeoutMs),
|
|
647
648
|
runtimeOf: (harness, id) => live.runtimeReceipt(harness, id), runtimeNamed: (receiptId) => live.receiptNamed(receiptId),
|
|
649
|
+
runtimesIn: (harness, cwd) => live.runtimesIn(harness, cwd),
|
|
648
650
|
log: (line) => this.emit('mail-log', line) });
|
|
649
651
|
this.mailSend = new MailSend({ machine, mailRoot, live, store: this.mailStore, agents: this.agents, relays: this.relays,
|
|
650
652
|
// a send waits on its receiver's door there (a relay's receipt takes up to 90 s), never past MAIL_SEND_MS
|
|
@@ -794,6 +796,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
794
796
|
this.sessionDescriptors?.clear();
|
|
795
797
|
for (const descriptor of answer?.initial ?? []) if (descriptor?.locator) { locators.set(`${descriptor.locator.harness}:${descriptor.locator.session_id}`, descriptor.locator); this.live?.codexIndex({ descriptor }); this.sessionDescriptors?.set(`sc:${machineName(this.env)}:${descriptor.locator.harness}:${descriptor.locator.session_id}`, descriptor); }
|
|
796
798
|
producer.note('sessions_index', null); noteIndexParts(answer?.failed, null); both();
|
|
799
|
+
this.live?.codexIndexed();
|
|
797
800
|
}, (error) => {
|
|
798
801
|
indexBackoff = backoff(indexBackoff);
|
|
799
802
|
const why = `the session index could not be subscribed: ${error.message}; sessions and panes change on pane and mail events only until it is`;
|
package/mail/relay.mjs
CHANGED
|
@@ -25,6 +25,8 @@ export const MAX_RELAYED_BYTES = 100_000;
|
|
|
25
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
26
|
* machine): beyond them a relay's runtime is closed (its folder kept: it starts again when next needed). */
|
|
27
27
|
export const RELAYS_RUNNING = 4;
|
|
28
|
+
/** How long a relay whose runtime start lost its answer is looked at for a runtime that start left running. */
|
|
29
|
+
const LOST_START_KEPT_MS = 5 * 60_000;
|
|
28
30
|
|
|
29
31
|
const alive = (pid) => { if (!Number.isInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
|
|
30
32
|
const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
|
|
@@ -61,8 +63,9 @@ export class Relays {
|
|
|
61
63
|
* id)` the live-runtime receipt the live-session map holds for a runtime id (its file read whole), or null;
|
|
62
64
|
* `runtimeNamed(receiptId)` one receipt read by its own name.
|
|
63
65
|
*/
|
|
64
|
-
constructor({ mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed = () => null, log = () => {} }) {
|
|
65
|
-
Object.assign(this, { mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed, log });
|
|
66
|
+
constructor({ mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed = () => null, runtimesIn = () => [], log = () => {} }) {
|
|
67
|
+
Object.assign(this, { mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed, runtimesIn, log });
|
|
68
|
+
this.lost = new Map(); // relay folder → when a start of its runtime lost its answer (the runtime may run, unnamed)
|
|
66
69
|
this.sending = new Map(); // relay folder → the send in progress through it (one at a time per relay)
|
|
67
70
|
this.running = new Map(); // relay folder → { connection, runtime_id, usedAt }: the relays this daemon runs
|
|
68
71
|
}
|
|
@@ -78,7 +81,25 @@ export class Relays {
|
|
|
78
81
|
// closed under the relay's own lock: a send to it that arrives meanwhile waits for the close, then starts it
|
|
79
82
|
// again, never handing its turn to a runtime that is closing
|
|
80
83
|
void this.#locked(directory, () => this.serve('harness.v1.runtimes.close', { connection: relay.connection, runtime_id: relay.runtime_id }, 15_000))
|
|
81
|
-
.catch((error) =>
|
|
84
|
+
.catch((error) => {
|
|
85
|
+
// still running, so still counted: the next cap closes it again (unless a send started it anew meanwhile)
|
|
86
|
+
if (!this.running.has(directory)) this.running.set(directory, relay);
|
|
87
|
+
this.log(`relay ${directory.split(/[\\/]/).pop()} beyond the ${RELAYS_RUNNING} most recent was not closed: ${error.message}`);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
// a start whose answer was lost may have left a runtime running in its relay's folder that no record names: closed by
|
|
91
|
+
// its id once the live-session map holds it (looked for over five minutes: the start may finish after its answer)
|
|
92
|
+
for (const [directory, lostAt] of this.lost) {
|
|
93
|
+
if (Date.now() - lostAt > LOST_START_KEPT_MS) { this.lost.delete(directory); continue; }
|
|
94
|
+
if (this.sending.has(directory)) continue;
|
|
95
|
+
const named = readJson(join(directory, 'relay.json'))?.runtime_id;
|
|
96
|
+
for (const ids of this.runtimesIn('claude-code', directory)) {
|
|
97
|
+
if (ids.includes(named)) continue;
|
|
98
|
+
void this.#locked(directory, async () => {
|
|
99
|
+
for (const id of ids) { try { await this.serve('harness.v1.runtimes.close', { runtime_id: id }, 15_000); return; } catch { /* the other id */ } }
|
|
100
|
+
this.log(`relay ${directory.split(/[\\/]/).pop()}: a runtime its lost start left running was not closed`);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
82
103
|
}
|
|
83
104
|
}
|
|
84
105
|
|
|
@@ -165,7 +186,7 @@ export class Relays {
|
|
|
165
186
|
catch (error) { if (this.runtimeOf('claude-code', record.runtime_id)) throw new Error(`could not retire the old relay: ${error.message}`); }
|
|
166
187
|
}
|
|
167
188
|
const key = spec.directory.split(/[\\/]/).pop();
|
|
168
|
-
const started = await this
|
|
189
|
+
const started = await this.#start(spec, {
|
|
169
190
|
harness: 'claude-code',
|
|
170
191
|
launch: {
|
|
171
192
|
program: 'claude',
|
|
@@ -270,6 +291,13 @@ export class Relays {
|
|
|
270
291
|
}
|
|
271
292
|
}
|
|
272
293
|
|
|
294
|
+
/** `runtimes.start` for a relay: a start that fails (its answer lost, or late past its bound) may still have left a
|
|
295
|
+
* runtime running, so its folder is looked at by the next caps (#cap). */
|
|
296
|
+
async #start(spec, params, timeoutMs) {
|
|
297
|
+
try { return await this.serve('harness.v1.runtimes.start', params, timeoutMs); }
|
|
298
|
+
catch (error) { this.lost.set(spec.directory, Date.now()); throw error; }
|
|
299
|
+
}
|
|
300
|
+
|
|
273
301
|
/** A relay this daemon runs, used now. */
|
|
274
302
|
#used(spec, connection) {
|
|
275
303
|
const runtimeId = readJson(spec.record)?.runtime_id;
|
package/mail/send.mjs
CHANGED
|
@@ -23,6 +23,9 @@ export function unansweredText(where, detail, id) {
|
|
|
23
23
|
return `Unanswered: ${where} took the message, but its answer was lost (${detail}), so whether it was delivered is unknown. Message id ${shortId(id)}. Don't send it again as a new message; supercode message show ${shortId(id)} says where it stands.`;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** How long a send waits, in a daemon's first moments, for its Codex sessions to be known before it resolves a receiver. */
|
|
27
|
+
const CODEX_KNOWN_WAIT_MS = 20_000;
|
|
28
|
+
|
|
26
29
|
/** Replies in one unbroken chain between two sessions beyond which another is refused. */
|
|
27
30
|
const REPLY_CHAIN_LIMIT = 32;
|
|
28
31
|
/** How long a name no running session carries still reaches the session that last carried it. */
|
|
@@ -148,7 +151,7 @@ export class MailSend {
|
|
|
148
151
|
}
|
|
149
152
|
if (parsed) {
|
|
150
153
|
const session = sessions.find((row) => row.address === parsed.address);
|
|
151
|
-
if (!session) return { outcome: outcome(EXIT_UNKNOWN, `${to} is no longer running. Nothing was sent. Run supercode message list for the live sessions.`) };
|
|
154
|
+
if (!session) return { outcome: outcome(EXIT_UNKNOWN, this.#notYetKnown(to) ?? `${to} is no longer running. Nothing was sent. Run supercode message list for the live sessions.`) };
|
|
152
155
|
return { address: session.address, name: session.name, session };
|
|
153
156
|
}
|
|
154
157
|
// a name: unique among the sessions here, else the session it last named (the names ledger)
|
|
@@ -176,7 +179,17 @@ export class MailSend {
|
|
|
176
179
|
}).slice(0, 3).map((row) => `${row.name} (${row.address.split(':')[2]}, ${row.status})`);
|
|
177
180
|
if (near.length) hint = ` Did you mean: ${near.join(', ')}?`;
|
|
178
181
|
}
|
|
179
|
-
return { outcome: outcome(EXIT_UNKNOWN, `No session named "${wanted}" is reachable.${hint} Run supercode message list. Nothing was sent.`) };
|
|
182
|
+
return { outcome: outcome(EXIT_UNKNOWN, this.#notYetKnown(wanted) ?? `No session named "${wanted}" is reachable.${hint} Run supercode message list. Nothing was sent.`) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** In a daemon's first moments the Codex sessions are not known yet (its session index and their activity still being
|
|
186
|
+
* read): a send waits for them, at most CODEX_KNOWN_WAIT_MS. */
|
|
187
|
+
async #codexKnown() { if (!this.live.codexKnown) await this.live.whenCodexKnown?.(CODEX_KNOWN_WAIT_MS); }
|
|
188
|
+
|
|
189
|
+
/** What a receiver not found is, while the Codex sessions are not known yet: not known yet, never not running. */
|
|
190
|
+
#notYetKnown(what) {
|
|
191
|
+
if (this.live.codexKnown !== false) return null;
|
|
192
|
+
return `${what} is not known yet: this machine's daemon started a moment ago and is still reading its sessions (its Codex sessions among them). Nothing was sent. Send it again in a minute.`;
|
|
180
193
|
}
|
|
181
194
|
|
|
182
195
|
/** The door that reaches `address` here, from one read of the live sessions (mail_route.rs `door_in`): null when no
|
|
@@ -195,6 +208,7 @@ export class MailSend {
|
|
|
195
208
|
|
|
196
209
|
/** Deliver from `caller` to `to` on this machine (mail_send.rs `deliver`). */
|
|
197
210
|
async #deliver(caller, to, body, { subject, in_reply_to, notify_when_idle, queue, id, notice, thread: carried, chosen }, link) {
|
|
211
|
+
await this.#codexKnown();
|
|
198
212
|
const sessions = this.live.sessions();
|
|
199
213
|
const receiver = this.#receiver(to, sessions);
|
|
200
214
|
if (receiver.outcome) return receiver.outcome;
|
|
@@ -226,7 +240,7 @@ export class MailSend {
|
|
|
226
240
|
if (plan.plan) return this.#deliverPlanned(caller, sent, plan.plan, { queue, notify_when_idle });
|
|
227
241
|
// the thread this message is in, kept in the send log: a later reply to it continues that thread
|
|
228
242
|
const sentIn = threadOf(sent);
|
|
229
|
-
if (!door || door.kind === 'other_machine') return outcome(EXIT_UNKNOWN, `${name} is no longer running. Nothing was sent. Run supercode message list for the live sessions.`);
|
|
243
|
+
if (!door || door.kind === 'other_machine') return outcome(EXIT_UNKNOWN, this.#notYetKnown(name) ?? `${name} is no longer running. Nothing was sent. Run supercode message list for the live sessions.`);
|
|
230
244
|
const idleNote = notify_when_idle && cameThroughLink(sent)
|
|
231
245
|
? ' No idle notice: this message came through a link, so its return address is as claimed, and nothing is sent to it on its own (D200); a reply is the receiver\'s own send.'
|
|
232
246
|
: notify_when_idle ? ' Subscribed: one idle notice reaches you when its next turn ends.' : '';
|
|
@@ -444,6 +458,7 @@ export class MailSend {
|
|
|
444
458
|
const receiver = typeof harness === 'string' && typeof sessionId === 'string' ? makeAddress(this.machine, harness, sessionId) : null;
|
|
445
459
|
if (!receiver) return refused('delivery_failed', `\`sc:${this.machine}:${harness}:${sessionId}\` is not a session address; addresses look like sc:<machine>:<harness>:<session-id>`);
|
|
446
460
|
if (params.voice_for != null && params.voice_for !== receiver) return refused('invalid_sender', 'a voice front must represent the receiving session');
|
|
461
|
+
await this.#codexKnown();
|
|
447
462
|
const sessions = this.live.sessions();
|
|
448
463
|
// a Claude session's registry name must still name only that session; a holder the mailbox launched may have stopped
|
|
449
464
|
if (harness === 'claude-code' && !(await this.agents.resumable(receiver)) && !this.live.runtimeReceipt('claude-code', sessionId)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.92",
|
|
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",
|