@volter/supercode-teams 0.3.98 → 0.3.99

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/machine.mjs CHANGED
@@ -30,6 +30,7 @@ import { spawn, spawnSync, execFile } from 'node:child_process';
30
30
  import { EventEmitter } from 'node:events';
31
31
  import { appendFileSync, readFileSync, writeFileSync, writeSync, statSync, readdirSync, mkdirSync, renameSync, rmSync, existsSync, openSync, readSync, closeSync, accessSync, constants } from 'node:fs';
32
32
  import { basename, join, resolve, dirname, isAbsolute } from 'node:path';
33
+ import { open as openAsync, readdir as readdirAsync } from 'node:fs/promises';
33
34
  import { askBoardOwner, BOARD_OPERATION_VERBS, boardHome } from '@volter/teams/board-owner';
34
35
  import { openMachine, normalMachineName } from '@volter/teams/machine-door';
35
36
  import { RecordHolder, RecordProducer, RECENT_PER_MAILBOX, RECORDS_NOT_GRANTED } from './machine-records.mjs';
@@ -153,6 +154,31 @@ const LAUNCH_SETTLE_MS = 500;
153
154
  /** How long a send to another machine waits for its mail door's answer (its receiver's door there: a relay's receipt
154
155
  * alone may take 90 s). An answer that does not come is unanswered, never refused or failed. */
155
156
  const MAIL_SEND_MS = 120_000;
157
+
158
+ /** How much of a transcript's end is read for a message that just landed. */
159
+ const LANDING_TAIL_BYTES = 4 * 1024 * 1024;
160
+
161
+ /**
162
+ * Whether Claude session `sessionId`'s transcript (`<projects>/<folder>/<id>.jsonl`, in any of `projectsDirs`) holds
163
+ * `landed` in its last LANDING_TAIL_BYTES: a relay's send whose receipt was lost is read where it lands. Read apart from
164
+ * the daemon's loop (it runs once per unconfirmed send).
165
+ */
166
+ async function claudeTranscriptHolds(projectsDirs, sessionId, landed) {
167
+ for (const projects of projectsDirs) {
168
+ let folders = [];
169
+ try { folders = await readdirAsync(projects); } catch { continue; }
170
+ for (const folder of folders) {
171
+ let file;
172
+ try { file = await openAsync(join(projects, folder, `${sessionId}.jsonl`), 'r'); } catch { continue; }
173
+ try {
174
+ const { size } = await file.stat(), length = Math.min(size, LANDING_TAIL_BYTES);
175
+ const { buffer } = await file.read(Buffer.alloc(length), 0, length, size - length);
176
+ if (buffer.toString('utf8').includes(landed)) return true;
177
+ } finally { await file.close(); }
178
+ }
179
+ }
180
+ return false;
181
+ }
156
182
  /**
157
183
  * 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
158
184
  * 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
@@ -682,6 +708,7 @@ export class TeamsMachine extends MachineCore {
682
708
  runtimeOf: (harness, id) => live.runtimeReceipt(harness, id), runtimeNamed: (receiptId) => live.receiptNamed(receiptId),
683
709
  runtimesIn: (harness, cwd) => live.runtimesIn(harness, cwd),
684
710
  endpointOf: async () => (await this.relayEndpoint)?.url ?? null,
711
+ arrived: (sessionId, landed) => claudeTranscriptHolds([...live.claudeDirs.keys()].map((dir) => join(dirname(dir), 'projects')), sessionId, landed),
685
712
  log: (line) => this.emit('mail-log', line) });
686
713
  this.mailSend = new MailSend({ machine, mailRoot, live, store: this.mailStore, agents: this.agents, relays: this.relays,
687
714
  // a send waits on its receiver's door there (a relay's receipt takes up to 90 s), never past MAIL_SEND_MS
@@ -1115,7 +1142,7 @@ export class TeamsMachine extends MachineCore {
1115
1142
  this.internalRequests.set(id, { resolve, reject });
1116
1143
  if (!this.#toServe(`${JSON.stringify({ jsonrpc: '2.0', id: joinId(INTERNAL_CONNECTION, id), method, params })}\n`)) {
1117
1144
  this.internalRequests.delete(id);
1118
- return reject(new Error('harness serve is not running'));
1145
+ return reject(Object.assign(new Error('harness serve is not running'), { notAsked: true }));
1119
1146
  }
1120
1147
  setTimeout(() => { if (this.internalRequests.delete(id)) reject(new Error(`${method} timed out`)); }, timeoutMs).unref();
1121
1148
  });
@@ -1145,7 +1172,8 @@ export class TeamsMachine extends MachineCore {
1145
1172
  if (typeof value === 'string') { if (!this.routes.has(value)) this.routes.set(value, new Set()); this.routes.get(value).add(INTERNAL_CONNECTION); }
1146
1173
  }
1147
1174
  }
1148
- if (message.error) pending.reject(new Error(message.error.message ?? 'harness serve refused'));
1175
+ // an answer that is an error is marked answered: a call refused, not one whose outcome is unknown
1176
+ if (message.error) pending.reject(Object.assign(new Error(message.error.message ?? 'harness serve refused'), { answered: true, code: message.error.code ?? null }));
1149
1177
  else pending.resolve(message.result);
1150
1178
  return;
1151
1179
  }
package/mail/relay.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
  import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, watch, writeFileSync } from 'node:fs';
11
11
  import { connect } from 'node:net';
12
12
  import { join } from 'node:path';
13
- import { blake3Hex } from './envelope.mjs';
13
+ import { blake3Hex, shortId } from './envelope.mjs';
14
14
 
15
15
  /** Model name a relay's requests carry; the relay endpoint answers them without any model. */
