@volter/supercode-teams 0.3.86 → 0.3.87

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
@@ -416,6 +416,22 @@ export class LiveSessions extends EventEmitter {
416
416
  return null;
417
417
  }
418
418
 
419
+ /**
420
+ * The live-runtime receipt named `receiptId`, read from its own file (`<live-runtimes>/<receiptId>.json`) and taken
421
+ * into the map: a runtime whose receipt the watch has not delivered yet (its event late or lost) is read by name, never
422
+ * waited on or listed for.
423
+ */
424
+ receiptNamed(receiptId) {
425
+ if (typeof receiptId !== 'string' || !/^[0-9a-f]+$/i.test(receiptId)) return null;
426
+ const receipt = readReceiptFile(join(this.receiptsDir, `${receiptId}.json`));
427
+ if (!receipt || receipt.receipt_id !== receiptId || typeof receipt.source?.harness !== 'string' || typeof receipt.source?.session_id !== 'string') return null;
428
+ if (!this.runtimes.has(receiptId)) {
429
+ this.runtimes.set(receiptId, { pid: receipt.pid, harness: receipt.source.harness, sessionId: receipt.source.session_id, runtimeSessionId: typeof receipt.runtime_session_id === 'string' ? receipt.runtime_session_id : null, cwd: typeof receipt.source.workspace === 'string' ? receipt.source.workspace : null });
430
+ this.#changed();
431
+ }
432
+ return receipt;
433
+ }
434
+
419
435
  /** A session the index holds that is not live: mail to it is stored until it is next active, never refused. */
420
436
  isIndexedNotLive(address) {
421
437
  const [, machine, harness, sessionId] = String(address).split(':');
package/machine.mjs CHANGED
@@ -39,6 +39,7 @@ import { MailReads, panePrompt } from './mail/reads.mjs';
39
39
  import { Agents } from './mail/agents.mjs';
40
40
  import { Relays } from './mail/relay.mjs';
41
41
  import { MailSend } from './mail/send.mjs';
42
+ import { AgentVerbs } from './mail/verbs.mjs';
42
43
  import { deliverToRuntime } from './mail/runtime.mjs';
43
44
  import { envelope as envelopeOf, linkOf, parseAddress, senderIdentity } from './mail/compose.mjs';
44
45
  import { codexQuestions } from './native-questions.mjs';
@@ -148,6 +149,19 @@ const LAUNCH_SETTLE_MS = 500;
148
149
  /** How long a send to another machine waits for its mail door's answer (its receiver's door there: a relay's receipt
149
150
  * alone may take 90 s). An answer that does not come is unanswered, never refused or failed. */
150
151
  const MAIL_SEND_MS = 120_000;
152
+ /**
153
+ * The doors whose work waits on a session, with the bound each answers within (the node door's own is 30 s): a send or
154
+ * a hand-over waits on its receiver's door (a relay's receipt, up to 90 s; another machine's door, MAIL_SEND_MS), a
155
+ * delegation or a declaration that starts a session waits for it to appear (up to a minute). Past its bound a call is
156
+ * answered 504, which its client reads as unanswered: the work may still finish.
157
+ */
158
+ const DOOR_BOUNDS_MS = {
159
+ 'harness.v1.mail.send': 170_000,
160
+ 'harness.v1.mail.message': 170_000,
161
+ 'harness.v1.mail.handover': 140_000,
162
+ 'harness.v1.mail.delegate': 110_000,
163
+ 'harness.v1.agent.declare': 110_000,
164
+ };
151
165
  /** How many typed lines' keys a machine remembers; a server retries a line for minutes, not thousands of lines. */
152
166
  const TURN_KEYS_KEPT = 1000;
153
167
  /** How long a typed line's door gets to confirm it: a loaded machine's reader types it in seconds and answers late. */
@@ -628,11 +642,14 @@ export class TeamsMachine extends MachineCore {
628
642
  discover: (params) => this.#serveRequest('harness.v1.sessions.discover', params, 10_000),
629
643
  recordedConfig: (locator) => this.#serveRequest('harness.v1.sessions.recorded_config', { locator }, 15_000) });
630
644
  this.relays = new Relays({ mailRoot, machine, supercodeBin: this.supercodeBin, serve: (method, params, timeoutMs) => this.#serveRequest(method, params, timeoutMs),
631
- runtimeOf: (harness, id) => live.runtimeReceipt(harness, id) });
645
+ runtimeOf: (harness, id) => live.runtimeReceipt(harness, id), runtimeNamed: (receiptId) => live.receiptNamed(receiptId) });
632
646
  this.mailSend = new MailSend({ machine, mailRoot, live, store: this.mailStore, agents: this.agents, relays: this.relays,
633
647
  // a send waits on its receiver's door there (a relay's receipt takes up to 90 s), never past MAIL_SEND_MS
634
648
  askMachine: (to, request) => this.#askMailDoor(to, request, MAIL_SEND_MS),
635
649
  deliverRuntime: deliverToRuntime, codexAnswer: (request) => codexQuestions(request) });
