@volter/supercode-teams 0.3.122 → 0.3.124

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/live-sessions.mjs CHANGED
@@ -484,17 +484,19 @@ export class LiveSessions extends EventEmitter {
484
484
  #loadTools() {
485
485
  let kept = {};
486
486
  try { kept = JSON.parse(readFileSync(this.toolsFile, 'utf8')) ?? {}; } catch { /* none yet */ }
487
+ let legacy = false;
487
488
  try {
488
489
  for (const name of readdirSync(this.legacyMarkersDir)) { const server = Number(readFileSync(join(this.legacyMarkersDir, name), 'utf8').trim()); if (Number.isInteger(Number(name))) kept[name] = server; }
489
- rmSync(this.legacyMarkersDir, { recursive: true, force: true });
490
+ legacy = true;
490
491
  } catch { /* none written before */ }
491
492
  for (const [session, server] of Object.entries(kept)) this.registerTools(Number(session), Number(server), { save: false });
492
- this.#saveTools();
493
+ // the merged markers kept first; the old folder removed only once they are (a daemon ended between the two loses none)
494
+ if (this.#saveTools() && legacy) { try { rmSync(this.legacyMarkersDir, { recursive: true, force: true }); } catch { /* removed at the next start */ } }
493
495
  }
494
496
 
495
497
  #saveTools() {
496
- try { writeFileSync(`${this.toolsFile}.tmp`, JSON.stringify(Object.fromEntries(this.markers))); renameSync(`${this.toolsFile}.tmp`, this.toolsFile); }
497
- catch (error) { this.log(`live sessions: the messaging tools' markers were not kept: ${error.message}`); }
498
+ try { writeFileSync(`${this.toolsFile}.tmp`, JSON.stringify(Object.fromEntries(this.markers))); renameSync(`${this.toolsFile}.tmp`, this.toolsFile); return true; }
499
+ catch (error) { this.log(`live sessions: the messaging tools' markers were not kept: ${error.message}`); return false; }
498
500
  }
499
501
 
500
502
  /** A `message mcp` server says the session `session` has the messaging tools while it, `server`, runs: taken when the
package/machine.mjs CHANGED
@@ -43,6 +43,8 @@ import { Relays } from './mail/relay.mjs';
43
43
  import { serveRelayEndpoint } from './mail/relay-endpoint.mjs';
44
44
  import { MailCarrier } from './mail/carrier.mjs';
45
45
  import { MailHooks } from './mail/hooks.mjs';
46
+ import { IdleNotices } from './mail/idle.mjs';
47
+ import { AgentUpkeep } from './mail/upkeep.mjs';
46
48
  import { NativeQuestions } from './mail/questions.mjs';
47
49
 
48
50
  const NO_CALLER = "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.";
@@ -652,7 +654,6 @@ export class TeamsMachine extends MachineCore {
652
654
  async afterListen() {
653
655
  // Web apps on this machine reach it through the app door when the person has turned it on (app-door.mjs).
654
656
  this.appDoor = serveAppDoor({ home: this.home, open: () => { const [page, daemon] = inProcessPair(); void this.acceptLocal(daemon, { appDoor: true }); return page; }, log: (row) => this.log.append({ principal: 'app-door', cap: 'app-door', target: row.origin ?? '*', outcome: row.outcome, detail: row.detail }) });
655
- this.#startMailWatch();
656
657
  this.#startHealth();
657
658
  this.#startLive();
658
659
  this.#startMail();
@@ -792,7 +793,8 @@ export class TeamsMachine extends MachineCore {
792
793
  // a user's own turn typed into its pane, in process (as `panes.turn` types one)
793
794
  typeTurn,
794
795
  log: (line) => this.emit('mail-log', line) });
795
- // native questions' files (bindings, questions, replies) as mail_question.rs keeps them; their poll is step 4c's
796
+ // native questions' files (bindings, questions, replies) as mail_question.rs keeps them, for the question hook and
797
+ // the poll (started below)
796
798
  this.nativeQuestions = new NativeQuestions({ machine, mailRoot, store: this.mailStore, codexQuestions: (request) => codexQuestions(request),
797
799
  log: (line) => this.emit('mail-log', line) });
798
800
  // the harnesses' mail hooks (step 1b): each hook a front-door verb asking here, so none opens the mail folder
@@ -811,6 +813,18 @@ export class TeamsMachine extends MachineCore {
811
813
  handOver: (address, id) => this.mailSend.handOver(address, id),
812
814
  typeTurn, askMachine: (to, request) => this.#askMailDoor(to, request, MAIL_SEND_MS),
813
815
  log: (line) => this.emit('mail-log', line) }).start();
816
+ // idle notices and final replies (step 4c): each subscribed session's activity followed through harness serve
817
+ this.idleNotices = new IdleNotices({ machine, store: this.mailStore, live,
818
+ serveRequest: (method, params, ms) => this.#serveRequest(method, params, ms),
819
+ locatorOf: (address) => this.sessionDescriptors?.get(address)?.locator ?? null,
820
+ log: (line) => this.emit('mail-log', line) }).start();
821
+ // native Codex questions as mail (step 4c): the poll of each launched Codex pane's own app-server, in process
822
+ this.nativeQuestions.start();
823
+ // an agent's upkeep (step 4d): folder sessions become threads, typed lines reach their CC, idle launched threads end
824
+ this.agentUpkeep = new AgentUpkeep({ machine, mailRoot, store: this.mailStore, agents: this.agents, live,
825
+ claudeProjects: () => [...live.claudeDirs.keys()].map((dir) => join(dirname(dir), 'projects')),
826
+ lastMessageAt: (address) => this.sessionDescriptors?.get(address)?.updated_at_ms ?? null,
827
+ program: this.supercodeBin, env: this.env, log: (line) => this.emit('mail-log', line) }).start();
814
828
  // the user's own turns that wait in a pane session's mailbox (a draft in its composer when they came) are typed once
815
829
  // its composer is free: tried at each change of its mailbox, and every 2 s while one waits (mail_watch.rs
816
830
  // `deliver_waiting_user_turns` read every pane's mailbox every 2 s)
@@ -974,6 +988,7 @@ export class TeamsMachine extends MachineCore {
974
988
  noteIndexParts(params.failed, params.error?.message ?? null);
975
989
  both();
976
990
  } else if (params.subscription && params.subscription === this.recordsActivity) both();
991
+ else this.idleNotices?.notified(params);
977
992
  });
978
993
  let indexBackoff = 0;
979
994
  const subscribe = () => this.#serveRequest('harness.v1.sessions.index.subscribe', { harnesses: HARNESSES, limit: 0 }, 60_000)
@@ -992,6 +1007,7 @@ export class TeamsMachine extends MachineCore {
992
1007
  void subscribe();
993
1008
  // the harness service ended: its subscriptions went with it, said at once, and made again when it is back
994
1009
  this.on('serve-exit', () => {
1010
+ this.idleNotices?.serveExited();
995
1011
  if (this.recordsStopped) return;
996
1012
  this.recordsIndex = null; this.recordsActivity = null; this.recordsActivityFor = null;
997
1013
  producer.note('sessions_index', 'the harness service exited; its session index subscription is made again when it is back');
@@ -1137,11 +1153,12 @@ export class TeamsMachine extends MachineCore {
1137
1153
  for (const child of this.voiceProcesses.values()) child.kill('SIGTERM');
1138
1154
  this.voiceProcesses.clear();
1139
1155
  this.jobs?.stopAll();
1140
- this.stoppingMailWatch = true;
1141
- this.mailWatch?.kill('SIGTERM');
1142
1156
  void this.relayEndpoint?.then((endpoint) => endpoint?.close()).catch(() => {});
1143
1157
  this.carrier?.stop();
1144
1158
  clearInterval(this.hookSpoolTimer);
1159
+ this.idleNotices?.stop();
1160
+ this.agentUpkeep?.stop();
1161
+ this.nativeQuestions?.stop();
1145
1162
  clearInterval(this.turnsSweep);
1146
1163
  this.stoppingServe = true;
1147
1164
  clearTimeout(this.serveRestart);
@@ -1225,23 +1242,6 @@ export class TeamsMachine extends MachineCore {
1225
1242
  try { stdin.write(line); return true; } catch { return false; }
1226
1243
  }
1227
1244
 
1228
- // Idle notices for every session on this machine (`supercode message watch`):
1229
- // it settles `--notify-when-idle` subscriptions from session activity. It
1230
- // lives beside `harness serve` and is restarted if it exits, so a
1231
- // subscription made at any time has a watcher.
1232
- #startMailWatch() {
1233
- if (this.stoppingMailWatch) return;
1234
- const child = spawn(this.supercodeBin, ['message', 'watch'], { stdio: ['ignore', 'ignore', 'pipe'], env: this.env });
1235
- child.stderr.setEncoding('utf8');
1236
- child.stderr.on('data', (text) => this.emit('mail-watch-stderr', text));
1237
- child.on('error', (error) => this.emit('mail-watch-stderr', error.message));
1238
- child.on('exit', () => {
1239
- if (this.mailWatch !== child || this.stoppingMailWatch) return;
1240
- setTimeout(() => this.#startMailWatch(), 5000).unref();
1241
- });
1242
- this.mailWatch = child;
1243
- }
1244
-
1245
1245
  /** One `harness.v1` request of the daemon's own, to its `harness serve`: the
1246
1246
  * connection id INTERNAL_CONNECTION marks the answer as ours. */
