@volter/supercode-teams 0.3.104 → 0.3.106

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
@@ -41,6 +41,7 @@ import { render } from './mail/envelope.mjs';
41
41
  import { Agents } from './mail/agents.mjs';
42
42
  import { Relays } from './mail/relay.mjs';
43
43
  import { serveRelayEndpoint } from './mail/relay-endpoint.mjs';
44
+ import { MailCarrier } from './mail/carrier.mjs';
44
45
  import { MailSend } from './mail/send.mjs';
45
46
  import { AgentVerbs } from './mail/verbs.mjs';
46
47
  import { deliverToRuntime } from './mail/runtime.mjs';
@@ -729,19 +730,44 @@ export class TeamsMachine extends MachineCore {
729
730
  endpointOf: async () => (await this.relayEndpoint)?.url ?? null,
730
731
  arrived: (sessionId, landed) => claudeTranscriptHolds([...live.claudeDirs.keys()].map((dir) => join(dirname(dir), 'projects')), sessionId, landed),
731
732
  log: (line) => this.emit('mail-log', line) });
733
+ // a turn or a wake typed into a daemon pane's empty composer, in process (as `panes.turn` types one)
734
+ const typeTurn = async (pane, harness, text, messageIds) => {
735
+ const session = await this.#paneByContext(pane);
736
+ if (!session) throw new Error(`no pane with context ${pane}`);
737
+ const result = await this.#paneTurn(session, pane, text, { messageIds, harness });
738
+ if (result.refused) throw new Error(result.refused.reason);
739
+ return result.waits ?? result;
740
+ };
732
741
  this.mailSend = new MailSend({ machine, mailRoot, live, store: this.mailStore, agents: this.agents, relays: this.relays,
733
742
  // a send waits on its receiver's door there (a relay's receipt takes up to 90 s), never past MAIL_SEND_MS
734
743
  askMachine: (to, request) => this.#askMailDoor(to, request, MAIL_SEND_MS),
735
744
  deliverRuntime: deliverToRuntime, codexAnswer: (request) => codexQuestions(request),
736
745
  // a user's own turn typed into its pane, in process (as `panes.turn` types one)
737
- typeTurn: async (pane, harness, text, messageIds) => {
738
- const session = await this.#paneByContext(pane);
739
- if (!session) throw new Error(`no pane with context ${pane}`);
740
- const result = await this.#paneTurn(session, pane, text, { messageIds, harness });
741
- if (result.refused) throw new Error(result.refused.reason);
742
- return result.waits ?? result;
743
- },
746
+ typeTurn,
744
747
  log: (line) => this.emit('mail-log', line) });
748
+ // the mailbox's own delivery (step 4b): each wake attempted by its session's door, or carried to its machine
749
+ this.carrier = new MailCarrier({ machine, mailRoot, store: this.mailStore, live,
750
+ handOver: (address, id) => this.mailSend.handOver(address, id),
751
+ typeTurn, askMachine: (to, request) => this.#askMailDoor(to, request, MAIL_SEND_MS),
752
+ log: (line) => this.emit('mail-log', line) }).start();
753
+ // the user's own turns that wait in a pane session's mailbox (a draft in its composer when they came) are typed once
754
+ // its composer is free: tried at each change of its mailbox, and every 2 s while one waits (mail_watch.rs
755
+ // `deliver_waiting_user_turns` read every pane's mailbox every 2 s)
756
+ const turnsWaiting = new Map(); // address → timer
757
+ const typeWaiting = async (address) => {
758
+ clearTimeout(turnsWaiting.get(address)); turnsWaiting.delete(address);
759
+ const session = live.sessions().find((row) => row.address === address);
760
+ const pane = session ? this.mail.paneOf(session) : null;
761
+ if (!pane || !this.mailStore.userTurns(address).length) return;
762
+ try { await this.mailSend.typeUserTurns(address, pane); } catch (error) { this.emit('mail-log', `the user's turns for ${address} wait: ${error.message}`); }
763
+ if (this.mailStore.userTurns(address).length) { const timer = setTimeout(() => void typeWaiting(address), 2000); timer.unref?.(); turnsWaiting.set(address, timer); }
764
+ };
765
+ this.mailStore.on('changed', (address) => { if (address && !turnsWaiting.has(address)) void typeWaiting(address); });
766
+ // the turns already waiting when this listener came (the store read every mailbox at its start, before it), and every
767
+ // mailbox again once per 5 min, so a turn no event named is typed within that bound
768
+ const everyMailbox = () => { for (const address of this.mailStore.addresses()) if (!turnsWaiting.has(address)) void typeWaiting(address); };
769
+ everyMailbox();
770
+ this.turnsSweep = setInterval(everyMailbox, 300_000); this.turnsSweep.unref?.();
745
771
  // an agent's verbs (3b-2): its threads, a delegation, its record
