@volter/supercode-teams 0.3.98 → 0.3.100

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
  }
@@ -3391,7 +3419,8 @@ export class TeamsMachine extends MachineCore {
3391
3419
  case 'harness.v1.mail.handover': {
3392
3420
  if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('mail is handed over here by this machine\'s own processes'), { code: 403 });
3393
3421
  if (typeof params.address !== 'string' || typeof params.id !== 'string' || !parseAddress(params.address)) throw Object.assign(new Error('a hand-over names an address and a message id'), { code: 400 });
3394
- return this.mailSend.handOver(params.address, params.id, { markRead: params.mark_read === true, wake: params.wake === true });
3422
+ // a hand-over is moved to cur/ once delivered whatever `mark_read` says (MailSend.handOver)
3423
+ return this.mailSend.handOver(params.address, params.id, { wake: params.wake === true });
3395
3424
  }
3396
3425
  // harness serve's `sessions.message` (its mail to a session or an agent, not its `as_user` turn), asked here by
3397
3426
  // harness serve itself: one implementation of a send, whatever the entry.
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
@@ -52,6 +52,7 @@ export class MailSend {
52
52
  */
53
53
  constructor({ machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, typeTurn, log = () => {} }) {
54
54
  Object.assign(this, { machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, typeTurn, log });
55
+ this.handing = new Set(); // `<address>\n<id>` of each hand-over running here (handOver)
55
56
  }
56
57
 
57
58
  // ---- one send ----------------------------------------------------------------------------------------------------
@@ -254,6 +255,8 @@ export class MailSend {
254
255
  catch (error) {
255
256
  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
257
  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.`);
258
+ // may have arrived: kept filed, and said unanswered with its id (sent again with the same id, nothing is sent twice)
259
+ if (error.unanswered) { this.#recordSend(caller.address, address, messageId, sentIn); return outcome(EXIT_UNANSWERED, unansweredText(`${name}'s Claude relay`, error.message, messageId)); }
257
260
  return outcome(EXIT_FAILED, `Not sent to ${name}: ${error.message}`);
258
261
  }
259
262
  const [code, what] = {
@@ -321,7 +324,17 @@ export class MailSend {
321
324
  const filedHere = this.store.findIn(to, handed.id) == null;
322
325
  const filed = this.store.deliverRead(to, handed);
323
326
  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); }
327
+ // a send the relay may have made keeps its copy and is unanswered, never taken back and said not sent; only one the
328
+ // relay is known not to have made (refused, never handed in) takes its copy back. One its receiver read from its
329
+ // mailbox meanwhile (its recipient claim) has reached it. Otherwise the relay can no longer make it (its queue is
330
+ // gone, so its gate refuses a late send) and it is not in the receiver's transcript: a copy this delivery filed
331
+ // goes back unread, for the receiver's own mailbox read and the mailbox's wake, which hands it over again
332
+ const received = this.store.claimOf(to, handed.id)?.recipient === true;
333
+ if (!relayed.delivered && relayed.unknown && !received) {
334
+ if (filedHere) { try { this.store.markUnread(to, filed); if (wake) this.store.requestWake(to, handed.id); } catch { /* kept read */ } }
335
+ throw Object.assign(new Error(relayed.detail), { unanswered: true });
336
+ }
337
+ if (!relayed.delivered && !received) { if (filedHere) { try { unlinkSync(filed); } catch { /* gone */ } } throw new Error(relayed.detail); }
325
338
  delivered = busy ? 'native_busy' : 'native_idle';
326
339
  } else if (door.kind === 'hook') {
327
340
  this.store.deliver(to, sent);
@@ -368,7 +381,7 @@ export class MailSend {
368
381
  continue;
369
382
  }
370
383
  try { reached.push(`${label} (${await this.#deliverWoken(sent, recipient.address, sessions, queue, notify_when_idle)})`); }
371
- catch (error) { failed.push(`${label} (${error.message})`); }
384
+ catch (error) { failed.push(error.unanswered ? `${label} (unanswered, whether it arrived is unknown: ${error.message})` : `${label} (${error.message})`); }
372
385
  }
373
386
  const thread = plan.thread ? ` Thread ${shortId(plan.thread.id)}.` : '';
374
387
  let text;
@@ -517,6 +530,7 @@ export class MailSend {
517
530
  try { how = await this.route(sent, receiver, door, true, params.notify_when_idle === true); }
518
531
  catch (error) {
519
532
  if (error instanceof Refused && error.kind === 'too_long') return refused('too_long', `the message is ${error.bytes} bytes; the limit is ${MAX_RELAYED_BYTES}`);
533
+ if (error.unanswered) return { ...refused('unanswered', unansweredText(`the Claude relay`, error.message, sent.id)), message_id: sent.id };
520
534
  return refused('delivery_failed', error.message);
521
535
  }
522
536
  return {
@@ -652,16 +666,27 @@ export class MailSend {
652
666
  /**
653
667
  * A message filed unread in `address`'s mailbox handed to its session's door, once (mail_watch.rs `carry` and message.rs
654
668
  * `push`, and the native watch's notices): only a door that hands it to the session (a relay, a runtime) takes it, a
655
- * hook's is woken when `wake`, and an agent's mail is planned; `markRead` moves it to `cur/` once handed over. The
656
- * hand-over is recorded on its wake before it starts, so one interrupted mid-way is never handed over again. Answers
669
+ * hook's is woken when `wake`, and an agent's mail is planned. One handed over is moved to `cur/` here, whoever asked:
670
+ * its receiver has it, so its next inbox read never shows it again (the carrier marks it read only when its own call
671
+ * answers delivered, which a hand-over past its bound never does). The
672
+ * hand-over is recorded on its wake before it starts, so one interrupted mid-way (its daemon ended) is never handed over
673
+ * again; one still running here, its carrier past its bound, answers 'later' until it settles. Answers
657
674
  * `{ outcome: 'delivered' | 'filed' | 'later', detail }`.
658
675
  */
659
- async handOver(address, id, { markRead = false, wake = false } = {}) {
676
+ async handOver(address, id, { wake = false } = {}) {
660
677
  const found = this.store.findIn(address, id);
661
678
  if (!found) return { outcome: 'filed', detail: 'not in its mailbox' };
662
679
  const sent = readJson(found.path);
663
680
  if (!sent) return { outcome: 'later', detail: 'its envelope could not be read' };
664
681
  if (found.state !== 'unread') return { outcome: 'filed', detail: 'already read' };
682
+ // one already handed to its receiver's door (that delivery's claim, the door's or the carrier's: a daemon that ended
683
+ // between the delivery and moving the copy left it unread) is moved to `cur/`, never handed over again. A reader's
684
+ // claim is not one: a reader that ended before reading put the copy back unread, and it is handed over
685
+ const claim = this.store.claimOf(address, id);
686
+ if (claim?.recipient === true && (claim.via === 'door' || claim.via === 'carrier')) {
687
+ this.store.markRead(address, found.path);
688
+ return { outcome: 'filed', detail: 'already handed over' };
689
+ }
665
690
  const parsed = parseAddress(address);
666
691
  if (parsed?.harness === AGENT_HARNESS) {
667
692
  const plan = await this.#plan(sent, address, {});
@@ -678,16 +703,27 @@ export class MailSend {
678
703
  return { outcome: 'filed', detail: `its door is ${door.kind}` };
679
704
  }
680
705
  const state = this.store.wakeState(address, id);
706
+ // one still running here (its carrier gave up on its answer, past its bound) is waited for: the wake stays and is
707
+ // tried again after its backoff, settled by that hand-over's own outcome
708
+ const key = `${address}\n${id}`;
709
+ if (this.handing.has(key)) return { outcome: 'later', detail: 'its hand-over is still in progress' };
681
710
  if (state.handover_at_ms != null) return { outcome: 'filed', detail: 'an earlier hand-over never settled' };
682
711
  this.store.setWakeState(address, id, { ...state, handover_at_ms: Date.now() });
712
+ this.handing.add(key);
683
713
  try {
684
- await this.route(sent, address, door, true, false);
685
- if (markRead) { const now = this.store.findIn(address, id); if (now?.state === 'unread') this.store.markRead(address, now.path); }
714
+ const how = await this.route(sent, address, door, true, false);
715
+ // handed to the session (or already its): moved to cur/ (one only queued for a runtime not woken would stay unread)
716
+ const now = this.store.findIn(address, id);
717
+ if (how !== 'queued' && now?.state === 'unread') this.store.markRead(address, now.path);
686
718
  return { outcome: 'delivered', detail: null };
687
719
  } catch (error) {
688
720
  // a message its door can never take (too long for a relay) stays filed for the session to read
721
+ // one whose hand-over is unanswered was not found where it would have landed: it stays unread and its wake stays,
722
+ // tried again after its backoff (a wake settled as filed would leave an idle receiver never told)
723
+ if (error.unanswered) return { outcome: 'later', detail: `unanswered: ${error.message}` };
689
724
  return error instanceof Refused ? { outcome: 'filed', detail: error.kind } : { outcome: 'later', detail: error.message };
690
725
  } finally {
726
+ this.handing.delete(key);
691
727
  const after = this.store.wakeState(address, id);
692
728
  this.store.setWakeState(address, id, { ...after, handover_at_ms: null });
693
729
  }
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.100",
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",