1247
1247
  #serveRequest(method, params, timeoutMs = 3000) {
@@ -1830,6 +1830,18 @@ export class TeamsMachine extends MachineCore {
1830
1830
  * this daemon's native once per daemon (`__stable-program`), for a command a pane records; this daemon's own
1831
1831
  * supercode when its native has no such verb or the shim cannot be written.
1832
1832
  */
1833
+ /** Which home holds each board address here (`<SUPERCODE_HOME>/board-homes.json`): the first home whose round read it. */
1834
+ #boardHomes() {
1835
+ this.boardHomesHeld ??= (() => { try { return JSON.parse(readFileSync(join(supercodeHome(this.env), 'board-homes.json'), 'utf8')) ?? {}; } catch { return {}; } })();
1836
+ return this.boardHomesHeld;
1837
+ }
1838
+
1839
+ #saveBoardHomes(held) {
1840
+ const path = join(supercodeHome(this.env), 'board-homes.json');
1841
+ try { writeFileSync(`${path}.tmp`, JSON.stringify(held, null, 2)); renameSync(`${path}.tmp`, path); }
1842
+ catch (error) { this.emit('mail-log', `board homes: not kept: ${error.message}`); }
1843
+ }
1844
+
1833
1845
  /** Whether the machine's stable supercode serves `command` in TypeScript (its front door's own list), asked once: a dev
1834
1846
  * or cargo build's native, run with no front door, serves no hook verb. */
1835
1847
  #frontDoorServes(command) {
@@ -3708,6 +3720,14 @@ export class TeamsMachine extends MachineCore {
3708
3720
  act('mail.send', to, answer.code === 0 ? 'ok' : 'failed');
3709
3721
  return answer;
3710
3722
  }
3723
+ // `message questions`: one pass of the native question poll now (step 4c), this machine's own processes only
3724
+ case 'harness.v1.mail.questions': {
3725
+ if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('native questions are refreshed here by this machine\'s own processes'), { code: 403 });
3726
+ const counted = await this.nativeQuestions.poll();
3727
+ return counted
3728
+ ? { code: 0, text: `Asked ${counted.panes} launched Codex pane${counted.panes === 1 ? '' : 's'}: ${counted.questions} question${counted.questions === 1 ? '' : 's'} waiting, each filed once in its creator's mailbox.`, ...counted }
3729
+ : { code: 0, text: 'A pass of the native question poll is running now; its questions are filed as it finds them.' };
3730
+ }
3711
3731
  // An agent's verbs (docs/architecture/overview.md, 3b-2): `message thread`, `threads`, `delegate`, and `supercode
