@volter/supercode-teams 0.3.150 → 0.3.151

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/README.md CHANGED
@@ -178,6 +178,25 @@ its token or removing an origin closes the channels already admitted; a door lef
178
178
  off. While its port is taken the daemon retries every ten seconds, and `status` shows why it is not
179
179
  listening (app-door.state.json).
180
180
 
181
+ ## Native mail continuation
182
+
183
+ Mail to an old Claude Code address follows only the harness's explicit terminal
184
+ `continued-in` record to a live UUID on the same machine. It never infers a route
185
+ from a name or working directory. The send result and receipt retain
186
+ `continuation: { addressed, recipient, chain }`; send logs retain that metadata.
187
+ The existing plain-text sent markers and native relay behavior are unchanged.
188
+ If the receiver rolls over between an attempt and a same-`--id` repeat, delivery
189
+ to its successor can duplicate the message (separate task t_65153cfb). This
190
+ routing correction does not promise exactly-once delivery across rollover.
191
+ The target daemon rechecks mail grants on all routed addresses. A grant on only
192
+ the old address does not cover its continuation. Caller identity, pane ownership,
193
+ history access and old mailbox contents are unchanged.
194
+
195
+ The named lookup reads at most 64 KiB per transcript tail and follows at most
196
+ 16 links within a shared 4,096-project-entry / 750 ms work budget. Incomplete,
197
+ changed, conflicting or cyclic records are refused. The filesystem reads are
198
+ asynchronous; an in-progress OS read is not cancelled by the elapsed budget.
199
+
181
200
  ## Files (under `$SUPERCODE_HOME/teams`)
182
201
 
183
202
  `machine.key|pub` (the host key; it signs `describe` and nothing else) · `machine.sock` ·
package/machine.mjs CHANGED
@@ -797,6 +797,7 @@ export class TeamsMachine extends MachineCore {
797
797
  return result.waits ?? result;
798
798
  };