746
772
  this.agentVerbs = new AgentVerbs({ machine, store: this.mailStore, agents: this.agents, live, supercodeBin: this.supercodeBin, env: this.env,
747
773
  paneOf: (session) => this.mail.paneOf(session), mailRoot,
@@ -1053,6 +1079,8 @@ export class TeamsMachine extends MachineCore {
1053
1079
  this.stoppingMailWatch = true;
1054
1080
  this.mailWatch?.kill('SIGTERM');
1055
1081
  void this.relayEndpoint?.then((endpoint) => endpoint?.close()).catch(() => {});
1082
+ this.carrier?.stop();
1083
+ clearInterval(this.turnsSweep);
1056
1084
  this.stoppingServe = true;
1057
1085
  clearTimeout(this.serveRestart);
1058
1086
  // said, so a stop reads as one in the log and never as a serve that broke
@@ -0,0 +1,311 @@
1
+ // The mailbox's own delivery (D140; docs/architecture/overview.md, step 4b): every message filed with a wake (by a filer
2
+ // that does not wait, a board's dispatcher, a send to a hooked session) is handed to its session by the session's own
3
+ // door, by this daemon. Mail for another machine is carried there through its mail door and filed with the same wake,
4
+ // so that machine's daemon delivers it. The native watch did this by reading every mailbox every two seconds
5
+ // (mail_watch.rs `MailCarrier`); here a wake is followed from its own file event (one read of the mail folder at start),
6
+ // and each is attempted when it is due. Its rules and its records are the native carrier's, file for file:
7
+ // - a failed attempt waits FIRST_RETRY_MS doubling to LAST_RETRY_MS, kept in the wake itself (`wake/<id>`);
8
+ // - a wake expires once it has failed WAKE_ATTEMPTS counted times and its first counted failure is older than its bound
9
+ // (LOCAL_WAKE_LIFETIME_MS here, REMOTE_WAKE_LIFETIME_MS for another machine): the message stays filed and unread, and
10
+ // the expiry is recorded in its mailbox (`expired/<id>.json`, read by `supercode message expired`);
11
+ // - mail for a machine that is offline is not failing: the machine is probed once per LAST_RETRY_MS (its wait kept once
12
+ // per machine, `machines/<machine>.json`, read by `supercode message waiting`), all its mail is carried when it
13
+ // answers, and the mail expires only past OFFLINE_CEILING_MS;
14
+ // - a hand-over is waited for at most HANDOVER_BOUND_MS, and one mailbox's attempt never waits on another's;
15
+ // - each attempt is a line of `carrier.jsonl`.
16
+ import { appendFileSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { render } from './envelope.mjs';
19
+
20
+ const FIRST_RETRY_MS = 2_000;
21
+ const LAST_RETRY_MS = 300_000;
22
+ const WAKE_ATTEMPTS = 8;
23
+ const LOCAL_WAKE_LIFETIME_MS = 30 * 60_000;
24
+ const REMOTE_WAKE_LIFETIME_MS = 3 * 24 * 60 * 60_000;
25
+ const OFFLINE_CEILING_MS = 30 * 24 * 60 * 60_000;
26
+ // a hand-over is waited for this long (mail_watch.rs HANDOVER_BOUND); one still running past it is attempted again later,
27
+ // and the hand-over itself answers 'later' until it settles (MailSend.handOver), so a frozen door holds only its own
28
+ // mailbox's wakes, and only for this long
29
+ const HANDOVER_BOUND_MS = 150_000;
30
+
31
+ const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
32
+ const offline = (text) => String(text).includes('machine_offline');
33
+ const safeMachine = (machine) => String(machine).replace(/[^A-Za-z0-9._-]/g, '_');
34
+
35
+ export class MailCarrier {
36
+ /**
37
+ * `store` the mail store (its events name each wake); `live` the live-session map (a hooked session's pane);
38
+ * `handOver(address, id)` the daemon's hand-over of a filed message to its session's door (mail/send.mjs);
39
+ * `typeTurn(pane, harness, text, ids)` a wake typed into a hooked session's pane; `askMachine(machine, request)` one
40
+ * request to another machine's mail door.
41
+ */
42
+ constructor({ machine, mailRoot, store, live, handOver, typeTurn, askMachine, log = () => {} }) {
43
+ Object.assign(this, { machine, mailRoot, store, live, handOver, typeTurn, askMachine, log });
44
+ this.timers = new Map(); // `${folder}\0${id}` → timer of a wake here, due
45
+ this.running = new Set(); // mailbox folders with an attempt in flight
46
+ this.machines = new Map(); // machine → timer of its next carry
47
+ this.carrying = new Set(); // machines with a carry in flight
48
+ this.remote = new Map(); // machine → Map(`${folder}\0${id}` → { folder, address, id }): wakes for another machine
49
+ }
50
+
51
+ /** Follow every wake: those filed before this daemon (one read of the mail folder), each as the store names it (its
52
+ * file's event, or a mailbox the store read whole), and every wake again once per LAST_RETRY_MS, so a wake no event
53
+ * named is carried within that bound. */
54
+ start() {
55
+ this.store.on('wake', ({ folder, id }) => this.#wake(folder, id));
56
+ this.#sweep();
57
+ this.sweeper = setInterval(() => this.#sweep(), LAST_RETRY_MS);
58
+ this.sweeper.unref?.();
59
+ return this;
60
+ }
61
+
62
+ /** Every mailbox's wakes read from its folder and followed (one already due or scheduled is not scheduled twice). */
63
+ #sweep() {
64
+ let folders = [];
65
+ try { folders = readdirSync(this.mailRoot); } catch { return; }
66
+ for (const folder of folders) this.#rewake(folder);
67
+ }
68
+
69
+ stop() { clearInterval(this.sweeper); for (const timer of [...this.timers.values(), ...this.machines.values()]) clearTimeout(timer); this.timers.clear(); this.machines.clear(); }
70
+
71
+ #address(folder) { try { return readFileSync(join(this.mailRoot, folder, 'address'), 'utf8').trim(); } catch { return null; } }
72
+
73
+ /** A wake seen (its file written or changed): attempted when it is due. */
74
+ #wake(folder, id) {
75
+ if (!/^[A-Za-z0-9_-]+$/.test(id)) return;
76
+ const address = this.#address(folder);
77
+ if (!address) return;
78
+ const machine = address.split(':')[1];
79
+ if (machine !== this.machine) {
80
+ if (!this.remote.has(machine)) this.remote.set(machine, new Map());
81
+ this.remote.get(machine).set(`${folder}\0${id}`, { folder, address, id });
82
+ this.#scheduleMachine(machine);
83
+ return;
84
+ }
85
+ const key = `${folder}\0${id}`;
86
+ if (this.timers.has(key)) return;
87
+ const state = this.store.wakeState(address, id);
88
+ const timer = setTimeout(() => { this.timers.delete(key); void this.#local(folder, address); }, Math.max(0, (state.next_at_ms ?? 0) - Date.now()));
89
+ timer.unref?.();
90
+ this.timers.set(key, timer);
91
+ }
92
+
93
+ /** The wakes of one mailbox here that are due, attempted (mail_watch.rs `tick`, its local half). */
94
+ async #local(folder, address) {
95
+ if (this.running.has(folder)) { setTimeout(() => this.#rewake(folder), 1000).unref?.(); return; }
96
+ this.running.add(folder);
97
+ try {
98
+ const now = Date.now();
99
+ const due = this.#pending(folder, address).filter((id) => (this.store.wakeState(address, id).next_at_ms ?? 0) <= now);
100
+ if (!due.length) return;
101
+ // a session in a daemon pane whose mail is typed there: a hooked one (its hook shows it the rest), and one with no
102
+ // hook and no relay (Grok), whose mail that found its composer busy waits here until it is free
103
+ const session = this.live.sessions().find((row) => row.address === address);
104
+ if ((session?.door === 'hook' || session?.door === 'pane') && session.pane) {
105
+ for (const id of due) {
106
+ const stored = this.store.findIn(address, id);
107
+ const envelope = stored ? readJson(stored.path) : null;
108
+ let carried;
109
+ if (!envelope) carried = { kind: 'filed' };
110
+ else {
111
+ try {
112
+ const typed = await this.typeTurn(session.pane, address.split(':')[2], render(envelope), [id]);
113
+ // held: the daemon's queue for the session types it once the composer frees; nothing asks it again
114
+ carried = typed?.delivered ? { kind: 'delivered' } : typed?.queued ? { kind: 'filed' } : { kind: 'later', reason: 'its pane was not at its prompt' };
115
+ } catch (error) { carried = { kind: 'later', reason: error.message }; }
116
+ }
117
+ this.#record({ to: address, id, path: `${session.door}-pane`, carried });
118
+ this.#settle(folder, address, id, carried);
119
+ }
120
+ return;
121
+ }
122
+ for (const id of due) {
123
+ const found = this.store.findIn(address, id);
124
+ let carried;
125
+ // its recipient has it already (its own read, or a hand-over by an attempt that ended before it was settled):
126
+ // never delivered twice; a read by anyone else does not count
127
+ if (!found || (found.state === 'read' && this.store.claimOf(address, id)?.recipient === true)) carried = { kind: 'filed' };
128
+ else {
129
+ try {
130
+ let bound;
131
+ const answer = await Promise.race([
132
+ this.handOver(address, id),
133
+ new Promise((settle) => { bound = setTimeout(() => settle({ outcome: 'later', detail: `its hand-over was still running after ${HANDOVER_BOUND_MS / 1000} s` }), HANDOVER_BOUND_MS); bound.unref?.(); }),
134
+ ]).finally(() => clearTimeout(bound));
135
+ carried = answer?.outcome === 'delivered' ? { kind: 'delivered' } : answer?.outcome === 'filed' ? { kind: 'filed' } : { kind: 'later', reason: answer?.detail ?? 'not handed over' };
136
+ } catch (error) { carried = { kind: 'later', reason: `this machine's daemon did not hand it over: ${error.message}` }; }
137
+ }
138
+ this.#record({ to: address, id, path: 'door', carried });
139
+ this.#settle(folder, address, id, carried);
140
+ }
141
+ } finally {
142
+ this.running.delete(folder);
143
+ this.#rewake(folder);
144
+ }
145
+ }
146
+
147
+ /** A mailbox's wakes followed again (each one scheduled at its own due time). */
148
+ #rewake(folder) {
149
+ let ids = [];
150
+ try { ids = readdirSync(join(this.mailRoot, folder, 'wake')); } catch { return; }
151
+ for (const id of ids) this.#wake(folder, id);
152
+ }
153
+
154
+ /** A mailbox's wakes whose message is still filed; a wake whose message is gone is let go (mailbox.rs `pending_wakes`). */
155
+ #pending(folder, address) {
156
+ let ids = [];
157
+ try { ids = readdirSync(join(this.mailRoot, folder, 'wake')); } catch { return []; }
158
+ return ids.filter((id) => {
159
+ if (this.store.findIn(address, id)) return true;
160
+ try { rmSync(join(this.mailRoot, folder, 'wake', id), { force: true }); } catch { /* gone */ }
161
+ return false;
162
+ });
163
+ }
164
+
165
+ // ---- another machine's mail ------------------------------------------------------------------------------------
166
+
167
+ #machineWaitPath(machine) { return join(this.mailRoot, 'machines', `${safeMachine(machine)}.json`); }
168
+
169
+ /** A machine's mail carried at its next due time: its wait's next probe when it is offline, else its earliest wake. */
170
+ #scheduleMachine(machine) {
171
+ if (this.machines.has(machine) || this.carrying.has(machine)) return;
172
+ const wait = readJson(this.#machineWaitPath(machine));
173
+ let at = wait?.next_probe_ms ?? null;
174
+ if (at == null) {
175
+ at = Infinity;
176
+ for (const { address, id } of this.#remoteWakes(machine)) at = Math.min(at, this.store.wakeState(address, id).next_at_ms ?? 0);
177
+ if (!Number.isFinite(at)) return;
178
+ }
179
+ const timer = setTimeout(() => { this.machines.delete(machine); void this.#carry(machine); }, Math.max(0, at - Date.now()));
180
+ timer.unref?.();
181
+ this.machines.set(machine, timer);
182
+ }
183
+
184
+ /** Every wake filed here for `machine` (as their events named them), with when its message was filed, oldest first; a
185
+ * wake whose file or message is gone is let go. */
186
+ #remoteWakes(machine) {
187
+ const items = [];
188
+ for (const [key, item] of this.remote.get(machine) ?? []) {
189
+ let present = true;
190
+ try { readFileSync(join(this.mailRoot, item.folder, 'wake', item.id)); } catch { present = false; }
191
+ const found = present ? this.store.findIn(item.address, item.id) : null;
192
+ if (!found) { this.remote.get(machine).delete(key); if (present) { try { rmSync(join(this.mailRoot, item.folder, 'wake', item.id), { force: true }); } catch { /* gone */ } } continue; }
193
+ const envelope = readJson(found.path);
194
+ items.push({ ...item, filed: Number(envelope?.created_at_ms) || 0 });
195
+ }
196
+ return items.sort((a, b) => a.filed - b.filed);
197
+ }
198
+
199
+ /** One machine's mail carried (mail_watch.rs `tick`, its remote half): its oldest due message probes the machine, and
200
+ * when it answers, the rest follow in this pass. */
201
+ async #carry(machine) {
202
+ this.carrying.add(machine);
203
+ try {
204
+ const now = Date.now();
205
+ const items = this.#remoteWakes(machine);
206
+ if (!items.length) return;
207
+ const wait = readJson(this.#machineWaitPath(machine));
208
+ if (wait) {
209
+ // a machine away longer than the ceiling is not coming back for this mail: it expires, saying so
210
+ if (now - (wait.offline_since_ms ?? now) >= OFFLINE_CEILING_MS) {
211
+ const reason = `${machine} has been offline for ${OFFLINE_CEILING_MS / 86_400_000} days (since epoch ms ${wait.offline_since_ms}): ${wait.last_error ?? 'not linked'}`;
212
+ for (const item of items) this.#expire(item.folder, item.address, item.id, this.store.wakeState(item.address, item.id).attempts ?? 0, reason);
213
+ this.#setMachineWait(machine, null);
214
+ this.#line({ t: now, machine, outcome: 'expired', messages: items.length, reason });
215
+ return;
216
+ }
217
+ if ((wait.next_probe_ms ?? 0) > now) return;
218
+ }
219
+ let carriedHere = 0, offlineAnswer = null, called = false;
220
+ for (const item of items) {
221
+ if (!wait && (this.store.wakeState(item.address, item.id).next_at_ms ?? 0) > now) continue;
222
+ const found = this.store.findIn(item.address, item.id);
223
+ let carried;
224
+ // carried already (this carrier's own record): never carried twice
225
+ if (!found || (found.state === 'read' && this.store.claimOf(item.address, item.id)?.recipient === true)) carried = { kind: 'filed' };
226
+ else {
227
+ called = true;
228
+ carried = await this.#carryOne(machine, item.address, readJson(found.path));
229
+ }
230
+ if (carried.kind === 'offline') { offlineAnswer = carried.reason; break; }
231
+ carriedHere++;
232
+ this.#settle(item.folder, item.address, item.id, carried);
233
+ }
234
+ if (called) this.#line({ t: now, machine, outcome: offlineAnswer ? 'offline' : 'carried', waiting: items.length, carried: carriedHere, reason: offlineAnswer });
235
+ if (offlineAnswer) this.#setMachineWait(machine, { machine, offline_since_ms: wait?.offline_since_ms ?? now, next_probe_ms: now + LAST_RETRY_MS, probes: (wait?.probes ?? 0) + 1, last_error: offlineAnswer });
236
+ else if (called) this.#setMachineWait(machine, null);
237
+ } finally {
238
+ this.carrying.delete(machine);
239
+ this.#scheduleMachine(machine);
240
+ }
241
+ }
242
+
243
+ /** One message carried to its machine's mail door, filed there with a wake. */
244
+ async #carryOne(machine, to, envelope) {
245
+ if (!envelope) return { kind: 'filed' };
246
+ try {
247
+ const answer = await this.askMachine(machine, { op: 'file', to, envelope, wake: true });
248
+ if (answer?.code === 0) return { kind: 'delivered' };
249
+ const reason = `${machine} did not take it: ${answer?.text ?? answer?.detail ?? 'no reason given'}`;
250
+ return offline(JSON.stringify(answer)) ? { kind: 'offline', reason } : { kind: 'later', reason };
251
+ } catch (error) {
252
+ return offline(error.code ?? error.message) || offline(error.message)
253
+ ? { kind: 'offline', reason: `${machine} is offline: ${error.message}` }
254
+ : { kind: 'later', reason: `its mail door did not carry it to ${machine}: ${error.message}` };
255
+ }
256
+ }
257
+
258
+ #setMachineWait(machine, wait) {
259
+ const path = this.#machineWaitPath(machine);
260
+ if (!wait) { try { rmSync(path, { force: true }); } catch { /* gone */ } return; }
261
+ try { mkdirSync(join(this.mailRoot, 'machines'), { recursive: true }); writeFileSync(`${path}.tmp`, JSON.stringify(wait)); renameSync(`${path}.tmp`, path); }
262
+ catch (error) { this.log(`carrier: ${machine}'s wait was not kept: ${error.message}`); }
263
+ }
264
+
265
+ // ---- how an attempt went ---------------------------------------------------------------------------------------
266
+
267
+ /** Record how a wake went: done, waiting for its next attempt, or expired at its bound (mail_watch.rs `settle`). */
268
+ #settle(folder, address, id, carried) {
269
+ const wakePath = join(this.mailRoot, folder, 'wake', id);
270
+ if (carried.kind === 'delivered' || carried.kind === 'filed') {
271
+ if (carried.kind === 'delivered') {
272
+ // the carrier's own hand-off record: delivered here by its door, or carried to its machine
273
+ try { this.store.claimFor(address, id, { pid: process.pid, via: 'carrier', caller: null, recipient: true, at_ms: Date.now() }); } catch { /* best kept */ }
274
+ const found = this.store.findIn(address, id);
275
+ if (found && found.state !== 'read') { try { this.store.markRead(address, found.path); } catch { /* read already */ } }
276
+ }
277
+ try { rmSync(wakePath, { force: true }); } catch { /* gone */ }
278
+ return;
279
+ }
280
+ if (carried.kind === 'offline') return;
281
+ const now = Date.now();
282
+ const state = this.store.wakeState(address, id);
283
+ const attempts = (state.attempts ?? 0) + 1;
284
+ const since = state.failing_since_ms ?? now;
285
+ const lifetime = address.split(':')[1] === this.machine ? LOCAL_WAKE_LIFETIME_MS : REMOTE_WAKE_LIFETIME_MS;
286
+ if (attempts >= WAKE_ATTEMPTS && now - since >= lifetime) { this.#expire(folder, address, id, attempts, carried.reason); return; }
287
+ const wait = Math.min(LAST_RETRY_MS, FIRST_RETRY_MS * 2 ** Math.min(attempts - 1, 16));
288
+ this.store.setWakeState(address, id, { ...state, attempts, failing_since_ms: since, next_at_ms: now + wait, last_error: carried.reason });
289
+ }
290
+
291
+ /** End a wake undelivered: the message stays filed and unread, and the expiry is recorded in its mailbox. */
292
+ #expire(folder, address, id, attempts, reason) {
293
+ const found = this.store.findIn(address, id);
294
+ const envelope = found ? readJson(found.path) : null;
295
+ const expiry = { id, to: address, from: envelope?.from ?? '', subject: envelope?.subject ?? null, filed_at_ms: Number(envelope?.created_at_ms) || 0, expired_at_ms: Date.now(), attempts, reason };
296
+ try {
297
+ mkdirSync(join(this.mailRoot, folder, 'expired'), { recursive: true });
298
+ const temporary = join(this.mailRoot, folder, 'tmp', `expired.${id}`);
299
+ writeFileSync(temporary, JSON.stringify(expiry));
300
+ renameSync(temporary, join(this.mailRoot, folder, 'expired', `${id}.json`));
301
+ rmSync(join(this.mailRoot, folder, 'wake', id), { force: true });
302
+ } catch (error) { this.log(`carrier: the expiry of ${id} was not recorded: ${error.message}`); }
303
+ }
304
+
305
+ /** One local delivery attempt in the carrier's record (`carrier.jsonl`). */
306
+ #record({ to, id, path, carried }) {
307
+ this.#line({ t: Date.now(), to, id, path, outcome: carried.kind, reason: carried.reason ?? null });
308
+ }
309
+
310
+ #line(line) { try { appendFileSync(join(this.mailRoot, 'carrier.jsonl'), `${JSON.stringify(line)}\n`); } catch { /* the record is best kept */ } }
311
+ }
package/mail/send.mjs CHANGED
@@ -353,7 +353,9 @@ export class MailSend {
353
353
  if (typed?.delivered) { this.store.deliverRead(to, sent); delivered = 'typed'; }
354
354
  // held by the daemon's queue for the pane, typed once its composer frees: not typed yet, so kept unread
355
355
  else if (typed?.queued) { this.store.deliver(to, sent); delivered = 'pane_held'; }
356
- else { this.store.deliver(to, sent); delivered = wake ? 'pane_waits' : 'queued'; }
356
+ // not typed (its composer held a draft or a turn ran): it waits unread with a wake, so the carrier types it once the
357
+ // composer is empty (mail/carrier.mjs), never left for a read a session with no hook never makes
358
+ else { this.store.deliver(to, sent); if (wake) this.store.requestWake(to, sent.id); delivered = wake ? 'pane_waits' : 'queued'; }
357
359
  } else if (door.kind === 'stored') { this.store.deliver(to, sent); delivered = 'stored'; }
358
360
  else { this.store.deliver(to, sent); delivered = 'operator'; }
359
361
  // a hand-over to the recipient's own door is its delivery, recorded as such
package/mail/store.mjs CHANGED
@@ -68,6 +68,8 @@ export class MailStore extends EventEmitter {
68
68
  const [folder, part, file] = name.split(/[\\/]/);
69
69
  if (!folder) return;
70
70
  if (!part) return this.#readBox(folder);
71
+ // a wake written or changed: the carrier's (mail/carrier.mjs) to attempt
72
+ if (part === 'wake' && file) { this.emit('wake', { folder, id: file }); return; }
71
73
  if (!STATES[part] || !file) { if (part === 'address') this.#readBox(folder); return; }
72
74
  if (!this.boxes.has(folder)) return this.#readBox(folder);
73
75
  this.#readFile(folder, part, file);
@@ -103,6 +105,11 @@ export class MailStore extends EventEmitter {
103
105
  for (const file of files) this.#take(box, part, file);
104
106
  }
105
107
  this.emit('changed', address);
108
+ // every wake the mailbox holds, read with it: a mailbox read whole (at start, or on a rescan once a lost watch is back)
109
+ // names each one to the carrier, so a wake written while no event could say so is carried all the same
110
+ let wakes = [];
111
+ try { wakes = readdirSync(join(box.dir, 'wake')); } catch { /* none */ }
112
+ for (const id of wakes) this.emit('wake', { folder, id });
106
113
  }
107
114
 
108
115
  /** One envelope file read again on its event: held while it is there, let go once it is gone. */
@@ -2,9 +2,9 @@
2
2
  // Supercode hosts neither the agent nor its run loop; it can attach to native
3
3
  // question requests while the stock TUI remains the session's UI.
4
4
  import { spawn } from 'node:child_process';
5
- import { readFileSync, writeFileSync, renameSync, mkdtempSync, existsSync, rmSync, appendFileSync } from 'node:fs';
5
+ import { readFileSync, writeFileSync, renameSync, mkdtempSync, existsSync, rmSync, appendFileSync, mkdirSync } from 'node:fs';
6
6
  import { tmpdir } from 'node:os';
7
- import { join } from 'node:path';
7
+ import { basename, dirname, join } from 'node:path';
8
8
 
9
9
  const path = process.argv[2];
10
10
  const seed = JSON.parse(readFileSync(path, 'utf8'));
@@ -45,9 +45,11 @@ if(writableRoots.length)config.push('-c',`sandbox_workspace_write.writable_roots
45
45
  // The app-server's stderr is never the pane's: the pane is the TUI's screen, and a record the server writes there lands
46
46
  // on the TUI's composer row, which the mail wake then reads as a person's draft and never types over (yuerans,
47
47
  // 2026-10-06: `ERROR codex_models_manager::manager: failed to refresh available models` held every wake of the box
48
- // maintainer off for three hours). It is kept beside the pane's binding instead, at most LOG_BYTES of it.
48
+ // maintainer off for three hours). It is kept in a folder beside the bindings' (never among them: the carrier reads
49
+ // every file there on each poll, mail_question.rs `poll`), at most LOG_BYTES of it.
49
50
  const LOG_BYTES = 8 * 1024 * 1024;
50
- const serverLog = `${path.replace(/\.json$/, '')}.app-server.log`;
51
+ const serverLog = join(dirname(path), '..', 'native-launch-logs', `${basename(path).replace(/\.json$/, '')}.app-server.log`);
52
+ try { mkdirSync(dirname(serverLog), { recursive: true, mode: 0o700 }); } catch { /* its log is best kept */ }
51
53
  let logged = 0;
52
54
  const server = spawn(seed.program, [...config, 'app-server', '--listen', endpoint], { stdio: ['ignore', 'ignore', 'pipe'] });
53
55
  server.stderr.on('data', (chunk) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.3.104",
3
+ "version": "0.3.106",
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",