3712
3732
  // agent declare|show`, this machine's own processes only; a delegation and a thread list without --agent are the
3713
3733
  // caller's, as its claim names it.
@@ -3914,6 +3934,17 @@ export class TeamsMachine extends MachineCore {
3914
3934
  }
3915
3935
  }
3916
3936
  if (!caller) return { code: 2, text: 'a board\'s mailbox is read by that board\'s own owner only (its registered owner, or the process holding its home\'s board lock)' };
3937
+ // two homes whose folders are named alike name the same board address, and so share one mailbox: the home that
3938
+ // first read it holds it (kept beside this daemon's state), and another home's round is refused, never handed the
3939
+ // first one's answers
3940
+ const home = typeof params.root === 'string' && params.root ? params.root : null;
3941
+ if (home) {
3942
+ const held = this.#boardHomes();
3943
+ let real = home; try { real = realpathSync(home); } catch { /* as named */ }
3944
+ const holder = held[caller];
3945
+ if (holder && holder !== real && existsSync(holder)) return { code: 2, text: `${caller} is the board address of the home ${holder} as well as of ${real}: two homes named alike share one address and one mailbox. This round reads nothing; rename one home's folder (its board address follows it).` };
3946
+ if (holder !== real) { held[caller] = real; this.#saveBoardHomes(held); }
3947
+ }
3917
3948
  this.boardClaims ??= new Map();
3918
3949
  if (method === 'harness.v1.mail.board_ack') {
3919
3950
  const pending = this.boardClaims.get(params.token);
package/mail/agents.mjs CHANGED
@@ -21,7 +21,7 @@ const validName = (name) => typeof name === 'string' && /^[A-Za-z0-9._-]+$/.test
21
21
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
22
22
 
23
23
  /** A record written whole and renamed into place (mail_agent.rs `write_atomically`). */
24
- function writeAtomically(path, text) {
24
+ export function writeAtomically(path, text) {
25
25
  mkdirSync(join(path, '..'), { recursive: true });
26
26
  const temporary = `${path.replace(/\.[^./]*$/, '')}.tmp.${process.pid}`;
27
27
  writeFileSync(temporary, text);
package/mail/idle.mjs ADDED
@@ -0,0 +1,243 @@
1
+ // Idle notices and final replies (step 4c; mail_watch.rs `IdleWatcher`, deleted): a sender that asked for
2
+ // `--notify-when-idle` gets one notice when its receiver is seen working after the message and then idle, one when the
3
+ // receiver ends, or one saying the subscription expired after SUBSCRIPTION_LIFETIME_MS; a sender whose message went in
4
+ // `reply-via=final-message` to a runtime supercode hosts gets the runtime's answer as its reply, or a notice that none
5
+ // came. The native watch read every subscribed session's activity every two seconds; here the daemon follows only the
6
+ // subscribed sessions' activity (harness serve's `sessions.activity` subscription: its first answer, then each change),
7
+ // asks a hosted runtime with a final reply waiting for its turn every RUNTIME_POLL_MS (as the native watch did), and
8
+ // settles what no event names (an expiry, a change it missed) once per SWEEP_MS.
9
+ //
10
+ // A notice or a reply is filed in its subscriber's mailbox with a wake, so the carrier (mail/carrier.mjs) hands it to
11
+ // the subscriber's door, or carries it to the subscriber's machine, with its retries and its records: one delivery path
12
+ // for all mail.
13
+ import { FrontendClient } from '@volter/supercode-frontend';
14
+ import { envelope as makeEnvelope, newMessageId, parseAddress } from './compose.mjs';
15
+
16
+ export const SUBSCRIPTION_LIFETIME_MS = 24 * 60 * 60_000;
17
+ const RUNTIME_POLL_MS = 2_000;
18
+ const SWEEP_MS = 60_000;
19
+ const MAX_FOLLOWED = 2048;
20
+
21
+ const textOf = (content) => typeof content === 'string' ? content
22
+ : Array.isArray(content) ? content.map((part) => typeof part === 'string' ? part : part?.text ?? '').join('') : '';
23
+
24
+ export class IdleNotices {
25
+ /**
26
+ * `store` the mail store; `live` the live-session map; `serveRequest(method, params, ms)` one request to harness serve;
27
+ * `locatorOf(address)` the session index's locator for a session here, when it has one; `log(line)`.
28
+ */
29
+ constructor({ machine, store, live, serveRequest, locatorOf, log = () => {} }) {
30
+ Object.assign(this, { machine, store, live, serveRequest, locatorOf, log });
31
+ this.targets = new Set(); // addresses here with a subscription on them
32
+ this.activity = new Map(); // address → { turn, presence } as serve last reported it
33
+ this.subscription = null; this.followed = '';
34
+ this.polling = new Set(); // hosted runtimes asked for their turn
35
+ this.settling = new Set(); // addresses being settled now
36
+ }
37
+
38
+ start() {
39
+ this.store.on('subscriptions', (address) => this.#track(address));
40
+ for (const address of this.store.addresses()) this.#track(address);
41
+ this.sweeper = setInterval(() => { for (const address of this.targets) void this.#settle(address); }, SWEEP_MS);
42
+ this.sweeper.unref?.();
43
+ return this;
44
+ }
45
+
46
+ stop() { clearInterval(this.sweeper); clearInterval(this.runtimeTimer); this.#unfollow(); }
47
+
48
+ /** Serve's activity notification for this subscription: each changed session settled. */
49
+ notified(params) {
50
+ if (!params?.subscription || params.subscription !== this.subscription) return false;
51
+ for (const activity of params.activities ?? []) this.#seen(activity);
52
+ return true;
53
+ }
54
+
55
+ /** Serve ended: its subscription went with it, made again on the next change or sweep. */
56
+ serveExited() { this.subscription = null; this.followed = ''; this.#follow(); }
57
+
58
+ #track(address) {
59
+ const parsed = parseAddress(address);
60
+ if (!parsed || parsed.machine !== this.machine) return;
61
+ const has = this.store.subscriptions(address).length > 0;
62
+ if (has === this.targets.has(address)) { if (has) void this.#settle(address); return; }
63
+ if (has) this.targets.add(address); else { this.targets.delete(address); this.activity.delete(address); }
64
+ this.#follow();
65
+ if (has) void this.#settle(address);
66
+ }
67
+
68
+ #seen(activity) {
69
+ const address = `sc:${this.machine}:${activity.harness}:${activity.session_id}`;
70
+ if (!this.targets.has(address)) return;
71
+ this.activity.set(address, { turn: activity.turn, presence: activity.presence });
72
+ void this.#settle(address);
73
+ }
74
+
75
+ // ---- following ---------------------------------------------------------------------------------------------------
76
+
77
+ /** The subscribed sessions' activity followed (one subscription for all of them, made again when the set changes). */
78
+ async #follow() {
79
+ const locators = [];
80
+ for (const address of this.targets) {
81
+ if (this.#runtime(address)) continue; // a hosted runtime is asked itself
82
+ const { harness, session } = parseAddress(address);
83
+ locators.push(this.locatorOf(address) ?? { harness, session_id: session, storage: { kind: 'file', path: '' } });
84
+ }
85
+ const fingerprint = locators.map((locator) => `${locator.harness}:${locator.session_id}`).sort().join('\n');
86
+ if (fingerprint === this.followed && (this.subscription || !locators.length)) { this.#pollRuntimes(); return; }
87
+ this.followed = fingerprint;
88
+ const previous = this.subscription;
89
+ this.subscription = null;
90
+ if (locators.length) {
91
+ try {
92
+ const answer = await this.serveRequest('harness.v1.sessions.activity.subscribe', { locators: locators.slice(0, MAX_FOLLOWED) }, 30_000);
93
+ if (this.followed !== fingerprint) { if (answer?.subscription) void this.serveRequest('harness.v1.sessions.activity.unsubscribe', { subscription: answer.subscription }, 30_000).catch(() => {}); return; }
94
+ this.subscription = answer?.subscription ?? null;
95
+ if (locators.length > MAX_FOLLOWED) this.log(`idle notices: ${locators.length - MAX_FOLLOWED} subscribed sessions are not followed (at most ${MAX_FOLLOWED}); they settle on expiry`);
96
+ for (const activity of answer?.initial ?? []) this.#seen(activity);
97
+ } catch (error) {
98
+ this.followed = ''; // tried again at the next change or sweep
99
+ this.log(`idle notices: the subscribed sessions' activity could not be followed: ${error.message}`);
100
+ }
101
+ }
102
+ if (previous) void this.serveRequest('harness.v1.sessions.activity.unsubscribe', { subscription: previous }, 30_000).catch(() => {});
103
+ this.#pollRuntimes();
104
+ }
105
+
106
+ #unfollow() {
107
+ if (this.subscription) void this.serveRequest('harness.v1.sessions.activity.unsubscribe', { subscription: this.subscription }, 30_000).catch(() => {});
108
+ this.subscription = null; this.followed = '';
109
+ }
110
+
111
+ /** The hosted runtimes with a final reply waiting, asked for their turn every RUNTIME_POLL_MS while one waits. */
112
+ #pollRuntimes() {
113
+ const waiting = [...this.targets].filter((address) => this.#runtime(address) && this.store.subscriptions(address).some((item) => item.final_reply));
114
+ this.polling = new Set(waiting);
115
+ if (!waiting.length) { clearInterval(this.runtimeTimer); this.runtimeTimer = null; return; }
116
+ if (this.runtimeTimer) return;
117
+ this.runtimeTimer = setInterval(() => {
118
+ if (!this.polling.size) { clearInterval(this.runtimeTimer); this.runtimeTimer = null; return; }
119
+ for (const address of this.polling) void this.#settle(address);
120
+ }, RUNTIME_POLL_MS);
121
+ this.runtimeTimer.unref?.();
122
+ }
123
+
124
+ #runtime(address) {
125
+ const { harness, session } = parseAddress(address);
126
+ return this.live.runtimeReceipt(harness, session);
127
+ }
128
+
129
+ // ---- settling ------------------------------------------------------------------------------------------------------
130
+
131
+ /** One pass over one receiver's subscriptions (mail_watch.rs `tick`, for one mailbox). */
132
+ async #settle(address) {
133
+ if (this.settling.has(address)) return;
134
+ this.settling.add(address);
135
+ try {
136
+ const subscriptions = this.store.subscriptions(address);
137
+ if (!subscriptions.length) { this.#track(address); return; }
138
+ let working = false, idle = false, ended = false;
139
+ const receipt = this.#runtime(address);
140
+ if (subscriptions.some((item) => item.final_reply)) {
141
+ // a runtime supercode hosts reports its own turn
142
+ if (!receipt) ended = true;
143
+ else {
144
+ try {
145
+ const client = new FrontendClient({ baseUrl: receipt.base_url, token: receipt.token, clientId: `mail-${receipt.receipt_id}`, permissions: ['observe'] });
146
+ const turn = (await client.describe())?.turn_state;
147
+ working = turn === 'busy'; idle = turn === 'idle';
148
+ } catch { /* not answering: nothing settles on it */ }
149
+ }
150
+ } else {
151
+ const seen = this.activity.get(address);
152
+ if (seen) { working = seen.turn === 'working'; idle = seen.turn === 'idle'; ended = seen.presence === 'persisted'; }
153
+ }
154
+ // one turn settles every message a sender subscribed with; the sender hears it once, about its latest message,
155
+ // and its earlier subscriptions settle with it silently
156
+ subscriptions.sort((a, b) => (b.created_at_ms ?? 0) - (a.created_at_ms ?? 0));
157
+ const told = new Set();
158
+ const now = Date.now();
159
+ for (const subscription of subscriptions) {
160
+ const expired = now - (subscription.created_at_ms ?? now) > SUBSCRIPTION_LIFETIME_MS;
161
+ // a reply is the runtime's answer to this message: it settles once the runtime is idle and has answered,
162
+ // however fast the turn was
163
+ if (subscription.final_reply && !ended && !expired) {
164
+ const answer = receipt && idle ? await this.#answerTo(address, receipt, subscription.message_id) : null;
165
+ if (answer && this.store.removeSubscription(address, subscription.message_id)) {
166
+ this.#reply(address, subscription, answer);
167
+ if (subscription.notice !== false) this.#notice(address, subscription, 'idle');
168
+ }
169
+ continue;
170
+ }
171
+ const settled = ended ? 'ended' : expired ? 'expired' : idle && subscription.seen_working ? 'idle' : null;
172
+ if (settled) {
173
+ if (!this.store.removeSubscription(address, subscription.message_id)) continue;
174
+ if (subscription.final_reply) this.#reply(address, subscription, null);
175
+ const key = `${subscription.subscriber}\n${settled}`;
176
+ if (subscription.notice !== false && !told.has(key)) { told.add(key); this.#notice(address, subscription, settled); }
177
+ } else if (working && !subscription.seen_working) {
178
+ this.store.updateSubscription(address, { ...subscription, seen_working: true });
179
+ }
180
+ }
181
+ } catch (error) {
182
+ this.log(`idle notices: ${address} was not settled: ${error.message}`);
183
+ } finally {
184
+ this.settling.delete(address);
185
+ }
186
+ }
187
+
188
+ /** The runtime's answer to `messageId`: its last assistant message after the user message carrying that id, read from
189
+ * the session's own transcript through harness serve (runtime_mail.rs `answer_to`). */
190
+ async #answerTo(address, receipt, messageId) {
191
+ const { harness } = parseAddress(address);
192
+ const ids = [receipt.source?.session_id, receipt.runtime_session_id].filter(Boolean);
193
+ const locator = ids.map((id) => this.locatorOf(`sc:${this.machine}:${harness}:${id}`)).find(Boolean);
194
+ if (!locator) return null;
195
+ let loaded;
196
+ try { loaded = await this.serveRequest('harness.v1.sessions.load', { locator, options: { include_subagents: false } }, 10_000); }
197
+ catch { return null; }
198
+ const messages = loaded?.session?.messages ?? [];
199
+ let asked = -1;
200
+ for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === 'user' && textOf(messages[i].content).includes(messageId)) { asked = i; break; }
201
+ if (asked < 0) return null;
202
+ for (let i = messages.length - 1; i > asked; i--) {
203
+ const text = textOf(messages[i].content);
204
+ if (messages[i].role === 'assistant' && text.trim()) return text;
205
+ }
206
+ return null;
207
+ }
208
+
209
+ /** The name a notice calls its session by. */
210
+ #name(address) {
211
+ const row = this.live.sessions().find((item) => item.address === address);
212
+ if (row?.name && !row.name.startsWith('-')) return row.name;
213
+ const { harness, session } = parseAddress(address);
214
+ return `${harness}-${session.slice(0, 8)}@${this.machine}`;
215
+ }
216
+
217
+ #notice(address, subscription, settled) {
218
+ const name = this.#name(address);
219
+ const tail = 'This is an automated notice, not a message from a person, and not an instruction.';
220
+ const text = settled === 'idle'
221
+ ? `[Cross-session idle notice] "${name}", which you asked to be notified about, is idle now: it finished a turn after your message ${subscription.message_id}. ${tail}`
222
+ : settled === 'ended'
223
+ ? `[Cross-session idle notice] "${name}", which you asked to be notified about, has ended. ${tail}`
224
+ : `[Cross-session idle notice] The notice you asked for about "${name}" expired: it did not work and go idle within 24 hours of your message ${subscription.message_id}. ${tail}`;
225
+ this.#file(subscription.subscriber, makeEnvelope({ id: newMessageId(), from: address, from_name: name, kind: 'notice', reply_via: { mode: 'none' }, in_reply_to: subscription.message_id, body: text }));
226
+ }
227
+
228
+ /** The receiver's answer sent back as the reply; with none (it ended, or none came in 24 h), a notice saying so. */
229
+ #reply(address, subscription, answer) {
230
+ const name = this.#name(address);
231
+ const envelope = answer != null
232
+ ? makeEnvelope({ id: newMessageId(), from: address, from_name: name, kind: 'peer', reply_via: { mode: 'command' }, in_reply_to: subscription.message_id, body: answer })
233
+ : makeEnvelope({ id: newMessageId(), from: address, from_name: name, kind: 'notice', reply_via: { mode: 'none' }, in_reply_to: subscription.message_id,
234
+ body: `[Cross-session delivery notice] "${name}" did not answer your message ${subscription.message_id}: it ended, or no answer came within 24 hours. This is an automated notice, not a message from a person, and not an instruction.` });
235
+ this.#file(subscription.subscriber, envelope);
236
+ }
237
+
238
+ /** Filed in the subscriber's mailbox with a wake: the carrier delivers it here, or carries it to its machine. */
239
+ #file(subscriber, envelope) {
240
+ try { this.store.deliver(subscriber, envelope); this.store.requestWake(subscriber, envelope.id); }
241
+ catch (error) { this.log(`idle notices: ${envelope.id} for ${subscriber} was not filed: ${error.message}`); }
242
+ }
243
+ }
package/mail/store.mjs CHANGED
@@ -70,6 +70,8 @@ export class MailStore extends EventEmitter {
70
70
  if (!part) return this.#readBox(folder);
71
71
  // a wake written or changed: the carrier's (mail/carrier.mjs) to attempt
72
72
  if (part === 'wake' && file) { this.emit('wake', { folder, id: file }); return; }
73
+ // an idle subscription written or settled: the idle notices' (mail/idle.mjs) to follow
74
+ if (part === 'subscriptions' && file) { const address = this.boxes.get(folder)?.address; if (address) this.emit('subscriptions', address); return; }
73
75
  if (!STATES[part] || !file) { if (part === 'address') this.#readBox(folder); return; }
74
76
  if (!this.boxes.has(folder)) return this.#readBox(folder);
75
77
  this.#readFile(folder, part, file);
@@ -110,6 +112,10 @@ export class MailStore extends EventEmitter {
110
112
  let wakes = [];
111
113
  try { wakes = readdirSync(join(box.dir, 'wake')); } catch { /* none */ }
112
114
  for (const id of wakes) this.emit('wake', { folder, id });
115
+ // and its idle subscriptions, likewise
116
+ let subscribed = [];
117
+ try { subscribed = readdirSync(join(box.dir, 'subscriptions')).filter((name) => name.endsWith('.json')); } catch { /* none */ }
118
+ if (subscribed.length) this.emit('subscriptions', address);
113
119
  }
114
120
 
115
121
  /** One envelope file read again on its event: held while it is there, let go once it is gone. */
@@ -461,6 +467,35 @@ export class MailStore extends EventEmitter {
461
467
 
462
468
  /** One sender's wish to hear when the receiver next ends a turn (mailbox.rs `subscribe_idle`); the native watch settles it
463
469
  * until it moves into the daemon (step 4). */
470
+ /** The idle subscriptions on `address` (mailbox.rs `subscriptions`): each sender that asked for a notice, or for the
471
+ * receiver's final message as its reply, about one message. */
472
+ subscriptions(address) {
473
+ const box = this.boxOf(address) ?? this.#named(address);
474
+ if (!box) return [];
475
+ let names = [];
476
+ try { names = readdirSync(join(box.dir, 'subscriptions')); } catch { return []; }
477
+ return names.filter((name) => name.endsWith('.json')).map((name) => readEnvelope(join(box.dir, 'subscriptions', name))).filter((item) => item && typeof item.message_id === 'string');
478
+ }
479
+
480
+ /** A subscription settled (mailbox.rs `remove_subscription`): true for the one caller that removed it, so its notice is
481
+ * sent once whoever else settles it at the same time. */
482
+ removeSubscription(address, messageId) {
483
+ const box = this.boxOf(address) ?? this.#named(address);
484
+ if (!box || !/^[A-Za-z0-9_-]+$/.test(messageId)) return false;
485
+ try { unlinkSync(join(box.dir, 'subscriptions', `${messageId}.json`)); return true; } catch { return false; }
486
+ }
487
+
488
+ /** A subscription kept with what it has seen (its receiver seen working after the message). */
489
+ updateSubscription(address, subscription) {
490
+ const box = this.boxOf(address) ?? this.#named(address);
491
+ if (!box || !/^[A-Za-z0-9_-]+$/.test(subscription.message_id)) return;
492
+ const dir = join(box.dir, 'subscriptions');
493
+ if (!existsSync(join(dir, `${subscription.message_id}.json`))) return; // settled meanwhile
494
+ const temporary = join(dir, `.${subscription.message_id}.tmp`);
495
+ writeFileSync(temporary, JSON.stringify(subscription));
496
+ renameSync(temporary, join(dir, `${subscription.message_id}.json`));
497
+ }
498
+
464
499
  subscribeIdle(address, subscription) {
465
500
  const box = this.open(address);
466
501
  const dir = join(box.dir, 'subscriptions');
@@ -12,7 +12,7 @@ import { blake3Hex } from './envelope.mjs';
12
12
  const YIELD_LINES = 2000;
13
13
  const yieldTurn = () => new Promise((resolve) => setImmediate(resolve));
14
14
 
15
- const TYPED_ID_PREFIX = 'u-';
15
+ export const TYPED_ID_PREFIX = 'u-';
16
16
  const CHANNEL_ID_PREFIX = 'c-';
17
17
  const ANSWER_ID_PREFIX = 'a-';
18
18
 
@@ -0,0 +1,143 @@
1
+ // An agent's upkeep (step 4d; mail_agent.rs `register_folder_sessions`, `copy_typed_lines`, `close_idle_threads`, which
2
+ // the native `message watch` ran every ten seconds, deleted with it):
3
+ // - a session started directly in an agent's folder becomes one of its threads (decision 9);
4
+ // - each line a person typed into an agent's session is copied, from no one (D195), to whoever is CC on it: a delegated
5
+ // thread's holder's lines to its CC participants (decision 11), every agent session's lines to the owner's account
6
+ // manager (decision 17), filed unread and not woken;
7
+ // - a thread session the mailbox launched that has been idle past its agent's `idle_minutes` is ended (`supercode
8
+ // close`); its transcript stays, and the next reply in its thread resumes it.
9
+ // The first and the last follow the live map's changes; the copying passes every TYPED_EVERY_MS while any agent is
10
+ // declared, reading only a transcript written since its cursor.
11
+ import { spawn } from 'node:child_process';
12
+ import { readFileSync, statSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { blake3Hex } from './envelope.mjs';
15
+ import { envelope as makeEnvelope, makeAddress } from './compose.mjs';
16
+ import { TYPED_ID_PREFIX, readClaude, transcriptPath, typedLineId } from './transcript.mjs';
17
+ import { writeAtomically } from './agents.mjs';
18
+
19
+ const SESSION_THREAD_PREFIX = 's-';
20
+ const TYPED_EVERY_MS = 10_000;
21
+
22
+ export class AgentUpkeep {
23
+ /** `agents` the agents' records; `live` the live-session map; `lastMessageAt(address)` when a session last spoke (its
24
+ * index row); `program` the supercode a session is ended with. */
25
+ constructor({ machine, mailRoot, store, agents, live, claudeProjects, lastMessageAt, program, env, log = () => {} }) {
26
+ Object.assign(this, { machine, mailRoot, store, agents, live, claudeProjects, lastMessageAt, program, env, log });
27
+ this.typedDir = join(mailRoot, 'agents', 'typed');
28
+ this.running = new Set();
29
+ }
30
+
31
+ start() {
32
+ let pending = null;
33
+ this.live.on('changed', () => {
34
+ if (pending) return;
35
+ pending = setTimeout(() => { pending = null; void this.#once('sessions', () => this.registerFolderSessions().then(() => this.closeIdleThreads())); }, 1000);
36
+ pending.unref?.();
37
+ });
38
+ this.timer = setInterval(() => void this.#once('typed', () => this.copyTypedLines()), TYPED_EVERY_MS);
39
+ this.timer.unref?.();
40
+ // an idle thread's bound passes with no change to the map: checked once a minute as well
41
+ this.idleTimer = setInterval(() => void this.#once('sessions', () => this.closeIdleThreads()), 60_000);
42
+ this.idleTimer.unref?.();
43
+ return this;
44
+ }
45
+
46
+ stop() { clearInterval(this.timer); clearInterval(this.idleTimer); }
47
+
48
+ async #once(key, work) {
49
+ if (this.running.has(key)) return;
50
+ this.running.add(key);
51
+ try { await work(); } catch (error) { this.log(`agent upkeep: ${key}: ${error.message}`); } finally { this.running.delete(key); }
52
+ }
53
+
54
+ /** A session started directly in an agent's folder, not its main session and holding no thread yet, becomes a thread
55
+ * of that agent: it holds it (To), the agent's main session is CC. */
56
+ async registerFolderSessions() {
57
+ const agents = this.agents.all().filter((agent) => agent.folder);
58
+ if (!agents.length) return;
59
+ const holders = new Set(this.agents.threads().map((thread) => thread.holder));
60
+ for (const session of this.live.sessions()) {
61
+ if (!session.cwd) continue;
62
+ const agent = agents.find((item) => item.folder === session.cwd && item.main_session !== session.address);
63
+ if (!agent || holders.has(session.address)) continue;
64
+ const id = `${SESSION_THREAD_PREFIX}${session.address.split(':').slice(3).join(':')}`;
65
+ await this.agents.update(id, () => ({ id, agent: agent.name, holder: session.address,
66
+ participants: [{ address: session.address, role: 'to' }, { address: agent.main_session, role: 'cc' }],
67
+ markers: {}, surface: null, created_at_ms: Date.now(), holder_launched: false, parent: null }), () => {});
68
+ holders.add(session.address);
69
+ }
70
+ }
71
+
72
+ #cursorPath(address) { return join(this.typedDir, blake3Hex(address).slice(0, 24)); }
73
+
74
+ /** Each line typed into an agent's session since the last pass, copied to who is CC on it; a session seen the first
75
+ * time starts from now, so its history is not copied. */
76
+ async copyTypedLines() {
77
+ const agents = this.agents.all();
78
+ if (!agents.length) return;
79
+ const threads = this.agents.threads();
80
+ const accountManager = this.agents.ownersAccountManager();
81
+ const sessions = agents.map((agent) => [agent.main_session, agent.name]);
82
+ for (const thread of threads) if (!sessions.some(([address]) => address === thread.holder)) sessions.push([thread.holder, thread.agent]);
83
+ const now = Date.now();
84
+ for (const [session, agentName] of sessions) {
85
+ const cursorPath = this.#cursorPath(session);
86
+ let cursor = null;
87
+ try { cursor = Number(readFileSync(cursorPath, 'utf8').trim()); } catch { /* first seen */ }
88
+ if (!Number.isFinite(cursor)) { writeAtomically(cursorPath, String(now)); continue; }
89
+ // a transcript not written since the last pass has no new line
90
+ const path = transcriptPath(session, { claudeProjects: this.claudeProjects(), mailRoot: this.mailRoot });
91
+ if (path) { try { if (statSync(path).mtimeMs <= cursor) continue; } catch { /* not there: nothing new */ } }
92
+ // a Claude session's lines are read from its transcript; a Codex session's were filed in its mailbox by its hook
93
+ let lines;
94
+ if (session.split(':')[2] === 'codex') {
95
+ lines = this.store.inventory(session).stored
96
+ .filter((item) => item.envelope.kind === 'user' && item.envelope.id.startsWith(TYPED_ID_PREFIX) && item.envelope.created_at_ms > cursor)
97
+ .map((item) => { const whole = this.store.body(item); return [item.envelope.created_at_ms, whole?.body ?? '']; });
98
+ } else {
99
+ if (!path) continue;
100
+ const mail = await readClaude(path).catch(() => null);
101
+ if (!mail) continue;
102
+ lines = mail.typed.filter((line) => !line.withdrawn && line.sent_at_ms > cursor).map((line) => [line.sent_at_ms, line.text]);
103
+ }
104
+ if (!lines.length) continue;
105
+ const newest = Math.max(...lines.map(([at]) => at));
106
+ // the newest thread this session holds as a delegate (its agent's main session is another: mail_agent.rs `delegated`)
107
+ const held = [...threads].reverse().find((thread) => thread.holder === session && this.agents.load(thread.agent)?.main_session !== thread.holder);
108
+ const receivers = held ? held.participants.filter((p) => p.role === 'cc' && p.address !== session).map((p) => p.address) : [];
109
+ if (accountManager && agentName !== accountManager.name && !receivers.includes(accountManager.main_session)) receivers.push(accountManager.main_session);
110
+ const from = makeAddress(session.split(':')[1], 'operator', 'typed');
111
+ for (const [sentAt, text] of lines) {
112
+ const copy = makeEnvelope({ id: typedLineId(session, sentAt, text), created_at_ms: sentAt, from, from_name: `typed into ${session} (unattributed)`,
113
+ kind: 'typed', reply_via: { mode: 'none' }, thread: held?.id ?? null, body: text });
114
+ // a copy that cannot be filed is said, never dropped in silence
115
+ for (const receiver of receivers) { try { this.store.deliver(receiver, copy); } catch (error) { this.log(`agent upkeep: the line typed into ${session} was not copied to ${receiver}: ${error.message}`); } }
116
+ }
117
+ writeAtomically(cursorPath, String(newest));
118
+ }
119
+ }
120
+
121
+ /** A thread session the mailbox launched, idle past its agent's `idle_minutes` since its last message (or its last
122
+ * resumption), ended through `supercode close`. */
123
+ async closeIdleThreads() {
124
+ const launched = this.agents.threads().filter((thread) => thread.holder_launched)
125
+ .map((thread) => [thread, this.agents.load(thread.agent)?.idle_minutes]).filter(([, minutes]) => Number.isFinite(minutes));
126
+ if (!launched.length) return;
127
+ const live = this.live.sessions();
128
+ const now = Date.now();
129
+ for (const [thread, minutes] of launched) {
130
+ const session = live.find((row) => row.address === thread.holder);
131
+ if (!session || session.status !== 'idle') continue;
132
+ const last = this.lastMessageAt(thread.holder);
133
+ if (!Number.isFinite(last)) continue;
134
+ let resumed = 0;
135
+ try { resumed = Number(readFileSync(join(this.mailRoot, 'agents', 'resumed', blake3Hex(thread.holder).slice(0, 24)), 'utf8').trim()) || 0; } catch { /* never resumed */ }
136
+ if (now - Math.max(last, resumed) < minutes * 60_000) continue;
137
+ await new Promise((done) => {
138
+ const child = spawn(this.program, ['close', thread.holder], { env: this.env, stdio: 'ignore' });
139
+ child.on('exit', done); child.on('error', (error) => { this.log(`agent upkeep: ${thread.holder} was not closed: ${error.message}`); done(); });
140
+ });
141
+ }
142
+ }
143
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.3.122",
3
+ "version": "0.3.124",
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",
package/worker/index.mjs CHANGED
@@ -143,15 +143,42 @@ export class TeamsServer extends DurableObject {
143
143
  }
144
144
  }
145
145
 
146
+ /** What the Durable Object's runtime said of a failed call (its error's own flags), for the log and the answer. */
147
+ const objectFailure = (error) => ({
148
+ name: String(error?.name ?? 'Error'),
149
+ message: String(error?.message ?? error).slice(0, 200),
150
+ retryable: error?.retryable === true,
151
+ overloaded: error?.overloaded === true,
152
+ remote: error?.remote === true,
153
+ reset: error?.durableObjectReset === true,
154
+ });
155
+
146
156
  export default {
147
157
  async fetch(request, env) {
148
- try { return await env.TEAMS_SERVER.get(env.TEAMS_SERVER.idFromName('server')).fetch(request); }
158
+ const repeatable = request.method === 'GET' || request.method === 'HEAD';
159
+ const call = () => env.TEAMS_SERVER.get(env.TEAMS_SERVER.idFromName('server')).fetch(repeatable ? request.clone() : request);
160
+ // A request safe to repeat (a read, or a link's WebSocket upgrade: every machine's connection) is tried once more
161
+ // when the runtime says the failure is retryable and the object is not overloaded: a reset object (its code
162
+ // updated, or evicted and restarted) answers the second call. Fleet-wide 503s for minutes with no deploy behind
163
+ // them (2026-10-06, 14:04Z and 18:41-18:44Z) dropped every machine's link; what the runtime said of them was only
164
+ // in the log, which now says it in one JSON line and the answer names it.
165
+ let first = null;
166
+ try { return await call(); }
149
167
  catch (error) {
150
- // Failures before the HTTP handler (object initialization, migrations or
151
- // storage) otherwise become a platform HTML page, not our API contract.
152
- const requestId = crypto.randomUUID();
153
- console.error(`Teams request ${requestId} failed before response: ${error.message}`);
154
- return Response.json({ version: 1, error: { code: 'unavailable', message: 'Teams server is temporarily unavailable', request_id: requestId, retryable: true } }, { status: 503, headers: { 'Retry-After': '5', 'X-Request-ID': requestId } });
168
+ first = objectFailure(error);
169
+ if (!(repeatable && first.retryable && !first.overloaded)) return unavailable(first, null);
155
170
  }
171
+ try { return await call(); }
172
+ catch (error) { return unavailable(objectFailure(error), first); }
156
173
  },
157
174
  };
175
+
176
+ /** Failures before the HTTP handler (object initialization, migrations, storage, a reset) as our API contract, not a
177
+ * platform HTML page. The answer goes to callers not yet authenticated, so it names only the error's kind and the
178
+ * runtime's flags; its message (storage or SQL text, internal paths) is in the log line its request id leads to. */
179
+ function unavailable(failure, retried) {
180
+ const requestId = crypto.randomUUID();
181
+ console.error(JSON.stringify({ event: 'teams_unavailable', request_id: requestId, failure, ...(retried ? { first: retried } : {}) }));
182
+ const cause = `${failure.name}${failure.reset ? ', object reset' : ''}${failure.overloaded ? ', overloaded' : ''}${retried ? ', after one retry' : ''}`;
183
+ return Response.json({ version: 1, error: { code: 'unavailable', message: `Teams server is temporarily unavailable (${cause})`, request_id: requestId, retryable: true, cause: { name: failure.name, retryable: failure.retryable, overloaded: failure.overloaded, remote: failure.remote, reset: failure.reset } } }, { status: 503, headers: { 'Retry-After': failure.overloaded ? '15' : '5', 'X-Request-ID': requestId } });
184
+ }