650
+ // an agent's verbs (3b-2): its threads, a delegation, its record
651
+ this.agentVerbs = new AgentVerbs({ machine, store: this.mailStore, agents: this.agents, live, supercodeBin: this.supercodeBin, env: this.env,
652
+ paneOf: (session) => this.mail.paneOf(session), mailRoot });
636
653
  // the names sessions ran under (`<mail>/names.json`): a name an agent was given reaches the same session after it
637
654
  // runs under another, kept from the map's changes (at most once a second)
638
655
  let naming = null;
@@ -2470,8 +2487,10 @@ export class TeamsMachine extends MachineCore {
2470
2487
  const reply = (envelope) => { if (stream.closed) return; try { stream.write(JSON.stringify(envelope) + '\n'); } catch { /* peer gone */ } };
2471
2488
  try {
2472
2489
  // Every door answers or fails within a bound; a hung substrate call becomes an error, never silence.
2473
- // The caller may shorten the bound, never lengthen it past the daemon's own 30 s.
2474
- const bound = Math.min(Number(request.params?.timeoutMs) || 30_000, 30_000);
2490
+ // The caller may shorten the bound, never lengthen it past the daemon's own: 30 s, or the longer one a door
2491
+ // that waits on a session states (DOOR_BOUNDS_MS), each above its own inner waits.
2492
+ const own = DOOR_BOUNDS_MS[request.method] ?? 30_000;
2493
+ const bound = Math.min(Number(request.params?.timeoutMs) || own, own);
2475
2494
  const result = await withTimeout(this.#dispatch(entry, request.method, request.params ?? {}), bound, request.method);
2476
2495
  reply({ id: request.id, result });
2477
2496
  this.emit('door', { method: request.method, ms: Date.now() - startedAt, outcome: 'ok' });
@@ -3242,6 +3261,33 @@ export class TeamsMachine extends MachineCore {
3242
3261
  act('mail.send', to, answer.code === 0 ? 'ok' : 'failed');
3243
3262
  return answer;
3244
3263
  }
3264
+ // An agent's verbs (docs/architecture/overview.md, 3b-2): `message thread`, `threads`, `delegate`, and `supercode
3265
+ // agent declare|show`, this machine's own processes only; a delegation and a thread list without --agent are the
3266
+ // caller's, as its claim names it.
3267
+ case 'harness.v1.mail.thread': case 'harness.v1.mail.threads': case 'harness.v1.mail.delegate': case 'harness.v1.agent.declare': case 'harness.v1.agent.show': {
3268
+ if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('an agent\'s verbs are answered here for this machine\'s own processes'), { code: 403 });
3269
+ const text = (key) => (typeof params[key] === 'string' && params[key] ? params[key] : null);
3270
+ if (method === 'harness.v1.mail.thread') {
3271
+ if (!text('id')) throw Object.assign(new Error('thread names a message id'), { code: 400 });
3272
+ return this.agentVerbs.thread(text('id'));
3273
+ }
3274
+ if (method === 'harness.v1.agent.show') return this.agentVerbs.show(text('name'));
3275
+ if (method === 'harness.v1.agent.declare') {
3276
+ if (!text('name')) throw Object.assign(new Error('declare names its agent'), { code: 400 });
3277
+ const answer = await this.agentVerbs.declare({ name: text('name'), main: text('main'), open: text('open'), input: text('input'), folder: text('folder'), harness: text('harness'),
3278
+ idle_minutes: Number.isInteger(params.idle_minutes) && params.idle_minutes >= 0 ? params.idle_minutes : null, owners_account_manager: params.owners_account_manager === true });
3279
+ act('agent.declare', text('name'), answer.code === 0 ? 'ok' : 'failed');
3280
+ return answer;
3281
+ }
3282
+ let caller = null;
3283
+ try { caller = await this.#callerOf(entry, params); } catch (error) { if (method === 'harness.v1.mail.delegate') throw error; }
3284
+ if (method === 'harness.v1.mail.threads') return this.agentVerbs.threads(text('agent'), typeof caller === 'string' ? caller : null);
3285
+ if (typeof caller !== 'string' || !caller) return { code: 2, text: "Can't tell which session is running this command, so replies would have nowhere to go. Nothing was sent. Run it from your agent session's own shell tool." };
3286
+ if (!text('root')) throw Object.assign(new Error('delegate names its root'), { code: 400 });
3287
+ const answer = await this.agentVerbs.delegate(caller, text('root'), { folder: text('folder'), harness: text('harness') });
3288
+ act('mail.delegate', caller, answer.code === 0 ? 'ok' : 'failed');
3289
+ return answer;
3290
+ }
3245
3291
  // A filed message handed to its session's door (the native watch's carrier and a relay's `message push`, until
3246
3292
  // step 4): once, by its id, recorded on its wake.
3247
3293
  case 'harness.v1.mail.handover': {
package/mail/agents.mjs CHANGED
@@ -8,7 +8,7 @@ import { closeSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync,
8
8
  import { spawn } from 'node:child_process';
9
9
  import { join } from 'node:path';
10
10
  import { blake3Hex } from './envelope.mjs';
11
- import { cameThroughLink, parseAddress } from './compose.mjs';
11
+ import { cameThroughLink, parseAddress, threadOf } from './compose.mjs';
12
12
 
13
13
  /** The pseudo-harness of an agent address. */
14
14
  export const AGENT_HARNESS = 'agent';
@@ -64,10 +64,18 @@ export class Agents {
64
64
 
65
65
  /** Every thread, oldest first. */
66
66
  threads() {
67
+ // Read once and kept while the folder is unchanged: every writer (this daemon, and mail_agent.rs's in the native
68
+ // watch until step 4) renames a whole record into it, which moves the folder's time. One stat a call, not a read of
69
+ // every record: a send's plan asks this on every send, and an agent's threads grow with its history.
70
+ let changed;
71
+ try { changed = statSync(this.threadsDir).mtimeMs; } catch { return []; }
72
+ if (this.threadsCache?.changed === changed) return this.threadsCache.list.map((thread) => structuredClone(thread));
67
73
  let names = [];
68
74
  try { names = readdirSync(this.threadsDir); } catch { return []; }
69
- return names.filter((name) => name.endsWith('.json')).map((name) => readJson(join(this.threadsDir, name))).filter((thread) => thread?.id)
75
+ const list = names.filter((name) => name.endsWith('.json')).map((name) => readJson(join(this.threadsDir, name))).filter((thread) => thread?.id)
70
76
  .sort((a, b) => (a.created_at_ms - b.created_at_ms) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
77
+ this.threadsCache = { changed, list };
78
+ return list.map((thread) => structuredClone(thread));
71
79
  }
72
80
 
73
81
  /** The thread `address` holds for an agent other than as its main session (mail_agent.rs `thread_held_by`). */
@@ -135,6 +143,63 @@ export class Agents {
135
143
  }
136
144
  }
137
145
 
146
+ /** Record `agent`, replacing an earlier record of the same name (mail_agent.rs `declare`): its fields in its order, an
147
+ * empty optional left out. */
148
+ declare(agent) {
149
+ if (!validName(agent.name)) throw new Error(`\`${agent.name}\` is not an agent name: use letters, digits, \`-\`, \`_\` and \`.\``);
150
+ const out = { name: agent.name, main_session: agent.main_session };
151
+ if (agent.folder != null) out.folder = agent.folder;
152
+ if (agent.harness != null) out.harness = agent.harness;
153
+ if (agent.idle_minutes != null) out.idle_minutes = agent.idle_minutes;
154
+ writeAtomically(join(this.agentsDir, `${agent.name}.json`), JSON.stringify(out, null, 2));
155
+ }
156
+
157
+ /** Name `name` the owner's account manager: every line the owner writes to any agent is CC'd to its main session. */
158
+ setOwnersAccountManager(name) { writeAtomically(join(this.agentsDir, 'owners-account-manager'), `${name}\n`); }
159
+
160
+ /** The owner's account manager, when one is named and declared. */
161
+ ownersAccountManager() {
162
+ try { return this.load(readFileSync(join(this.agentsDir, 'owners-account-manager'), 'utf8').trim()); } catch { return null; }
163
+ }
164
+
165
+ /** The threads of the agent `name`, oldest first. */
166
+ threadsOf(name) { return this.threads().filter((thread) => thread.agent === name); }
167
+
168
+ /**
169
+ * A line that reached an agent's main session through its own input (its terminal, its DM) is a root of the agent:
170
+ * its thread, held by main, with its sender, opened when main first acts on it (mail_agent.rs `root_of_main`). Null
171
+ * when `main` is no agent's main session or the line is main's own.
172
+ */
173
+ async rootOfMain(main, envelope) {
174
+ const agent = this.agentOfSession(main);
175
+ if (!agent || agent.main_session !== main) return null;
176
+ if (envelope.from === main || threadOf(envelope) !== envelope.id) return null;
177
+ const participants = [{ address: main, role: 'to' }, ...(cameThroughLink(envelope) ? [] : [{ address: envelope.from, role: 'to' }])];
178
+ return this.update(envelope.id, () => ({ id: envelope.id, agent: agent.name, holder: main, participants, markers: {}, surface: null, created_at_ms: Date.now(), holder_launched: false, parent: null }), () => {});
179
+ }
180
+
181
+ /** Hand the thread `id` to `holder`: it becomes its `to`, and main `cc`; `launched` says the mailbox started the holder
182
+ * itself (mail_agent.rs `delegate_to`). Handed back to main, a launched holder leaves the thread. */
183
+ async delegateTo(id, holder, launched) {
184
+ const current = this.thread(id);
185
+ const agent = current ? this.load(current.agent) : null;
186
+ if (!current || !agent) return null;
187
+ const join_ = (thread, address, role) => {
188
+ const found = thread.participants.find((p) => p.address === address);
189
+ if (found) found.role = role; else thread.participants.push({ address, role });
190
+ };
191
+ return this.update(id, () => null, (thread) => {
192
+ if (holder === agent.main_session && thread.holder_launched && thread.holder !== holder) {
193
+ const previous = thread.holder;
194
+ thread.participants = thread.participants.filter((p) => p.address !== previous);
195
+ }
196
+ thread.holder = holder;
197
+ thread.holder_launched = launched && holder !== agent.main_session;
198
+ join_(thread, holder, 'to');
199
+ if (holder !== agent.main_session) join_(thread, agent.main_session, 'cc');
200
+ });
201
+ }
202
+
138
203
  // ---- who receives one message ----------------------------------------------------------------------------------
139
204
 
140
205
  /**
package/mail/reads.mjs CHANGED
@@ -136,6 +136,9 @@ export class MailReads {
136
136
  return prompt ? { prompt, source: 'screen' } : null;
137
137
  }
138
138
 
139
+ /** The daemon pane a live session runs in, or null. */
140
+ paneOf(session) { return this.#paneOf(session); }
141
+
139
142
  #paneOf(session) {
140
143
  const pane = typeof session.pane === 'string' ? session.pane.split(':')[0] : null;
141
144
  return pane && isDaemonPane(pane) ? pane : null;
package/mail/relay.mjs CHANGED
@@ -18,8 +18,6 @@ const RELAY_MODEL = 'haiku';
18
18
  export const RELAY_NAME_PREFIX = 'sc-';
19
19
  /** How long a send waits for Claude's own receipt. */
20
20
  const SEND_TIMEOUT_MS = 90_000;
21
- /** How long a relay's new runtime may take to show in the live-session map. */
22
- const RECEIPT_WAIT_MS = 10_000;
23
21
  /** Largest message relayed into a Claude session: the relay copies it into a model turn byte for byte. */
24
22
  export const MAX_RELAYED_BYTES = 100_000;
25
23
 
@@ -55,10 +53,11 @@ export function readReceipt(value) {
55
53
  export class Relays {
56
54
  /**
57
55
  * `serve(method, params, timeoutMs)` is one `harness.v1` request to this daemon's harness serve; `runtimeOf(harness,
58
- * id)` the live-runtime receipt the live-session map holds for a runtime id (its file read whole), or null.
56
+ * id)` the live-runtime receipt the live-session map holds for a runtime id (its file read whole), or null;
57
+ * `runtimeNamed(receiptId)` one receipt read by its own name.
59
58
  */
60
- constructor({ mailRoot, machine, supercodeBin, serve, runtimeOf }) {
61
- Object.assign(this, { mailRoot, machine, supercodeBin, serve, runtimeOf });
59
+ constructor({ mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed = () => null }) {
60
+ Object.assign(this, { mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed });
62
61
  this.sending = new Map(); // relay folder → the send in progress through it (one at a time per relay)
63
62
  }
64
63
 
@@ -141,15 +140,18 @@ export class Relays {
141
140
  const connection = typeof started.connection === 'string' ? started.connection : '';
142
141
  const pid = Number.isInteger(started?.handle?.endpoint?.pid) ? started.handle.endpoint.pid : null;
143
142
  writeFileSync(spec.record, JSON.stringify({ runtime_id: runtimeId, pid, endpoint, connection }));
144
- // its receipt reaches the live-session map from the map's watch of the receipts folder, a moment after harness serve
145
- // writes it: waited on (10 s at most), never taken as absent at once
146
- const deadline = Date.now() + RECEIPT_WAIT_MS;
147
- for (;;) {
148
- const runtime = this.runtimeOf('claude-code', runtimeId);
149
- if (runtime) return { runtime, connection };
150
- if (Date.now() > deadline) throw new Error(`the relay started but registered no live runtime within ${RECEIPT_WAIT_MS / 1000} s`);
151
- await new Promise((resolve) => setTimeout(resolve, 50));
152
- }
143
+ // harness serve writes its receipt before it answers the start; the map learns it from its watch a moment later, so
144
+ // one it does not hold yet is read by its own name (the receipt the runtime's terminal launch opens), never waited on
145
+ const known = this.runtimeOf('claude-code', runtimeId);
146
+ if (known) return { runtime: known, connection };
147
+ let receiptId = null;
148
+ try {
149
+ const launch = (await this.serve('harness.v1.runtimes.terminal_instructions', { connection }, 5000))?.launch;
150
+ receiptId = (Array.isArray(launch?.arguments) ? launch.arguments : []).find((arg) => typeof arg === 'string' && arg.startsWith('supercode-live://'))?.slice('supercode-live://'.length) ?? null;
151
+ } catch { /* said below */ }
152
+ const runtime = receiptId ? this.runtimeNamed(receiptId) : null;
153
+ if (!runtime) throw new Error('the relay started but registered no live runtime');
154
+ return { runtime, connection };
153
155
  }
154
156
 
155
157
  /** Claude's receipt for the queued send, waited on by a watch of the relay's folder (and its process checked at least
package/mail/send.mjs CHANGED
@@ -55,7 +55,26 @@ export class MailSend {
55
55
  * unique here): through Teams when `to` is on another machine, else through its door here. `link` is the link a request
56
56
  * came through (`{ principal, name }`), or null for this machine's own callers. Answers `{ code, text, receipt }`.
57
57
  */
58
- async send(caller, to, body, { subject = null, in_reply_to = null, notify_when_idle = false, queue = false, id = null, notice = false, thread = null, message_id = null } = {}, { link = null } = {}) {
58
+ async send(caller, to, body, options = {}, context = {}) {
59
+ // A send under an --id is held while it runs: the sent marker is written only once it is done, so a second send
60
+ // under the same key meanwhile waits for the first and takes its outcome (sent once), never delivering it again; one
61
+ // after a first that was not sent sends.
62
+ const key = typeof options.id === 'string' ? `${caller.address}\0${options.id}` : null;
63
+ if (key) {
64
+ const running = (this.inFlight ??= new Map()).get(key);
65
+ if (running) {
66
+ const first = await running.catch(() => null);
67
+ if (first?.code === 0) return outcome(0, `Already sent with --id ${options.id}: ${first.text} Nothing was sent again.`, first.receipt ?? null);
68
+ return this.send(caller, to, body, options, context);
69
+ }
70
+ }
71
+ const sending = this.#send(caller, to, body, options, context);
72
+ if (!key) return sending;
73
+ this.inFlight.set(key, sending);
74
+ try { return await sending; } finally { if (this.inFlight.get(key) === sending) this.inFlight.delete(key); }
75
+ }
76
+
77
+ async #send(caller, to, body, { subject = null, in_reply_to = null, notify_when_idle = false, queue = false, id = null, notice = false, thread = null, message_id = null } = {}, { link = null } = {}) {
59
78
  // an id its client chose (so a lost answer can name it), never an idempotency key: it marks nothing sent
60
79
  const chosen = typeof message_id === 'string' && /^m-[0-9a-f]{24}$/.test(message_id) ? message_id : null;
61
80
  const machine = this.#remoteMachine(to);
package/mail/store.mjs CHANGED
@@ -122,7 +122,10 @@ export class MailStore extends EventEmitter {
122
122
  let envelope = null; try { envelope = JSON.parse(text); } catch { /* unreadable */ }
123
123
  // an unreadable file is skipped, never fatal, and said: a mailbox that skipped one is partial
124
124
  if (!envelope || typeof envelope.id !== 'string') { box.skipped.add(`${part}/${file}`); return; }
125
- box.files.set(`${part}/${file}`, { header: headerOf(envelope), state: STATES[part], path, part });
125
+ // its body's first line is kept beside the header (never in it): a listing of threads names each root by it with no
126
+ // body read
127
+ const firstLine = typeof envelope.body === 'string' ? envelope.body.slice(0, 400).split('\n')[0] : '';
128
+ box.files.set(`${part}/${file}`, { header: headerOf(envelope), state: STATES[part], path, part, firstLine });
126
129
  }
127
130
 
128
131
  // ---- what it holds --------------------------------------------------------------------------------------------
@@ -161,6 +164,19 @@ export class MailStore extends EventEmitter {
161
164
  return found;
162
165
  }
163
166
 
167
+ /** The first line of each message whose id is in `ids`, from the index in one pass (no file read): id → line. */
168
+ firstLines(ids) {
169
+ const wanted = new Set(ids);
170
+ const lines = new Map();
171
+ if (!wanted.size) return lines;
172
+ for (const box of this.boxes.values()) {
173
+ for (const entry of box.files.values()) {
174
+ if (wanted.has(entry.header.id) && !lines.has(entry.header.id)) lines.set(entry.header.id, entry.firstLine ?? '');
175
+ }
176
+ }
177
+ return lines;
178
+ }
179
+
164
180
  // ---- writes, as mailbox.rs makes them --------------------------------------------------------------------------
165
181
 
166
182
  /** The unread mail of `address` (not the user's own turns) moved into `claimed/` under its reader's pid (`pid`, the
package/mail/verbs.mjs ADDED
@@ -0,0 +1,232 @@
1
+ // An agent's verbs (docs/architecture/overview.md, "The mail operations", 3b-2): reading a conversation (`message
2
+ // thread`), listing an agent's threads (`message threads`), handing a root to a new session of the agent (`message
3
+ // delegate`), and declaring or showing an agent (`supercode agent declare|show`), answered by the daemon as
4
+ // crates/cli/src/message.rs answered them, their texts kept. Each read answers from the header index; a body is read from
5
+ // its one file when its message is shown.
6
+ import { spawn } from 'node:child_process';
7
+ import { randomBytes } from 'node:crypto';
8
+ import { realpathSync } from 'node:fs';
9
+ import { listed, messageRow, rfc3339, shortId } from './envelope.mjs';
10
+ import { makeAddress, parseAddress, threadOf } from './compose.mjs';
11
+
12
+ export const EXIT_REFUSED = 2;
13
+ export const EXIT_UNKNOWN = 3;
14
+ export const EXIT_FAILED = 5;
15
+
16
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
17
+
18
+ export class AgentVerbs {
19
+ /** `machine` this machine's name; `store` the mail store; `agents` an agent's records; `live` the live-session map;
20
+ * `supercodeBin` the native binary (`supercode open` starts a session in a pane); `paneOf(session)` the daemon pane a
21
+ * live session runs in; `mailRoot` the mail folder. */
22
+ constructor({ machine, store, agents, live, supercodeBin, env = process.env, paneOf, mailRoot }) {
23
+ Object.assign(this, { machine, store, agents, live, supercodeBin, env, paneOf, mailRoot });
24
+ }
25
+
26
+ #whole(stored) { const envelope = this.store.body(stored); return envelope ? { ...stored, envelope } : null; }
27
+
28
+ // ---- message thread -------------------------------------------------------------------------------------------
29
+
30
+ /**
31
+ * The conversation a message belongs to, oldest first (message.rs `thread`): every message here is a node, a reply's
32
+ * `in_reply_to` its edge to what it answers, and a stored thread id joins a message whose parent is not filed here.
33
+ * Answers `{ code, rows, rendered }` or `{ code, text }`.
34
+ */
35
+ thread(id) {
36
+ const all = new Map(); // id → { address, stored }
37
+ for (const address of this.store.addresses()) {
38
+ for (const stored of this.store.inventory(address).stored) if (!all.has(stored.envelope.id)) all.set(stored.envelope.id, { address, stored });
39
+ }
40
+ const matches = [...all.keys()].filter((key) => key.startsWith(id));
41
+ if (matches.length !== 1) return { code: EXIT_UNKNOWN, text: `${id} names ${matches.length} messages here; give one message's id.` };
42
+ // up to the root: the oldest ancestor filed here
43
+ let root = matches[0];
44
+ const seen = new Set();
45
+ for (;;) {
46
+ const parent = all.get(root)?.stored.envelope.in_reply_to;
47
+ if (!parent || !all.has(parent) || seen.has(parent)) break;
48
+ seen.add(parent); root = parent;
49
+ }
50
+ const threadOfRoot = threadOf(all.get(root).stored.envelope);
51
+ // down from the root: every reply, and every message naming the same thread
52
+ const children = new Map();
53
+ for (const [key, { stored }] of all) {
54
+ const parent = stored.envelope.in_reply_to;
55
+ if (parent) children.set(parent, [...(children.get(parent) ?? []), key]);
56
+ }
57
+ const members = new Set();
58
+ const queue = [root, ...[...all].filter(([, { stored }]) => stored.envelope.thread === threadOfRoot).map(([key]) => key)];
59
+ while (queue.length) {
60
+ const next = queue.pop();
61
+ if (members.has(next)) continue;
62
+ members.add(next);
63
+ queue.push(...(children.get(next) ?? []));
64
+ }
65
+ const conversation = [...members].map((key) => all.get(key)).map(({ address, stored }) => ({ address, stored: this.#whole(stored) })).filter((item) => item.stored)
66
+ .sort((a, b) => a.stored.envelope.created_at_ms - b.stored.envelope.created_at_ms);
67
+ const deliveries = new Map();
68
+ return {
69
+ code: 0,
70
+ rows: conversation.map(({ address, stored }) => messageRow(address, stored, deliveries, this.mailRoot)),
71
+ rendered: conversation.map(({ address, stored }) => `To ${address}:\n${listed(stored.envelope, deliveries, this.mailRoot)}\n`),
72
+ };
73
+ }
74
+
75
+ // ---- message threads ------------------------------------------------------------------------------------------
76
+
77
+ /** An agent's threads, oldest first (message.rs `threads`): the agent named, else the caller's own agent. */
78
+ threads(agent, caller) {
79
+ let name = agent;
80
+ if (!name) {
81
+ name = caller ? this.agents.agentOfSession(caller)?.name : null;
82
+ if (!name) return { code: EXIT_REFUSED, text: 'Name the agent with --agent: this session is not one of an agent\'s.' };
83
+ }
84
+ const threads = this.agents.threadsOf(name);
85
+ // every root's first line from the index in one pass: no walk of the index per thread, no body read
86
+ const roots = this.store.firstLines(threads.map((thread) => thread.id));
87
+ const rows = threads.map((thread) => ({
88
+ id: thread.id, holder: thread.holder,
89
+ participants: thread.participants.map((p) => ({ address: p.address, role: p.role })),
90
+ surface: thread.surface ?? null, opened_at: rfc3339(thread.created_at_ms),
91
+ root: roots.has(thread.id) ? roots.get(thread.id) : null,
92
+ }));
93
+ const text = rows.length
94
+ ? rows.map((row) => `${shortId(row.id)} opened ${row.opened_at} held by ${row.holder}\n ${row.participants.map((p) => `${p.address} (${p.role})`).join(', ')}\n ${row.root ?? '(no root message)'}`).join('\n')
95
+ : `Agent ${name} has no threads.`;
96
+ return { code: 0, rows, text };
97
+ }
98
+
99
+ // ---- a new session of an agent ----------------------------------------------------------------------------------
100
+
101
+ /**
102
+ * Start `program` in a new pane in `directory`, typed `input` once ready, and answer the session it runs (message.rs
103
+ * `open_session`): a Claude session is given its id up front, so `ready(address)` runs before it starts; another is
104
+ * found in the live-session map by the pane it opened in, within a minute.
105
+ */
106
+ async openSession(program, directory, key, input, ready) {
107
+ const stem = String(program).split(/[\\/]/).pop().replace(/\.[^.]*$/, '');
108
+ const claude = stem === 'claude' || stem === 'claude-code';
109
+ let known = null;
110
+ const extra = [];
111
+ if (claude) {
112
+ const bytes = randomBytes(16);
113
+ bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80;
114
+ const hex = bytes.toString('hex');
115
+ const id = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
116
+ known = makeAddress(this.machine, 'claude-code', id);
117
+ extra.push('--', '--session-id', id);
118
+ await ready(known);
119
+ }
120
+ const run = await new Promise((resolve) => {
121
+ const child = spawn(this.supercodeBin, ['--cwd', directory, 'open', '--new', program, '--key', key, '--input', input, '--detach', ...extra], { stdio: ['ignore', 'pipe', 'pipe'], env: this.env });
122
+ let out = '', err = '';
123
+ child.stdout.on('data', (chunk) => { out += chunk; });
124
+ child.stderr.on('data', (chunk) => { err += chunk; });
125
+ child.on('error', (error) => resolve({ code: 1, out, err: error.message }));
126
+ child.on('close', (code) => resolve({ code, out, err }));
127
+ });
128
+ if (run.code !== 0) throw new Error(`the session did not start: ${run.err.trim()}`);
129
+ if (known) return known;
130
+ let pane = null;
131
+ try { pane = JSON.parse(run.out).pane; } catch { /* not reported */ }
132
+ if (typeof pane !== 'string') throw new Error('the new pane was not reported');
133
+ const started = Date.now();
134
+ while (Date.now() - started < 60_000) {
135
+ const session = this.live.sessions().find((row) => this.paneOf(row) === pane);
136
+ if (session) { await ready(session.address); return session.address; }
137
+ await sleep(500);
138
+ }
139
+ throw new Error(`${program} started in pane ${pane}, but no session appeared there within a minute`);
140
+ }
141
+
142
+ /**
143
+ * Hand a root the caller received as an agent's main session to a new session of the agent (message.rs `delegate`):
144
+ * it opens in a pane with the root, holds the thread (every reply wakes it), and main stays CC. Answers `{ code, text }`.
145
+ */
146
+ async delegate(caller, root, { folder = null, harness = null } = {}) {
147
+ const found = this.store.findPrefixIn(caller, root);
148
+ if (found.length !== 1) return { code: EXIT_REFUSED, text: `Not delegated: ${root} names ${found.length} messages in your mailbox. Name the root as your inbox shows it.` };
149
+ const stored = found[0].envelope;
150
+ const threadId = threadOf(stored);
151
+ // a line in main's own session (its terminal, its DM) is a root of the agent: main opens its thread to delegate it
152
+ const thread = this.agents.thread(threadId) ?? await this.agents.rootOfMain(caller, stored);
153
+ if (!thread) return { code: EXIT_REFUSED, text: `Not delegated: ${shortId(stored.id)} is not in a thread of an agent. Only a root an agent received can be delegated.` };
154
+ const agent = this.agents.load(thread.agent);
155
+ if (!agent) return { code: EXIT_REFUSED, text: `Not delegated: agent ${thread.agent} is no longer declared.` };
156
+ if (agent.main_session !== caller || thread.holder !== caller) return { code: EXIT_REFUSED, text: `Not delegated: only ${agent.name}'s main session delegates a thread it holds.` };
157
+ const program = harness ?? agent.harness ?? 'claude';
158
+ const directory = folder ?? agent.folder ?? null;
159
+ if (!directory) return { code: EXIT_FAILED, text: 'Not delegated: no directory to start the session in (give --folder, or declare the agent with one).' };
160
+ const shownRoot = shortId(thread.id);
161
+ const input = `You are a session of agent ${agent.name}, holding its thread ${shownRoot}. Read it: supercode message thread ${shownRoot}. Answer in it with supercode message reply <id>; what you send stays in this thread, and ${agent.name}'s main session is CC on it.`;
162
+ // the new session holds the thread and has its messages before it starts, so its first turn finds its root
163
+ const handOver = async (holder) => {
164
+ for (const line of this.store.inventory(caller).stored) {
165
+ if (threadOf(line.envelope) !== thread.id) continue;
166
+ const whole = this.#whole(line);
167
+ if (!whole) continue;
168
+ // a line typed into main's terminal reaches the holder as one typed in another session (`typed`, ADR 0008)
169
+ const envelope = whole.envelope.kind === 'user' ? { ...whole.envelope, kind: 'typed' } : whole.envelope;
170
+ this.store.deliverRead(holder, envelope);
171
+ }
172
+ await this.agents.delegateTo(thread.id, holder, true);
173
+ };
174
+ let holder;
175
+ try { holder = await this.openSession(program, directory, `delegate:${thread.id}`, input, handOver); }
176
+ catch (error) {
177
+ // the thread goes back to main, so nothing waits on a session that never started
178
+ await this.agents.delegateTo(thread.id, caller, false);
179
+ return { code: EXIT_FAILED, text: `Not delegated: ${error.message}` };
180
+ }
181
+ const sessionId = parseAddress(holder).session;
182
+ return { code: 0, text: `Delegated thread ${shownRoot} to a new session of ${agent.name} (${holder}) in ${directory}. You are CC on it. Continue there: supercode open ${sessionId}` };
183
+ }
184
+
185
+ // ---- supercode agent declare | show ---------------------------------------------------------------------------
186
+
187
+ /** Declare an agent, or replace its record (message.rs `run_agent` Declare): its main session an address, a name
188
+ * running here, or one started (`open`) when the declared main is neither running nor resumable. */
189
+ async declare({ name, main = null, open = null, input = null, folder = null, harness = null, idle_minutes = null, owners_account_manager = false }) {
190
+ const directory = folder ? (() => { try { return realpathSync(folder); } catch { return null; } })() : null;
191
+ if (folder && !directory) return { code: 1, text: `${folder}: no such directory` };
192
+ let mainSession = main;
193
+ if (!mainSession) {
194
+ if (!open) return { code: EXIT_REFUSED, text: 'Name its main session with --main, or start one with --open.' };
195
+ const kept = this.agents.load(name)?.main_session ?? null;
196
+ const live = kept && this.live.sessions().some((row) => row.address === kept);
197
+ if (kept && (live || await this.agents.resumable(kept))) mainSession = kept;
198
+ else {
199
+ const first = input ?? `You are the main session of agent ${name}. Roots addressed to it reach you: answer each or delegate it (supercode message delegate).`;
200
+ try { mainSession = await this.openSession(open, directory ?? process.cwd(), `agent:${name}`, first, async () => {}); }
201
+ catch (error) { return { code: EXIT_FAILED, text: `Not declared: ${error.message}` }; }
202
+ }
203
+ }
204
+ if (!parseAddress(mainSession)) {
205
+ const sessions = this.live.sessions();
206
+ const wanted = mainSession.split('@')[0];
207
+ const matching = sessions.filter((row) => row.name.split('@')[0] === wanted);
208
+ if (matching.length !== 1) return { code: EXIT_UNKNOWN, text: matching.length ? `${mainSession} names ${matching.length} sessions here; give its address.` : `No session named "${mainSession}" is reachable. Run supercode message list. Nothing was sent.` };
209
+ mainSession = matching[0].address;
210
+ }
211
+ try {
212
+ this.agents.declare({ name, main_session: mainSession, folder: directory, harness, idle_minutes });
213
+ if (owners_account_manager) this.agents.setOwnersAccountManager(name);
214
+ } catch (error) { return { code: 1, text: error.message }; }
215
+ return { code: 0, text: `Declared agent ${name} (${makeAddress(this.machine, 'agent', name)}): roots go to ${mainSession}.${owners_account_manager ? ' It is the owner\'s account manager.' : ''}` };
216
+ }
217
+
218
+ /** One declared agent, or all of them (message.rs `run_agent` Show). */
219
+ show(name) {
220
+ let agents;
221
+ if (name) {
222
+ const agent = this.agents.load(name);
223
+ if (!agent) return { code: EXIT_UNKNOWN, text: `No agent named ${name} is declared on this machine.` };
224
+ agents = [agent];
225
+ } else agents = this.agents.all();
226
+ const manager = this.agents.ownersAccountManager()?.name ?? null;
227
+ const text = agents.length
228
+ ? agents.map((agent) => `${agent.name} ${makeAddress(this.machine, 'agent', agent.name)}\n main: ${agent.main_session}\n folder: ${agent.folder ?? '(none)'}\n threads: ${this.agents.threadsOf(agent.name).length}${manager === agent.name ? '\n the owner\'s account manager' : ''}`).join('\n')
229
+ : 'No agents are declared on this machine.';
230
+ return { code: 0, agents, text };
231
+ }
232
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.3.86",
3
+ "version": "0.3.87",
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",