@volter/supercode-teams 0.3.123 → 0.3.125

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 CHANGED
@@ -31,6 +31,7 @@
31
31
  // CLI in ./workspace.mjs.
32
32
  import { appendFileSync, createWriteStream, readFileSync, writeFileSync } from 'node:fs';
33
33
  import { spawnSync } from 'node:child_process';
34
+ import { resolve } from 'node:path';
34
35
  import { fileURLToPath, pathToFileURL } from 'node:url';
35
36
  import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
36
37
  import { noteStep, rotatedLogs, slowLogPath, timeCommand } from '@volter/supercode-harness-sdk/slow-log';
@@ -195,7 +196,15 @@ async function main() {
195
196
  }
196
197
  // `machine restart [--machine M] [--reason text]`: the daemon (this one, or M's through the server) restarts under its service.
197
198
  if (sub === 'restart') return withNode(async (rpc) => out(await rpc.call('machine.restart', { reason: flags.reason ? String(flags.reason) : null })));
198
- die('machine start|status|restart');
199
+ // `machine boards [list]` and `machine boards unregister <root>`: the board homes this machine's connector follows
200
+ // and publishes, and one no longer followed (a proof's scratch home: t_f7b219b8)
201
+ if (sub === 'boards') {
202
+ const [action = 'list', root] = rest;
203
+ if (action === 'list') return withNode(async (rpc) => out(await rpc.call('harness.v1.boards.list', {})));
204
+ if (action === 'unregister' && root) return withNode(async (rpc) => out(await rpc.call('harness.v1.boards.unregister', { root: resolve(root) })));
205
+ die('machine boards [list] | machine boards unregister <root>');
206
+ }
207
+ die('machine start|status|restart|boards');
199
208
  }