799
799
  this.mailSend = new MailSend({ machine, mailRoot, live, store: this.mailStore, agents: this.agents, relays: this.relays,
800
+ claudeProjects: () => [...live.claudeDirs.keys()].map((dir) => join(dirname(dir), 'projects')),
800
801
  // a send waits on its receiver's door there (a relay's receipt takes up to 90 s), never past MAIL_SEND_MS
801
802
  askMachine: (to, request) => this.#askMailDoor(to, request, MAIL_SEND_MS),
802
803
  deliverRuntime: deliverToRuntime, codexAnswer: (request) => codexQuestions(request),
@@ -1598,7 +1599,18 @@ export class TeamsMachine extends MachineCore {
1598
1599
  const link = linkOf(principal, entry.principalName ?? null);
1599
1600
  void (async () => {
1600
1601
  let answer;
1601
- try { answer = request.op === 'file' ? this.#fileHere(request, link) : await this.#sendHere(request, link); }
1602
+ try { answer = request.op === 'file' ? this.#fileHere(request, link) : await this.#sendHere(request, link, async (addresses) => {
1603
+ // Native continuation changes a recipient, not a grant. Re-derive this stream's admission after the named
1604
+ // transcript read, and require mail on every address in its route before the send can file anything.
1605
+ if (stream.closed) return 'the mail stream closed before delivery';
1606
+ const fresh = entry.verify ? await entry.verify() : entry.admitted;
1607
+ if (stream.closed) return 'the mail stream closed before delivery';
1608
+ if (fresh) entry.admitted = fresh;
1609
+ const missing = addresses.find(address => !holds(fresh, 'mail', `mail:${address}`));
1610
+ if (!missing) return null;
1611
+ this.log.append({ principal, cap: 'mail', target: `mail:${missing}`, outcome: 'refused', detail: 'receiver grant' });
1612
+ return `no live mail grant for mail:${missing}`;
1613
+ }); }
1602
1614
  catch (error) { answer = { outcome: 'failed', detail: error.message }; }
1603
1615
  this.log.append({ principal, cap: 'mail', target, outcome: answer.outcome === 'failed' || (answer.code ?? 0) !== 0 ? 'failed' : request.op, detail: answer.outcome === 'failed' ? String(answer.detail).slice(0, 200) : undefined });
1604
1616
  if (stream.closed) return;
@@ -1610,14 +1622,14 @@ export class TeamsMachine extends MachineCore {
1610
1622
  /** A send through the mail door, for a session here: one for another machine is
1611
1623
  * refused when it came through a link (never forwarded on this machine's own credential), and this machine's own
1612
1624
  * daemon sends on through the one way out. */
1613
- async #sendHere(request, link) {
1625
+ async #sendHere(request, link, authorizeRecipients) {
1614
1626
  const from = parseAddress(request.from)?.address;
1615
1627
  if (!from) return { code: 2, text: `\`${request.from}\` is not a session address; addresses look like sc:<machine>:<harness>:<session-id>`, receipt: null };
1616
1628
  const text = (key) => (typeof request[key] === 'string' ? request[key] : null);
1617
1629
  return this.mailSend.send({ address: from, name: text('from_name') ?? '' }, text('to') ?? '', text('body') ?? '', {
1618
1630
  subject: text('subject'), in_reply_to: text('in_reply_to'), notify_when_idle: request.notify_when_idle === true, queue: request.queue === true,
1619
1631
  id: text('id'), notice: request.notice === true, thread: text('thread'),
1620
- }, { link });
1632
+ }, { link, authorizeRecipients });
1621
1633
  }
1622
1634
 
1623
1635
  /** A message another machine's mailbox carried here, filed in a mailbox here: the
@@ -0,0 +1,101 @@
1
+ // Native continuation is a mail route, never a caller identity or an authority alias. Only Claude's own terminal
2
+ // continued-in row links two UUIDs. Read the requested filenames and their tails, not every transcript in a home.
3
+ import { open, opendir, realpath, stat } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { makeAddress, parseAddress } from './compose.mjs';
6
+
7
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
+ const TAIL_BYTES = 64 * 1024;
9
+ const MAX_PROJECTS = 4096;
10
+ const MAX_HOPS = 16;
11
+ const LOOKUP_MS = 750;
12
+ const unchanged = (a, b) => a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs && a.ino === b.ino && a.dev === b.dev;
13
+ const problem = (reason) => Object.assign(new Error(reason), { continuation: true });
14
+
15
+ /** All followed homes must agree on the named transcript: a UUID copied to another project/home is ambiguous. */
16
+ async function namedTranscript(id, roots, budget) {
17
+ const found = new Set();
18
+ for (const root of new Set(roots)) {
19
+ budget.check();
20
+ let directory;
21
+ try { directory = await opendir(root); }
22
+ catch (error) { if (error.code === 'ENOENT') continue; throw error; }
23
+ for await (const project of directory) {
24
+ budget.check();
25
+ if (++budget.projects > MAX_PROJECTS) throw problem('the named transcript lookup exceeded its project bound');
26
+ if (!project.isDirectory() && !project.isSymbolicLink()) continue;
27
+ const path = join(root, project.name, `${id}.jsonl`);
28
+ try {
29
+ if ((await stat(path)).isFile()) found.add(await realpath(path));
30
+ } catch (error) { if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error; }
31
+ if (found.size > 1) throw problem('more than one native transcript has that session id');
32
+ }
33
+ }
34
+ budget.check();
35
+ return [...found][0] ?? null;
36
+ }
37
+
38
+ /** A complete, unchanged final JSONL row. A partial write, large final row or changed file cannot establish a route. */
39
+ async function terminalContinuation(path, id, budget) {
40
+ budget.check();
41
+ const file = await open(path, 'r');
42
+ try {
43
+ const before = await file.stat({ bigint: true });
44
+ const size = Number(before.size);
45
+ if (!Number.isSafeInteger(size)) throw problem('the native transcript size is not readable');
46
+ if (!size) return null;
47
+ const start = Math.max(0, size - TAIL_BYTES);
48
+ const bytes = Buffer.alloc(size - start);
49
+ const { bytesRead } = await file.read(bytes, 0, bytes.length, start);
50
+ const after = await file.stat({ bigint: true });
51
+ budget.check();
52
+ if (bytesRead !== bytes.length || !unchanged(before, after)) throw problem('the native transcript changed during its continuation read');
53
+ let end = bytes.length;
54
+ if (bytes[end - 1] !== 10) throw problem('the native transcript has an incomplete final row');
55
+ while (end && (bytes[end - 1] === 10 || bytes[end - 1] === 13)) end--;
56
+ const boundary = bytes.lastIndexOf(10, end - 1);
57
+ if (start && boundary < 0) throw problem('the native transcript final row exceeds the continuation read bound');
58
+ let row;
59
+ try { row = JSON.parse(bytes.subarray(boundary + 1, end).toString('utf8')); }
60
+ catch { throw problem('the native transcript final row is unreadable'); }
61
+ if (row?.type !== 'continued-in') return null;
62
+ if (row.sessionId !== id || typeof row.continuedInSessionId !== 'string' || !UUID.test(row.continuedInSessionId) || row.continuedInSessionId === id || row.isSidechain === true) {
63
+ throw problem('the native continuation row does not identify a distinct continuation of that session');
64
+ }
65
+ return row.continuedInSessionId;
66
+ } finally { await file.close(); }
67
+ }
68
+
69
+ /** Resolve an exact old native address to a currently live receiver. No route is cached, guessed or persisted.
70
+ * The shared budget bounds project entries, hops and elapsed lookup work, and each file read is at most 64 KiB.
71
+ * Filesystem calls are asynchronous; the elapsed bound is checked after each await, not an OS I/O cancellation.
72
+ */
73
+ export async function nativeMailContinuation(address, { machine, claudeProjects, sessions }) {
74
+ const parsed = parseAddress(address);
75
+ if (parsed?.machine !== machine || parsed.harness !== 'claude-code' || !UUID.test(parsed.session)) return null;
76
+ const budget = { projects: 0, until: performance.now() + LOOKUP_MS, check() {
77
+ if (performance.now() > this.until) throw problem('the named transcript continuation lookup exceeded its time bound');
78
+ } };
79
+ const chain = [address];
80
+ try {
81
+ for (let hop = 0; hop <= MAX_HOPS; hop++) {
82
+ // The exact address may resume while its old transcript is being read. A currently live exact receiver wins.
83
+ if (sessions().some((row) => row.address === address)) return null;
84
+ const current = chain.at(-1);
85
+ const session = sessions().find((row) => row.address === current);
86
+ if (session) return chain.length > 1 ? { session, addressed: address, chain } : null;
87
+ if (hop === MAX_HOPS) throw problem('the native continuation chain exceeded its hop bound');
88
+ const id = parseAddress(current).session;
89
+ const path = await namedTranscript(id, claudeProjects(), budget);
90
+ const next = path ? await terminalContinuation(path, id, budget) : null;
91
+ if (!next) {
92
+ if (chain.length > 1) throw problem(`the recorded continuation ${current} is not running and records no further continuation`);
93
+ return null;
94
+ }
95
+ const target = makeAddress(machine, 'claude-code', next);
96
+ if (chain.includes(target)) throw problem('the native continuation chain contains a cycle');
97
+ chain.push(target);
98
+ }
99
+ } catch (error) { return { error: error.continuation ? error.message : `the native continuation could not be read (${error.code ?? 'filesystem error'})` }; }
100
+ return null;
101
+ }
package/mail/send.mjs CHANGED
@@ -10,6 +10,7 @@ import { blake3Hex, render, shortId } from './envelope.mjs';
10
10
  import { AGENT_HARNESS, RESUME_WAIT_MS } from './agents.mjs';
11
11
  import { MAX_RELAYED_BYTES, RELAY_NAME_PREFIX } from './relay.mjs';
12
12
  import { FULL_ID_LEN, cameThroughLink, envelope as makeEnvelope, makeAddress, messageIdFor, newMessageId, normalMachineName, parseAddress, senderIdentity, subjectLine, threadOf } from './compose.mjs';
13
+ import { nativeMailContinuation } from './continuation.mjs';
13
14
 
14
15
  export const EXIT_REFUSED = 2;
15
16
  export const EXIT_UNKNOWN = 3;
@@ -56,8 +57,8 @@ export class MailSend {
56
57
  * question's answer through its app-server; `typeTurn(pane, harness, text, ids)` a user's own turn typed into a daemon
57
58
  * pane's empty composer (machine.mjs `#paneTurn`: `{ delivered }`, `{ queued }`, or neither when it waits).
58
59
  */
59
- constructor({ machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, typeTurn, log = () => {} }) {
60
- Object.assign(this, { machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, typeTurn, log });
60
+ constructor({ machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, typeTurn, claudeProjects = () => [], log = () => {} }) {
61
+ Object.assign(this, { machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, typeTurn, claudeProjects, log });
61
62
  this.handing = new Set(); // `<address>\n<id>` of each hand-over running here (handOver)
62
63
  this.relaying = new Set(); // `<address>\n<id>` of each relay send running here (route): its wake waits for it
63
64
  }
@@ -70,6 +71,16 @@ export class MailSend {
70
71
  * came through (`{ principal, name }`), or null for this machine's own callers. Answers `{ code, text, receipt }`.
71
72
  */
72
73
  async send(caller, to, body, options = {}, context = {}) {
74
+ if (context.link && this.#remoteMachine(to)) return outcome(EXIT_UNKNOWN, `${to} is not on this machine; a send through its mail door is for its own sessions.`);
75
+ // Every stream earns its current route's grants before it can join the existing same-ID send in flight.
76
+ let receiver = null;
77
+ if (!this.#remoteMachine(to)) {
78
+ receiver = await this.#receiver(to, this.live.sessions());
79
+ if (receiver.outcome && !this.live.codexKnown) { await this.#codexKnown(); receiver = await this.#receiver(to, this.live.sessions()); }
80
+ if (receiver.outcome) return receiver.outcome;
81
+ const refused = await this.#authorizeReceiver(context, receiver.continuation?.chain ?? [receiver.address]);
82
+ if (refused) return refused;
83
+ }
73
84
  // A send under an --id is held while it runs: the sent marker is written only once it is done, so a second send
74
85
  // under the same key meanwhile waits for the first and takes its outcome (sent once), never delivering it again; one
75
86
  // after a first that was not sent sends.
@@ -78,17 +89,25 @@ export class MailSend {
78
89
  const running = (this.inFlight ??= new Map()).get(key);
79
90
  if (running) {
80
91
  const first = await running.catch(() => null);
81
- if (first?.code === 0) return outcome(0, `Already sent with --id ${options.id}: ${first.text} Nothing was sent again.`, first.receipt ?? null);
92
+ if (first?.code === 0) {
93
+ const addresses = [...(first.continuation?.chain ?? []), ...(first.receipt?.recipients ?? []), first.receipt?.recipient].filter(Boolean);
94
+ if (addresses.length) {
95
+ const refused = await this.#authorizeReceiver(context, addresses);
96
+ if (refused) return refused;
97
+ } else if (context.authorizeRecipients) return outcome(EXIT_REFUSED, 'Not sent: the earlier send has no recipient receipt to authorize on this stream. Nothing was sent again.');
98
+ return { ...outcome(0, `Already sent with --id ${options.id}: ${first.text} Nothing was sent again.`, first.receipt ?? null), ...(first.continuation ? { continuation: first.continuation } : {}) };
99
+ }
82
100
  return this.send(caller, to, body, options, context);
83
101
  }
84
102
  }
85
- const sending = this.#send(caller, to, body, options, context);
103
+ const sending = this.#send(caller, to, body, options, context, receiver);
86
104
  if (!key) return sending;
87
105
  this.inFlight.set(key, sending);
88
106
  try { return await sending; } finally { if (this.inFlight.get(key) === sending) this.inFlight.delete(key); }
89
107
  }
90
108
 
91
- 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 } = {}) {
109
+ 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 } = {}, context = {}, receiver = null) {
110
+ const { link = null } = context;
92
111
  // an id its client chose (so a lost answer can name it), never an idempotency key: it marks nothing sent
93
112
  const chosen = typeof message_id === 'string' && /^m-[0-9a-f]{24}$/.test(message_id) ? message_id : null;
94
113
  const machine = this.#remoteMachine(to);
@@ -103,10 +122,25 @@ export class MailSend {
103
122
  const request = { op: 'send', from: caller.address, from_name: caller.name, to, body, subject, in_reply_to: answered.id, thread: carried, notify_when_idle, queue, id: messageId, notice };
104
123
  const sent = await this.#remoteSend(machine, request);
105
124
  // unanswered, it may have been delivered: kept in the send log, so a later reply to it resolves here
106
- if (sent.code === 0 || sent.code === EXIT_STORED || sent.receipt?.unanswered === true) this.#recordSend(caller.address, to, messageId, carried ?? answered.id ?? messageId);
125
+ if (sent.code === 0 || sent.code === EXIT_STORED || sent.receipt?.unanswered === true) this.#recordSend(caller.address, to, messageId, carried ?? answered.id ?? messageId, sent.continuation);
107
126
  return sent;
108
127
  }
109
- return this.#deliver(caller, to, body, { subject, in_reply_to, notify_when_idle, queue, id, notice, thread, chosen }, link);
128
+ const answer = await this.#deliver(caller, receiver, body, { subject, in_reply_to, notify_when_idle, queue, id, notice, thread, chosen }, link, context);
129
+ return this.#annotateReceiver(answer, receiver);
130
+ }
131
+
132
+ #annotateReceiver(answer, receiver) {
133
+ const continuation = receiver.continuation;
134
+ const alias = continuation && !answer.text.includes(receiver.alias) ? receiver.alias : '';
135
+ return { ...answer, text: answer.text + alias, ...(continuation ? { continuation } : {}),
136
+ receipt: answer.receipt ? { ...answer.receipt, recipient: receiver.address,
137
+ recipients: answer.receipt.recipients ?? [receiver.address], ...(continuation ? { continuation } : {}) } : null };
138
+ }
139
+
140
+ async #authorizeReceiver(context, addresses) {
141
+ if (!context.authorizeRecipients) return null;
142
+ const reason = await context.authorizeRecipients(addresses);
143
+ return reason ? outcome(EXIT_REFUSED, `Not sent: ${reason}. Nothing was filed or delivered by this request.`) : null;
110
144
  }
111
145
 
112
146
  /** The machine `to` names when it is not this one, in its normal form. */
@@ -123,7 +157,7 @@ export class MailSend {
123
157
  const answer = await this.askMachine(machine, request);
124
158
  const code = Number.isInteger(answer?.code) ? answer.code : EXIT_FAILED;
125
159
  const text = typeof answer?.text === 'string' ? answer.text : typeof answer?.detail === 'string' ? answer.detail : 'the other machine answered nothing readable';
126
- return outcome(code, text, answer?.receipt ?? null);
160
+ return { ...outcome(code, text, answer?.receipt ?? null), ...(answer?.continuation ? { continuation: answer.continuation } : {}) };
127
161
  } catch (error) {
128
162
  const detail = String(error?.message ?? error);
129
163
  // the door took it and its answer was lost: unanswered, never refused or failed (what it did there is unknown)
@@ -147,7 +181,7 @@ export class MailSend {
147
181
  }
148
182
 
149
183
  /** The receiver `to` names here: `{ address, name, session (its live row, when it runs) }`, or an outcome. */
150
- #receiver(to, sessions) {
184
+ async #receiver(to, sessions) {
151
185
  const operator = String(to).startsWith('operator:') ? makeAddress(this.machine, 'operator', String(to).slice('operator:'.length)) : null;
152
186
  const parsed = parseAddress(operator ?? to);
153
187
  // an operator (a board, a script) is not a listed session: its address is taken as given
@@ -158,7 +192,9 @@ export class MailSend {
158
192
  return { address: parsed.address, name: `agent ${parsed.session}@${parsed.machine}` };
159
193
  }
160
194
  if (parsed) {
161
- const session = sessions.find((row) => row.address === parsed.address);
195
+ const resolved = await this.#nativeReceiver(parsed.address, sessions);
196
+ if (resolved) return resolved;
197
+ const session = this.live.sessions().find((row) => row.address === parsed.address);
162
198
  if (!session) return { outcome: outcome(EXIT_UNKNOWN, this.#notYetKnown(to) ?? `${to} is no longer running. Nothing was sent. Run supercode message list for the live sessions.`) };
163
199
  return { address: session.address, name: session.name, session };
164
200
  }
@@ -172,7 +208,9 @@ export class MailSend {
172
208
  if (!matching.length) {
173
209
  const remembered = this.#rememberedAddress(wanted);
174
210
  if (remembered) {
175
- const session = sessions.find((row) => row.address === remembered);
211
+ const resolved = await this.#nativeReceiver(remembered, sessions);
212
+ if (resolved) return resolved;
213
+ const session = this.live.sessions().find((row) => row.address === remembered);
176
214
  if (!session) return { outcome: outcome(EXIT_UNKNOWN, `${wanted} was ${remembered}, which is not running. Nothing was sent. Run supercode message list for the live sessions.`) };
177
215
  return { address: session.address, name: session.name, session, alias: ` "${wanted}" is a name it ran under before; it runs as ${session.name} now.` };
178
216
  }
@@ -190,6 +228,18 @@ export class MailSend {
190
228
  return { outcome: outcome(EXIT_UNKNOWN, this.#notYetKnown(wanted) ?? `No session named "${wanted}" is reachable.${hint} Run supercode message list. Nothing was sent.`) };
191
229
  }
192
230
 
231
+ async #nativeReceiver(address, sessions) {
232
+ const live = sessions.find((row) => row.address === address);
233
+ if (live) return { address: live.address, name: live.name, session: live };
234
+ const continued = await nativeMailContinuation(address, { machine: this.machine, claudeProjects: this.claudeProjects, sessions: () => this.live.sessions() });
235
+ if (continued?.error) return { outcome: outcome(EXIT_UNKNOWN, `Not sent: ${address}'s continuation is unresolved: ${continued.error}. Nothing was sent.`) };
236
+ if (!continued) return null;
237
+ const { session, addressed, chain } = continued;
238
+ return { address: session.address, name: session.name, session,
239
+ continuation: { addressed, recipient: session.address, chain },
240
+ alias: ` Addressed to ${address}; its native continued-in record routes it to ${session.address}.` };
241
+ }
242
+
193
243
  /** In a daemon's first moments the Codex sessions are not known yet (its session index and their activity still being
194
244
  * read): a send waits for them, at most CODEX_KNOWN_WAIT_MS. */
195
245
  async #codexKnown() { if (!this.live.codexKnown) await this.live.whenCodexKnown?.(CODEX_KNOWN_WAIT_MS); }
@@ -219,12 +269,8 @@ export class MailSend {
219
269
  }
220
270
 
221
271
  /** Deliver from `caller` to `to` on this machine (mail_send.rs `deliver`). */
222
- async #deliver(caller, to, body, { subject, in_reply_to, notify_when_idle, queue, id, notice, thread: carried, chosen }, link) {
223
- let sessions = this.live.sessions();
224
- let receiver = this.#receiver(to, sessions);
225
- // only a receiver not found waits for the Codex sessions to be known (one the map holds is reached at once)
226
- if (receiver.outcome && !this.live.codexKnown) { await this.#codexKnown(); sessions = this.live.sessions(); receiver = this.#receiver(to, sessions); }
227
- if (receiver.outcome) return receiver.outcome;
272
+ async #deliver(caller, receiver, body, { subject, in_reply_to, notify_when_idle, queue, id, notice, thread: carried, chosen }, link, context) {
273
+ const sessions = this.live.sessions();
228
274
  const { address, name } = receiver;
229
275
  if (address === caller.address) return outcome(EXIT_REFUSED, 'Not sent: that address is your own session.');
230
276
  const door = parseAddress(address).harness === AGENT_HARNESS ? null : this.#door(address, sessions);
@@ -250,7 +296,14 @@ export class MailSend {
250
296
  });
251
297
  const plan = await this.#plan(sent, address, {});
252
298
  if (plan.outcome) return plan.outcome;
253
- if (plan.plan) return this.#deliverPlanned(caller, sent, plan.plan, { queue, notify_when_idle });
299
+ const recipients = plan.plan ? [...new Set([address, ...(plan.plan.agent ? [plan.plan.agent] : []),
300
+ ...plan.plan.recipients.filter(item => item.address !== caller.address).map(item => item.address)])] : [address];
301
+ const refused = await this.#authorizeReceiver(context, [...(receiver.continuation?.chain ?? []), ...recipients]);
302
+ if (refused) return refused;
303
+ if (plan.plan) {
304
+ const answer = await this.#deliverPlanned(caller, sent, plan.plan, { queue, notify_when_idle, continuation: receiver.continuation });
305
+ return { ...answer, receipt: answer.receipt ? { ...answer.receipt, recipients } : null };
306
+ }
254
307
  // the thread this message is in, kept in the send log: a later reply to it continues that thread
255
308
  const sentIn = threadOf(sent);
256
309
  if (!door || door.kind === 'other_machine') return outcome(EXIT_UNKNOWN, this.#notYetKnown(name) ?? `${name} is no longer running. Nothing was sent. Run supercode message list for the live sessions.`);
@@ -263,7 +316,7 @@ export class MailSend {
263
316
  if (error instanceof Refused && error.kind === 'queue') return outcome(EXIT_REFUSED, `Not sent: ${name} is idle, and a Claude session always starts a turn when a message arrives, so --queue cannot hold it. Send without --queue to wake it.`);
264
317
  if (error instanceof Refused) return outcome(EXIT_REFUSED, `Not sent: the message is ${error.bytes} bytes; the limit is ${MAX_RELAYED_BYTES}. Write it to a file and send its path instead.`);
265
318
  // may have arrived: kept filed, and said unanswered with its id (sent again with the same id, nothing is sent twice)
266
- if (error.unanswered) { this.#recordSend(caller.address, address, messageId, sentIn); return outcome(EXIT_UNANSWERED, unansweredText(`${name}'s Claude relay`, error.message, messageId)); }
319
+ if (error.unanswered) { this.#recordSend(caller.address, address, messageId, sentIn, receiver.continuation); return outcome(EXIT_UNANSWERED, unansweredText(`${name}'s Claude relay`, error.message, messageId)); }
267
320
  return outcome(EXIT_FAILED, `Not sent to ${name}: ${error.message}`);
268
321
  }
269
322
  const [code, what] = {
@@ -281,7 +334,7 @@ export class MailSend {
281
334
  pane_waits: [EXIT_STORED, 'Stored (not failed): its pane\'s composer was not empty, so the message waits in its mailbox; it sees it with supercode message inbox. Don\'t resend'],
282
335
  stored: [EXIT_STORED, `Stored (not failed): it has no delivery door, so it sees this only if it runs supercode message inbox${parseAddress(address).harness === 'codex' ? ' (a Codex session gets one with: supercode message setup codex)' : ''}. Don't resend, and don't wait for a reply`],
283
336
  }[delivered];
284
- this.#recordSend(caller.address, address, messageId, sentIn);
337
+ this.#recordSend(caller.address, address, messageId, sentIn, receiver.continuation);
285
338
  const harness = parseAddress(address).harness;
286
339
  const text = code === 0
287
340
  ? `sent to ${name} (${harness}, ${door.kind}): ${what}. Message id ${shortId(messageId)}.${receiver.alias ?? ''}${idleNote} Don't poll; carry on.`
@@ -408,7 +461,7 @@ export class MailSend {
408
461
  */
409
462
  async deliverPlanned(caller, sent, plan, { queue = false, notify_when_idle = false } = {}) { return this.#deliverPlanned(caller, sent, plan, { queue, notify_when_idle }); }
410
463
 
411
- async #deliverPlanned(caller, sent, plan, { queue, notify_when_idle }) {
464
+ async #deliverPlanned(caller, sent, plan, { queue, notify_when_idle, continuation = null }) {
412
465
  const reached = [], copied = [], failed = [];
413
466
  let recorded = false;
414
467
  const held = []; // recipients whose own copy is held unread with its wake (an unanswered relay)
@@ -428,7 +481,7 @@ export class MailSend {
428
481
  const sessions = this.live.sessions();
429
482
  for (const recipient of plan.recipients.filter((item) => item.address !== caller.address)) {
430
483
  const label = this.#label(sessions, recipient.address);
431
- this.#recordSend(caller.address, recipient.address, sent.id, threadOf(sent));
484
+ this.#recordSend(caller.address, recipient.address, sent.id, threadOf(sent), continuation);
432
485
  if (!recipient.wake) {
433
486
  try { this.#fileUnread(recipient.address, sent); copied.push(label); } catch (error) { failed.push(`${label} (${error.message})`); }
434
487
  continue;
@@ -914,10 +967,10 @@ export class MailSend {
914
967
  return depth;
915
968
  }
916
969
 
917
- #recordSend(sender, to, id, thread) {
970
+ #recordSend(sender, to, id, thread, continuation = null) {
918
971
  try {
919
972
  mkdirSync(this.#sentDir(sender), { recursive: true });
920
- appendFileSync(join(this.#sentDir(sender), 'log.jsonl'), `${JSON.stringify({ to, at_ms: Date.now(), id, thread })}\n`);
973
+ appendFileSync(join(this.#sentDir(sender), 'log.jsonl'), `${JSON.stringify({ to, at_ms: Date.now(), id, thread, ...(continuation ? { continuation } : {}) })}\n`);
921
974
  } catch { /* the log is best kept */ }
922
975
  }
923
976
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.3.150",
3
+ "version": "0.3.151",
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",