16
16
  const RELAY_MODEL = 'haiku';
@@ -18,6 +18,26 @@ 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
+
22
+ /** How long an unconfirmed send is given to land in its receiver's transcript before that is read: a SendMessage that
23
+ * passed the gate just before the send ended lands within it. */
24
+ const LANDING_MS = 2000;
25
+
26
+ /** How long a hand-in waits for its relay's last turn to end: a relay's receipt is written mid-turn (at its
27
+ * SendMessage), so the next send through it may find that turn still finishing. Past this the turn is stuck. */
28
+ const TURN_END_MS = 15_000;
29
+
30
+ /** A relay close's bound: over harness serve's own (RUNTIME_CONTROL_DEADLINE, 30 s), past which serve ends the
31
+ * runtime's process group itself, so a relay stuck in a turn is closed through serve, never left running. */
32
+ const CLOSE_MS = 35_000;
33
+
34
+ /**
35
+ * Whether harness serve's error for a relay's input leaves its outcome unknown: no answer (its bound passed, serve ended
36
+ * mid-call) or serve's own deadline on the runtime ("gave up after", harness_service.rs `within_control_deadline`).
37
+ * Any other answer is a refusal of the input (a turn in progress, an unknown or closed connection), and a call never
38
+ * made (no serve to ask) was not handed in either.
39
+ */
40
+ const answerLost = (error) => !error.notAsked && (!error.answered || /gave up after \d+s/.test(error.message));
21
41
  /** A relay send slower than this says where its time went. */
22
42
  const SLOW_SEND_MS = 2000;
23
43
  /** Largest message relayed into a Claude session: the relay copies it into a model turn byte for byte. */