200
209
  case 'slow': {
201
210
  // `slow [--since 1h|30m|<iso>]`: the slow log on this machine, grouped by what was slow (kind and name), the
package/bin/workspace.mjs CHANGED
@@ -1477,7 +1477,9 @@ function stampStderr() {
1477
1477
  const decoder = new StringDecoder('utf8');
1478
1478
  let atStart = true;
1479
1479
  process.stderr.write = (chunk, ...rest) => {
1480
- const text = typeof chunk === 'string' ? chunk : decoder.write(Buffer.from(chunk));
1480
+ // a string written while a Buffer's split character is pending ends that character first (as U+FFFD, the bytes
1481
+ // it had), so the bytes are never held back to land after the string
1482
+ const text = typeof chunk === 'string' ? decoder.end() + chunk : decoder.write(Buffer.from(chunk));
1481
1483
  // an empty write (or a Buffer holding only part of a character) changes no line
1482
1484
  if (!text) return write(text, ...rest);
1483
1485
  const stamp = new Date().toISOString();
@@ -1497,7 +1499,10 @@ async function releaseSkills() {
1497
1499
  const release = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
1498
1500
  try {
1499
1501
  if (JSON.parse(readFileSync(join(release, 'package.json'), 'utf8')).name !== '@volter/supercode') return [];
1500
- const { installSkills } = await import(pathToFileURL(join(release, 'lib', 'skills.mjs')).href);
1502
+ const { installSkills, notThisHomesOwner } = await import(pathToFileURL(join(release, 'lib', 'skills.mjs')).href);
1503
+ // only as the home's owner (a `sudo -E` connect would leave root-owned skill folders in the user's harness homes)
1504
+ const refused = notThisHomesOwner?.();
1505
+ if (refused) return [`skills not installed (${refused}); run \`supercode skills install\` as the home's owner`];
1501
1506
  return installSkills();
1502
1507
  } catch { return []; }
1503
1508
  }
package/board-events.mjs CHANGED
@@ -5,8 +5,8 @@
5
5
  // Each board home's cursor is kept beside the connector, so a restart resumes where it left off; a published event's id
6
6
  // is derived from its server, team, source and publication, so a follower that starts over republishes the same ids.
7
7
  import { spawn } from 'node:child_process';
8
- import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
9
- import { join } from 'node:path';
8
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
9
+ import { join, resolve } from 'node:path';
10
10
  import { createHash } from 'node:crypto';
11
11
  import { createInterface } from 'node:readline';
12
12
  import { TeamsClient, retryAfterMs, spentClause } from '@volter/teams/http-client';
@@ -91,6 +91,23 @@ export class BoardEvents {
91
91
  }
92
92
  return { root, registered: true, refused: this.refused };
93
93
  }
94
+ /**
95
+ * A home no longer followed: its follower ended for good and its record dropped, so no restart brings it back
96
+ * (t_f7b219b8: 16 scratch proof homes on aarons-mac-mini, each with its own follower, after every daemon start). By an
97
+ * operator (`supercode teams machine boards unregister <root>`), or here for a home whose folder is gone. Its cards
98
+ * already published stay on the team.
99
+ */
100
+ unregister(path, why = 'unregistered') {
101
+ let root = resolve(String(path));
102
+ try { root = realpathSync(root); } catch { /* gone: named as it was registered */ }
103
+ if (!this.state.boards[root]) return { root, unregistered: false };
104
+ delete this.state.boards[root]; delete this.state.sources[root]; this.save();
105
+ this.waits.delete(root);
106
+ const child = this.followers.get(root);
107
+ if (child) { child.gone = true; end(child); }
108
+ this.log(`board events of ${root}: no longer followed (${why})`);
109
+ return { root, unregistered: true };
110
+ }
94
111
  start() {
95
112
  this.started = true;
96
113
  for (const root of Object.keys(this.state.boards)) this.follow(root);
@@ -101,6 +118,9 @@ export class BoardEvents {
101
118
  follow(root) {
102
119
  if (this.followers.has(root) || this.stopped || this.refused) return;
103
120
  const board = this.state.boards[root];
121
+ if (!board) return; // unregistered while it waited
122
+ // a home whose folder is gone (a proof's scratch home, removed) has no board left to follow: dropped, not retried
123
+ if (!existsSync(root)) { this.unregister(root, 'its folder is gone'); return; }
104
124
  // In a process group of its own (end): the follower is the native binary, the orchestrator it runs and the one that
105
125
  // hands the line to, and ending the first alone left the others running, holding its output open.
106
126
  const child = spawn(this.binary, ['workflow', 'events', '--root', root, ...(board.cursor ? ['--after', board.cursor] : [])], { env: this.env, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
@@ -148,7 +168,7 @@ export class BoardEvents {
148
168
  child.on('close', async (code) => {
149
169
  await frames;
150
170
  this.followers.delete(root);
151
- if (this.stopped || this.refused) return;
171
+ if (this.stopped || this.refused || child.gone) return;
152
172
  if (child.again) { this.follow(root); return; } // registered again: on the orchestrator the home names now
153
173
  // its last lines, a stack among them when the store's own error ended it (orchestrator.mjs)
154
174
  if (code && !board.error) this.log(`board events of ${root}: workflow events ended (${code}): ${stderr.trim().split('\n').slice(-12).join('\n ')}`);
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);
@@ -3959,11 +3990,15 @@ export class TeamsMachine extends MachineCore {
3959
3990
  if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('a caller is resolved only for a process on this machine'), { code: 403 });
3960
3991
  return { caller: await this.#callerOf(entry, params) };
3961
3992
  }
3962
- case 'harness.v1.boards.register': case 'harness.v1.boards.list': {
3993
+ case 'harness.v1.boards.register': case 'harness.v1.boards.list': case 'harness.v1.boards.unregister': {
3963
3994
  // A board home this machine keeps, published to its team by its connector (board-events.mjs).
3964
3995
  if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
3965
3996
  if (!this.boardEvents) throw Object.assign(new Error('this daemon serves no team: a board is published by a connected machine (`supercode teams connect`)'), { code: 409 });
3966
3997
  if (method === 'harness.v1.boards.list') return this.boardEvents.list();
3998
+ if (method === 'harness.v1.boards.unregister') {
3999
+ if (typeof params.root !== 'string' || !params.root) throw Object.assign(new Error('name the board home (root)'), { code: 400 });
4000
+ return this.boardEvents.unregister(params.root);
4001
+ }
3967
4002
  if (typeof params.root !== 'string' || !params.root) throw Object.assign(new Error('name the board home (root)'), { code: 400 });
3968
4003
  const registered = this.boardEvents.register(params.root);
3969
4004
  // The home's board owner acts as its boards: the caller it names for a board of that home is taken from it
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.123",
3
+ "version": "0.3.125",
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.34",
73
+ "@volter/teams": "0.2.35",
74
74
  "@xterm/addon-serialize": "^0.14.0",
75
75
  "@xterm/headless": "^6.0.0",
76
76
  "protobufjs": "8.8.0",
package/worker/index.mjs CHANGED
@@ -145,7 +145,7 @@ export class TeamsServer extends DurableObject {
145
145
 
146
146
  /** What the Durable Object's runtime said of a failed call (its error's own flags), for the log and the answer. */
147
147
  const objectFailure = (error) => ({
148
- name: String(error?.name ?? 'Error'),
148
+ name: String(error?.name ?? 'Error').slice(0, 100),
149
149
  message: String(error?.message ?? error).slice(0, 200),
150
150
  retryable: error?.retryable === true,
151
151
  overloaded: error?.overloaded === true,
@@ -173,12 +173,16 @@ export default {
173
173
  },
174
174
  };
175
175
 
176
+ /** The error names an unauthenticated caller is told; any other (a library's or the runtime's own) is said as
177
+ * `Error`, and is in the log line only (#1246 review). */
178
+ const PUBLIC_NAMES = new Set(['Error', 'TypeError', 'RangeError', 'ReferenceError', 'SyntaxError', 'AbortError', 'TimeoutError', 'DOMException']);
179
+
176
180
  /** Failures before the HTTP handler (object initialization, migrations, storage, a reset) as our API contract, not a
177
181
  * platform HTML page. The answer goes to callers not yet authenticated, so it names only the error's kind and the
178
182
  * runtime's flags; its message (storage or SQL text, internal paths) is in the log line its request id leads to. */
179
183
  function unavailable(failure, retried) {
180
184
  const requestId = crypto.randomUUID();
181
185
  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 } });
186
+ const cause = `${PUBLIC_NAMES.has(failure.name) ? failure.name : 'Error'}${failure.reset ? ', object reset' : ''}${failure.overloaded ? ', overloaded' : ''}${retried ? ', after one retry' : ''}`;
187
+ return Response.json({ version: 1, error: { code: 'unavailable', message: `Teams server is temporarily unavailable (${cause})`, request_id: requestId, retryable: true, cause: { name: PUBLIC_NAMES.has(failure.name) ? failure.name : 'Error', 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
188
  }