@@ -68,8 +88,8 @@ export class Relays {
68
88
  * id)` the live-runtime receipt the live-session map holds for a runtime id (its file read whole), or null;
69
89
  * `runtimeNamed(receiptId)` one receipt read by its own name.
70
90
  */
71
- constructor({ mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed = () => null, runtimesIn = () => [], endpointOf = async () => null, log = () => {} }) {
72
- Object.assign(this, { mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed, runtimesIn, endpointOf, log });
91
+ constructor({ mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed = () => null, runtimesIn = () => [], endpointOf = async () => null, arrived = async () => false, log = () => {} }) {
92
+ Object.assign(this, { mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed, runtimesIn, endpointOf, arrived, log });
73
93
  this.lost = new Map(); // relay folder → when a start of its runtime lost its answer (the runtime may run, unnamed)
74
94
  this.remoteKept = new Map(); // relay folder of a sender on another machine → the timer that retires it
75
95
  this.sending = new Map(); // relay folder → the send in progress through it (one at a time per relay)
@@ -86,7 +106,7 @@ export class Relays {
86
106
  this.running.delete(directory);
87
107
  // closed under the relay's own lock: a send to it that arrives meanwhile waits for the close, then starts it
88
108
  // again, never handing its turn to a runtime that is closing
89
- void this.#locked(directory, () => this.serve('harness.v1.runtimes.close', { connection: relay.connection, runtime_id: relay.runtime_id }, 15_000))
109
+ void this.#locked(directory, () => this.serve('harness.v1.runtimes.close', { connection: relay.connection, runtime_id: relay.runtime_id }, CLOSE_MS))
90
110
  .catch((error) => {
91
111
  // still running, so still counted: the next cap closes it again (unless a send started it anew meanwhile)
92
112
  if (!this.running.has(directory)) this.running.set(directory, relay);
@@ -102,7 +122,7 @@ export class Relays {
102
122
  for (const ids of this.runtimesIn('claude-code', directory)) {
103
123
  if (ids.includes(named)) continue;
104
124
  void this.#locked(directory, async () => {
105
- for (const id of ids) { try { await this.serve('harness.v1.runtimes.close', { runtime_id: id }, 15_000); return; } catch { /* the other id */ } }
125
+ for (const id of ids) { try { await this.serve('harness.v1.runtimes.close', { runtime_id: id }, CLOSE_MS); return; } catch { /* the other id */ } }
106
126
  this.log(`relay ${directory.split(/[\\/]/).pop()}: a runtime its lost start left running was not closed`);
107
127
  });
108
128
  }
@@ -135,7 +155,7 @@ export class Relays {
135
155
  if (expected && this.#representedOf(directory) !== expected) return false;
136
156
  const record = readJson(join(directory, 'relay.json'));
137
157
  if (record?.runtime_id) {
138
- try { await this.serve('harness.v1.runtimes.close', { connection: record.connection ?? '', runtime_id: record.runtime_id }, 15_000); }
158
+ try { await this.serve('harness.v1.runtimes.close', { connection: record.connection ?? '', runtime_id: record.runtime_id }, CLOSE_MS); }
139
159
  catch (error) {
140
160
  // a runtime still live that could not be closed keeps its folder: it is retired again at the next end or start
141
161
  if (this.runtimeOf('claude-code', record.runtime_id)) { this.log(`relay ${directory.split(/[\\/]/).pop()} was not retired: ${error.message}`); return false; }
@@ -244,7 +264,7 @@ export class Relays {
244
264
 
245
265
  /** The relay's runtime, started in this daemon's harness serve when it is not running (claude_relay.rs
246
266
  * `ensure_relay_runtime`): `{ runtime (its receipt), connection }`. */
247
- async #ensure(spec, step = () => {}) {
267
+ async #ensure(spec, step = () => {}, { stale = false } = {}) {
248
268
  const endpoint = await this.endpoint();
249
269
  step('relay endpoint');
250
270
  if (!endpoint) throw new Error('the relay endpoint is not served by this machine\'s daemon (its log says why); a Claude session can be sent mail once the daemon starts again');
@@ -254,15 +274,16 @@ export class Relays {
254
274
  const record = readJson(spec.record);
255
275
  if (record?.runtime_id) {
256
276
  const runtime = this.runtimeOf('claude-code', record.runtime_id);
257
- if (runtime && alive(this.#pid(spec, runtime)) && record.connection && (record.endpoint === endpoint || await answers(Number(String(record.endpoint ?? '').split(':').pop())))) {
277
+ if (!stale && runtime && alive(this.#pid(spec, runtime)) && record.connection && (record.endpoint === endpoint || await answers(Number(String(record.endpoint ?? '').split(':').pop())))) {
258
278
  step('runtime reused');
259
279
  return { runtime, connection: record.connection };
260
280
  }
261
281
  // why it is not reused, said in the timing line: a relay that should have been warm and was not shows here
262
- step(`runtime not reused (${!runtime ? 'no live receipt' : !alive(this.#pid(spec, runtime)) ? 'its process ended' : !record.connection ? 'no connection' : 'endpoint moved'})`);
282
+ step(`runtime not reused (${stale ? 'it held a stale turn' : !runtime ? 'no live receipt' : !alive(this.#pid(spec, runtime)) ? 'its process ended' : !record.connection ? 'no connection' : 'endpoint moved'})`);
263
283
  // closed through the owner's door even after its receipt went
264
- try { await this.serve('harness.v1.runtimes.close', { connection: record.connection, runtime_id: record.runtime_id }, 15_000); }
284
+ try { await this.serve('harness.v1.runtimes.close', { connection: record.connection, runtime_id: record.runtime_id }, CLOSE_MS); }
265
285
  catch (error) { if (this.runtimeOf('claude-code', record.runtime_id)) throw new Error(`could not retire the old relay: ${error.message}`); }
286
+ step('old runtime closed (runtimes.close)');
266
287
  }
267
288
  const key = spec.directory.split(/[\\/]/).pop();
268
289
  const started = await this.#start(spec, {
@@ -297,7 +318,7 @@ export class Relays {
297
318
  if (!runtime) {
298
319
  // a runtime no send can use is closed, never left running untracked
299
320
  this.running.delete(spec.directory);
300
- try { await this.serve('harness.v1.runtimes.close', { connection, runtime_id: runtimeId }, 15_000); } catch { /* said below */ }
321
+ try { await this.serve('harness.v1.runtimes.close', { connection, runtime_id: runtimeId }, CLOSE_MS); } catch { /* said below */ }
301
322
  throw new Error('the relay started but registered no live runtime; it was closed');
302
323
  }
303
324
  return { runtime, connection };
@@ -312,11 +333,12 @@ export class Relays {
312
333
  const check = () => {
313
334
  const value = readJson(spec.receipt);
314
335
  if (value) return finish(readReceipt(value));
315
- if (pid && !alive(pid)) finish({ delivered: false, detail: 'the Claude relay process exited before confirming the send' });
336
+ // not known either way: it may have sent before it ended
337
+ if (pid && !alive(pid)) finish({ delivered: false, unknown: true, detail: 'the Claude relay process exited before confirming the send' });
316
338
  };
317
339
  try { watcher = watch(spec.directory, () => check()); watcher.on('error', () => {}); } catch { /* checked on the timer */ }
318
340
  timer = setInterval(check, 500);
319
- deadline = setTimeout(() => finish({ delivered: false, detail: `the Claude relay did not confirm the send within ${SEND_TIMEOUT_MS / 1000} seconds; it may still arrive` }), SEND_TIMEOUT_MS);
341
+ deadline = setTimeout(() => finish({ delivered: false, unknown: true, detail: `the Claude relay did not confirm the send within ${SEND_TIMEOUT_MS / 1000} seconds; it may still arrive` }), SEND_TIMEOUT_MS);
320
342
  check();
321
343
  });
322
344
  }
@@ -329,14 +351,16 @@ export class Relays {
329
351
  async send(sender, senderName, receiver, message, messageId) {
330
352
  const spec = this.spec(sender, senderName);
331
353
  const asked = performance.now();
332
- try { return await this.#locked(spec.directory, () => this.#send(spec, receiver, message, messageId, asked)); }
354
+ // the message as its receiver's transcript holds it once delivered (envelope.mjs render, its tag escaped there)
355
+ const landed = `cross-session-message id=\\"${shortId(messageId)}\\" from=\\"${sender}\\"`;
356
+ try { return await this.#locked(spec.directory, () => this.#send(spec, receiver, message, messageId, asked, landed)); }
333
357
  finally {
334
358
  if (String(sender).split(':')[1] !== this.machine) this.#keepRemote(spec.directory);
335
359
  this.#cap();
336
360
  }
337
361
  }
338
362
 
339
- async #send(spec, receiver, message, messageId, asked) {
363
+ async #send(spec, receiver, message, messageId, asked, landed) {
340
364
  try {
341
365
  // made under its lock: a retire that held the lock meanwhile may have removed it
342
366
  mkdirSync(spec.directory, { recursive: true });
@@ -351,8 +375,30 @@ export class Relays {
351
375
  writeFileSync(spec.queue, JSON.stringify(queued));
352
376
  let receipt;
353
377
  for (let attempt = 0; ; attempt++) {
354
- try { await this.serve('harness.v1.runtimes.send_input', { connection, text: sendTurn(queued) }, 30_000); step('turn handed in (send_input)'); receipt = await this.#receipt(spec, this.#pid(spec, runtime)); step('Claude\'s receipt'); }
355
- catch (error) { receipt = { delivered: false, detail: `could not reach the Claude relay: ${error.message}` }; }
378
+ // A hand-in whose answer did not come (its bound passed, serve ended mid-call, serve's own deadline on the
379
+ // runtime) may still have reached the relay, which then sends: its receipt is waited on as after any hand-in. A
380
+ // hand-in refused (an answer that is an error) or never asked (no serve) did not reach it, and is said at once,
381
+ // never held for a receipt that cannot come.
382
+ let handed = true;
383
+ const error = await this.#handIn(connection, queued, step);
384
+ if (!error) step('turn handed in (send_input)');
385
+ else if (answerLost(error)) step(`turn handed in, its answer lost (send_input: ${error.message})`);
386
+ else {
387
+ handed = false;
388
+ // still in a turn after TURN_END_MS while this send holds its lock: a turn of a send that already ended (its
389
+ // queue gone, so its gate refuses whatever that turn tries), stuck. The relay is closed through serve and
390
+ // started again, once
391
+ if (attempt === 0 && /already in progress/.test(error.message)) {
392
+ step(`relay still in a turn after ${TURN_END_MS / 1000} s, closed (${error.message})`);
393
+ ({ runtime, connection } = await this.#ensure(spec, step, { stale: true }));
394
+ this.#used(spec, connection);
395
+ rmSync(spec.receipt, { force: true });
396
+ continue;
397
+ }
398
+ receipt = { delivered: false, detail: `the Claude relay did not take the send: ${error.message}` };
399
+ }
400
+ if (!handed) break;
401
+ receipt = await this.#receipt(spec, this.#pid(spec, runtime)); step('Claude\'s receipt');
356
402
  const pid = this.#pid(spec, runtime);
357
403
  // only a relay proven dead is started again; one alive but slow may still deliver
358
404
  if (receipt.delivered || attempt === 1 || pid == null || alive(pid)) break;
@@ -360,6 +406,16 @@ export class Relays {
360
406
  rmSync(spec.receipt, { force: true });
361
407
  }
362
408
  rmSync(spec.queue, { force: true });
409
+ // an outcome not confirmed is read where it would have landed: the relay can no longer send it (its queue is
410
+ // gone), so the receiver's transcript says whether it did
411
+ if (!receipt.delivered && receipt.unknown) {
412
+ await new Promise((resolve) => setTimeout(resolve, LANDING_MS));
413
+ let found = false;
414
+ try { found = await this.arrived(receiver.sessionId, landed); } catch { /* unread: unknown */ }
415
+ step(found ? 'found in its receiver\'s transcript' : 'not in its receiver\'s transcript');
416
+ if (found) receipt = { delivered: true };
417
+ else receipt = { ...receipt, landed: false };
418
+ }
363
419
  if (receipt.delivered) {
364
420
  const sent = readJson(spec.sent) ?? {};
365
421
  sent[receiver.sessionId] = messageId;
@@ -374,6 +430,25 @@ export class Relays {
374
430
  }
375
431
  }
376
432
 
433
+ /**
434
+ * Hand the send's turn to the relay (`runtimes.send_input`): null once taken, else the error. A relay still in a turn
435
+ * (its last send's: its receipt is written mid-turn, the turn ends a moment later) is asked again every 250 ms for up
436
+ * to TURN_END_MS; the error is then that refusal.
437
+ */
438
+ async #handIn(connection, queued, step) {
439
+ const asked = performance.now();
440
+ for (;;) {
441
+ try { await this.serve('harness.v1.runtimes.send_input', { connection, text: sendTurn(queued) }, 30_000); }
442
+ catch (error) {
443
+ if (answerLost(error) || !/already in progress/.test(error.message) || performance.now() - asked > TURN_END_MS) return error;
444
+ await new Promise((resolve) => setTimeout(resolve, 250));
445
+ continue;
446
+ }
447
+ if (performance.now() - asked > 250) step(`waited for its last turn to end`);
448
+ return null;
449
+ }
450
+ }
451
+
377
452
  /** `runtimes.start` for a relay: a start that fails (its answer lost, or late past its bound) may still have left a
378
453
  * runtime running, so its folder is looked at by the next caps (#cap). */
379
454
  async #start(spec, params, timeoutMs) {
package/mail/send.mjs CHANGED
@@ -254,6 +254,8 @@ export class MailSend {
254
254
  catch (error) {
255
255
  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.`);
256
256
  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.`);
257
+ // may have arrived: kept filed, and said unanswered with its id (sent again with the same id, nothing is sent twice)
258
+ if (error.unanswered) { this.#recordSend(caller.address, address, messageId, sentIn); return outcome(EXIT_UNANSWERED, unansweredText(`${name}'s Claude relay`, error.message, messageId)); }
257
259
  return outcome(EXIT_FAILED, `Not sent to ${name}: ${error.message}`);
258
260
  }
259
261
  const [code, what] = {
@@ -321,7 +323,17 @@ export class MailSend {
321
323
  const filedHere = this.store.findIn(to, handed.id) == null;
322
324
  const filed = this.store.deliverRead(to, handed);
323
325
  const relayed = await this.relays.send(handed.from, handed.from_name, { sessionId: door.session.address.split(':').slice(3).join(':'), name: door.session.name.split('@')[0], socket: door.session.socket }, text, handed.id);
324
- if (!relayed.delivered) { if (filedHere) { try { unlinkSync(filed); } catch { /* gone */ } } throw new Error(relayed.detail); }
326
+ // a send the relay may have made keeps its copy and is unanswered, never taken back and said not sent; only one the
327
+ // relay is known not to have made (refused, never handed in) takes its copy back. One its receiver read from its
328
+ // mailbox meanwhile (its recipient claim) has reached it. Otherwise the relay can no longer make it (its queue is
329
+ // gone, so its gate refuses a late send) and it is not in the receiver's transcript: a copy this delivery filed
330
+ // goes back unread, for the receiver's own mailbox read and the mailbox's wake, which hands it over again
331
+ const received = this.store.claimOf(to, handed.id)?.recipient === true;
332
+ if (!relayed.delivered && relayed.unknown && !received) {
333
+ if (filedHere) { try { this.store.markUnread(to, filed); if (wake) this.store.requestWake(to, handed.id); } catch { /* kept read */ } }
334
+ throw Object.assign(new Error(relayed.detail), { unanswered: true });
335
+ }
336
+ if (!relayed.delivered && !received) { if (filedHere) { try { unlinkSync(filed); } catch { /* gone */ } } throw new Error(relayed.detail); }
325
337
  delivered = busy ? 'native_busy' : 'native_idle';
326
338
  } else if (door.kind === 'hook') {
327
339
  this.store.deliver(to, sent);
@@ -368,7 +380,7 @@ export class MailSend {
368
380
  continue;
369
381
  }
370
382
  try { reached.push(`${label} (${await this.#deliverWoken(sent, recipient.address, sessions, queue, notify_when_idle)})`); }
371
- catch (error) { failed.push(`${label} (${error.message})`); }
383
+ catch (error) { failed.push(error.unanswered ? `${label} (unanswered, whether it arrived is unknown: ${error.message})` : `${label} (${error.message})`); }
372
384
  }
373
385
  const thread = plan.thread ? ` Thread ${shortId(plan.thread.id)}.` : '';
374
386
  let text;
@@ -517,6 +529,7 @@ export class MailSend {
517
529
  try { how = await this.route(sent, receiver, door, true, params.notify_when_idle === true); }
518
530
  catch (error) {
519
531
  if (error instanceof Refused && error.kind === 'too_long') return refused('too_long', `the message is ${error.bytes} bytes; the limit is ${MAX_RELAYED_BYTES}`);
532
+ if (error.unanswered) return { ...refused('unanswered', unansweredText(`the Claude relay`, error.message, sent.id)), message_id: sent.id };
520
533
  return refused('delivery_failed', error.message);
521
534
  }
522
535
  return {
@@ -686,6 +699,9 @@ export class MailSend {
686
699
  return { outcome: 'delivered', detail: null };
687
700
  } catch (error) {
688
701
  // a message its door can never take (too long for a relay) stays filed for the session to read
702
+ // one whose hand-over is unanswered was not found where it would have landed: it stays unread and its wake stays,
703
+ // tried again after its backoff (a wake settled as filed would leave an idle receiver never told)
704
+ if (error.unanswered) return { outcome: 'later', detail: `unanswered: ${error.message}` };
689
705
  return error instanceof Refused ? { outcome: 'filed', detail: error.kind } : { outcome: 'later', detail: error.message };
690
706
  } finally {
691
707
  const after = this.store.wakeState(address, id);
package/mail/store.mjs CHANGED
@@ -397,6 +397,12 @@ export class MailStore extends EventEmitter {
397
397
  renameSync(path, join(box.dir, 'cur', path.split(/[\\/]/).pop()));
398
398
  }
399
399
 
400
+ /** A message filed read moved back to `new/`, unread, under its own name: a hand-over that may not have reached it. */
401
+ markUnread(address, path) {
402
+ const box = this.open(address);
403
+ renameSync(path, join(box.dir, 'new', path.split(/[\\/]/).pop()));
404
+ }
405
+
400
406
  /** A wake kept beside the envelope (mailbox.rs `request_wake`): its watcher hands it to the session's door. */
401
407
  requestWake(address, id) {
402
408
  if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error('invalid wake message id');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.3.98",
3
+ "version": "0.3.99",
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",