@volter/supercode-teams 0.3.150 → 0.3.152
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 +33 -1
- package/bin/teams.mjs +10 -2
- package/machine.mjs +143 -46
- package/mail/agents.mjs +40 -119
- package/mail/carrier.mjs +72 -111
- package/mail/continuation.mjs +101 -0
- package/mail/envelope.mjs +8 -10
- package/mail/hooks.mjs +43 -27
- package/mail/idle.mjs +14 -5
- package/mail/import-maildir.mjs +350 -0
- package/mail/questions.mjs +38 -46
- package/mail/reads.mjs +15 -26
- package/mail/relay-endpoint.mjs +14 -22
- package/mail/relay.mjs +100 -65
- package/mail/retire-maildir.mjs +122 -0
- package/mail/send.mjs +141 -104
- package/mail/sqlite-store.mjs +476 -0
- package/mail/sqlite.mjs +353 -0
- package/mail/transcript.mjs +3 -3
- package/mail/upkeep.mjs +46 -26
- package/mail/verbs.mjs +14 -36
- package/native-codex-pane.mjs +6 -6
- package/native-initial-input.mjs +6 -6
- package/native-launch-binding.mjs +35 -0
- package/package.json +2 -2
- package/session-people.mjs +7 -8
- package/terminal-mail.mjs +8 -19
- package/mail/store.mjs +0 -531
package/mail/send.mjs
CHANGED
|
@@ -3,13 +3,12 @@
|
|
|
3
3
|
// came through another machine's link to this machine's mail door. It resolves the receiver on the live-session map,
|
|
4
4
|
// routes to another machine through Teams or to the receiver's door here, guards against loops and repeats, and words
|
|
5
5
|
// the outcome for the sending agent, as crates/harness/src/mail_send.rs and mail_route.rs `deliver` did (their texts
|
|
6
|
-
// kept word for word: an agent reads them).
|
|
7
|
-
import { appendFileSync, chmodSync, closeSync, fsyncSync, linkSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
|
|
8
|
-
import { join } from 'node:path';
|
|
6
|
+
// kept word for word: an agent reads them). Mail is filed through the daemon's SQLite store.
|
|
9
7
|
import { blake3Hex, render, shortId } from './envelope.mjs';
|
|
10
8
|
import { AGENT_HARNESS, RESUME_WAIT_MS } from './agents.mjs';
|
|
11
9
|
import { MAX_RELAYED_BYTES, RELAY_NAME_PREFIX } from './relay.mjs';
|
|
12
10
|
import { FULL_ID_LEN, cameThroughLink, envelope as makeEnvelope, makeAddress, messageIdFor, newMessageId, normalMachineName, parseAddress, senderIdentity, subjectLine, threadOf } from './compose.mjs';
|
|
11
|
+
import { nativeMailContinuation } from './continuation.mjs';
|
|
13
12
|
|
|
14
13
|
export const EXIT_REFUSED = 2;
|
|
15
14
|
export const EXIT_UNKNOWN = 3;
|
|
@@ -43,7 +42,6 @@ const outcome = (code, text, receipt = null) => ({ code, text, receipt });
|
|
|
43
42
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
44
43
|
/** The daemon pane a live session runs in, or null (mail_route.rs `daemon_pane`). */
|
|
45
44
|
const paneOf = (session) => { const pane = typeof session.pane === 'string' ? session.pane.split(':')[0] : null; return pane && /^p_[A-Za-z0-9_-]+$/.test(pane) ? pane : null; };
|
|
46
|
-
const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
|
|
47
45
|
|
|
48
46
|
/** A delivery a door refused before anything was sent. */
|
|
49
47
|
class Refused extends Error { constructor(kind, bytes = 0) { super(kind); this.kind = kind; this.bytes = bytes; } }
|
|
@@ -56,10 +54,20 @@ export class MailSend {
|
|
|
56
54
|
* question's answer through its app-server; `typeTurn(pane, harness, text, ids)` a user's own turn typed into a daemon
|
|
57
55
|
* pane's empty composer (machine.mjs `#paneTurn`: `{ delivered }`, `{ queued }`, or neither when it waits).
|
|
58
56
|
*/
|
|
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 });
|
|
57
|
+
constructor({ machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, typeTurn, claudeProjects = () => [], log = () => {} }) {
|
|
58
|
+
Object.assign(this, { machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, typeTurn, claudeProjects, log });
|
|
61
59
|
this.handing = new Set(); // `<address>\n<id>` of each hand-over running here (handOver)
|
|
62
60
|
this.relaying = new Set(); // `<address>\n<id>` of each relay send running here (route): its wake waits for it
|
|
61
|
+
for (const name of ['askMachine', 'deliverRuntime', 'codexAnswer', 'typeTurn']) {
|
|
62
|
+
const operation = this[name];
|
|
63
|
+
this[name] = (...args) => { this.#active(); return operation(...args); };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
stop() { this.stopped = true; }
|
|
68
|
+
|
|
69
|
+
#active() {
|
|
70
|
+
if (this.stopped || this.store.database?.closed) throw Object.assign(new Error('the daemon is stopping; no mail delivery was submitted'), { code: 503, notAsked: true });
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
// ---- one send ----------------------------------------------------------------------------------------------------
|
|
@@ -70,6 +78,18 @@ export class MailSend {
|
|
|
70
78
|
* came through (`{ principal, name }`), or null for this machine's own callers. Answers `{ code, text, receipt }`.
|
|
71
79
|
*/
|
|
72
80
|
async send(caller, to, body, options = {}, context = {}) {
|
|
81
|
+
this.#active();
|
|
82
|
+
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.`);
|
|
83
|
+
// Every stream earns its current route's grants before it can join the existing same-ID send in flight.
|
|
84
|
+
let receiver = null;
|
|
85
|
+
if (!this.#remoteMachine(to)) {
|
|
86
|
+
receiver = await this.#receiver(to, this.live.sessions());
|
|
87
|
+
if (receiver.outcome && !this.live.codexKnown) { await this.#codexKnown(); receiver = await this.#receiver(to, this.live.sessions()); }
|
|
88
|
+
if (receiver.outcome) return receiver.outcome;
|
|
89
|
+
const refused = await this.#authorizeReceiver(context, receiver.continuation?.chain ?? [receiver.address]);
|
|
90
|
+
if (refused) return refused;
|
|
91
|
+
}
|
|
92
|
+
this.#active();
|
|
73
93
|
// 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
94
|
// under the same key meanwhile waits for the first and takes its outcome (sent once), never delivering it again; one
|
|
75
95
|
// after a first that was not sent sends.
|
|
@@ -78,17 +98,25 @@ export class MailSend {
|
|
|
78
98
|
const running = (this.inFlight ??= new Map()).get(key);
|
|
79
99
|
if (running) {
|
|
80
100
|
const first = await running.catch(() => null);
|
|
81
|
-
if (first?.code === 0)
|
|
101
|
+
if (first?.code === 0) {
|
|
102
|
+
const addresses = [...(first.continuation?.chain ?? []), ...(first.receipt?.recipients ?? []), first.receipt?.recipient].filter(Boolean);
|
|
103
|
+
if (addresses.length) {
|
|
104
|
+
const refused = await this.#authorizeReceiver(context, addresses);
|
|
105
|
+
if (refused) return refused;
|
|
106
|
+
} 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.');
|
|
107
|
+
return { ...outcome(0, `Already sent with --id ${options.id}: ${first.text} Nothing was sent again.`, first.receipt ?? null), ...(first.continuation ? { continuation: first.continuation } : {}) };
|
|
108
|
+
}
|
|
82
109
|
return this.send(caller, to, body, options, context);
|
|
83
110
|
}
|
|
84
111
|
}
|
|
85
|
-
const sending = this.#send(caller, to, body, options, context);
|
|
112
|
+
const sending = this.#send(caller, to, body, options, context, receiver);
|
|
86
113
|
if (!key) return sending;
|
|
87
114
|
this.inFlight.set(key, sending);
|
|
88
115
|
try { return await sending; } finally { if (this.inFlight.get(key) === sending) this.inFlight.delete(key); }
|
|
89
116
|
}
|
|
90
117
|
|
|
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 } = {},
|
|
118
|
+
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) {
|
|
119
|
+
const { link = null } = context;
|
|
92
120
|
// an id its client chose (so a lost answer can name it), never an idempotency key: it marks nothing sent
|
|
93
121
|
const chosen = typeof message_id === 'string' && /^m-[0-9a-f]{24}$/.test(message_id) ? message_id : null;
|
|
94
122
|
const machine = this.#remoteMachine(to);
|
|
@@ -103,10 +131,26 @@ export class MailSend {
|
|
|
103
131
|
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
132
|
const sent = await this.#remoteSend(machine, request);
|
|
105
133
|
// 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);
|
|
134
|
+
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
135
|
return sent;
|
|
108
136
|
}
|
|
109
|
-
|
|
137
|
+
const answer = await this.#deliver(caller, receiver, body, { subject, in_reply_to, notify_when_idle, queue, id, notice, thread, chosen }, link, context);
|
|
138
|
+
return this.#annotateReceiver(answer, receiver);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#annotateReceiver(answer, receiver) {
|
|
142
|
+
const continuation = receiver.continuation;
|
|
143
|
+
const alias = continuation && !answer.text.includes(receiver.alias) ? receiver.alias : '';
|
|
144
|
+
return { ...answer, text: answer.text + alias, ...(continuation ? { continuation } : {}),
|
|
145
|
+
receipt: answer.receipt ? { ...answer.receipt, recipient: receiver.address,
|
|
146
|
+
recipients: answer.receipt.recipients ?? [receiver.address], ...(continuation ? { continuation } : {}) } : null };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async #authorizeReceiver(context, addresses) {
|
|
150
|
+
if (!context.authorizeRecipients) return null;
|
|
151
|
+
const reason = await context.authorizeRecipients(addresses);
|
|
152
|
+
this.#active();
|
|
153
|
+
return reason ? outcome(EXIT_REFUSED, `Not sent: ${reason}. Nothing was filed or delivered by this request.`) : null;
|
|
110
154
|
}
|
|
111
155
|
|
|
112
156
|
/** The machine `to` names when it is not this one, in its normal form. */
|
|
@@ -123,7 +167,7 @@ export class MailSend {
|
|
|
123
167
|
const answer = await this.askMachine(machine, request);
|
|
124
168
|
const code = Number.isInteger(answer?.code) ? answer.code : EXIT_FAILED;
|
|
125
169
|
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);
|
|
170
|
+
return { ...outcome(code, text, answer?.receipt ?? null), ...(answer?.continuation ? { continuation: answer.continuation } : {}) };
|
|
127
171
|
} catch (error) {
|
|
128
172
|
const detail = String(error?.message ?? error);
|
|
129
173
|
// the door took it and its answer was lost: unanswered, never refused or failed (what it did there is unknown)
|
|
@@ -140,14 +184,14 @@ export class MailSend {
|
|
|
140
184
|
#answeredId(callerAddress, answered) {
|
|
141
185
|
if (typeof answered !== 'string' || answered.length >= FULL_ID_LEN) return { id: answered ?? null };
|
|
142
186
|
const found = this.store.findPrefixIn(callerAddress, answered).map((item) => item.envelope.id);
|
|
143
|
-
for (const id of this
|
|
187
|
+
for (const id of this.store.sentIds(callerAddress, answered)) if (!found.includes(id)) found.push(id);
|
|
144
188
|
if (found.length === 1) return { id: found[0] };
|
|
145
189
|
if (!found.length) return { refused: outcome(EXIT_REFUSED, `Not sent: no message you received or sent has an id starting ${answered}. Name the message you answer as its envelope or your send showed it.`) };
|
|
146
190
|
return { refused: outcome(EXIT_REFUSED, `Not sent: ${answered} names ${found.length} messages you received or sent (${found.join(', ')}). Give more of its id.`) };
|
|
147
191
|
}
|
|
148
192
|
|
|
149
193
|
/** The receiver `to` names here: `{ address, name, session (its live row, when it runs) }`, or an outcome. */
|
|
150
|
-
#receiver(to, sessions) {
|
|
194
|
+
async #receiver(to, sessions) {
|
|
151
195
|
const operator = String(to).startsWith('operator:') ? makeAddress(this.machine, 'operator', String(to).slice('operator:'.length)) : null;
|
|
152
196
|
const parsed = parseAddress(operator ?? to);
|
|
153
197
|
// an operator (a board, a script) is not a listed session: its address is taken as given
|
|
@@ -158,7 +202,9 @@ export class MailSend {
|
|
|
158
202
|
return { address: parsed.address, name: `agent ${parsed.session}@${parsed.machine}` };
|
|
159
203
|
}
|
|
160
204
|
if (parsed) {
|
|
161
|
-
const
|
|
205
|
+
const resolved = await this.#nativeReceiver(parsed.address, sessions);
|
|
206
|
+
if (resolved) return resolved;
|
|
207
|
+
const session = this.live.sessions().find((row) => row.address === parsed.address);
|
|
162
208
|
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
209
|
return { address: session.address, name: session.name, session };
|
|
164
210
|
}
|
|
@@ -172,7 +218,9 @@ export class MailSend {
|
|
|
172
218
|
if (!matching.length) {
|
|
173
219
|
const remembered = this.#rememberedAddress(wanted);
|
|
174
220
|
if (remembered) {
|
|
175
|
-
const
|
|
221
|
+
const resolved = await this.#nativeReceiver(remembered, sessions);
|
|
222
|
+
if (resolved) return resolved;
|
|
223
|
+
const session = this.live.sessions().find((row) => row.address === remembered);
|
|
176
224
|
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
225
|
return { address: session.address, name: session.name, session, alias: ` "${wanted}" is a name it ran under before; it runs as ${session.name} now.` };
|
|
178
226
|
}
|
|
@@ -190,6 +238,18 @@ export class MailSend {
|
|
|
190
238
|
return { outcome: outcome(EXIT_UNKNOWN, this.#notYetKnown(wanted) ?? `No session named "${wanted}" is reachable.${hint} Run supercode message list. Nothing was sent.`) };
|
|
191
239
|
}
|
|
192
240
|
|
|
241
|
+
async #nativeReceiver(address, sessions) {
|
|
242
|
+
const live = sessions.find((row) => row.address === address);
|
|
243
|
+
if (live) return { address: live.address, name: live.name, session: live };
|
|
244
|
+
const continued = await nativeMailContinuation(address, { machine: this.machine, claudeProjects: this.claudeProjects, sessions: () => this.live.sessions() });
|
|
245
|
+
if (continued?.error) return { outcome: outcome(EXIT_UNKNOWN, `Not sent: ${address}'s continuation is unresolved: ${continued.error}. Nothing was sent.`) };
|
|
246
|
+
if (!continued) return null;
|
|
247
|
+
const { session, addressed, chain } = continued;
|
|
248
|
+
return { address: session.address, name: session.name, session,
|
|
249
|
+
continuation: { addressed, recipient: session.address, chain },
|
|
250
|
+
alias: ` Addressed to ${address}; its native continued-in record routes it to ${session.address}.` };
|
|
251
|
+
}
|
|
252
|
+
|
|
193
253
|
/** In a daemon's first moments the Codex sessions are not known yet (its session index and their activity still being
|
|
194
254
|
* read): a send waits for them, at most CODEX_KNOWN_WAIT_MS. */
|
|
195
255
|
async #codexKnown() { if (!this.live.codexKnown) await this.live.whenCodexKnown?.(CODEX_KNOWN_WAIT_MS); }
|
|
@@ -219,19 +279,14 @@ export class MailSend {
|
|
|
219
279
|
}
|
|
220
280
|
|
|
221
281
|
/** Deliver from `caller` to `to` on this machine (mail_send.rs `deliver`). */
|
|
222
|
-
async #deliver(caller,
|
|
223
|
-
|
|
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;
|
|
282
|
+
async #deliver(caller, receiver, body, { subject, in_reply_to, notify_when_idle, queue, id, notice, thread: carried, chosen }, link, context) {
|
|
283
|
+
const sessions = this.live.sessions();
|
|
228
284
|
const { address, name } = receiver;
|
|
229
285
|
if (address === caller.address) return outcome(EXIT_REFUSED, 'Not sent: that address is your own session.');
|
|
230
286
|
const door = parseAddress(address).harness === AGENT_HARNESS ? null : this.#door(address, sessions);
|
|
231
287
|
const messageId = id == null && chosen ? chosen : messageIdFor(caller.address, id);
|
|
232
|
-
const marker = this.#sentMarker(caller.address, messageId);
|
|
233
288
|
const terminal = door?.kind === 'hook' && door.pane != null;
|
|
234
|
-
|
|
289
|
+
const previous = this.store.sentMarker(caller.address, messageId);
|
|
235
290
|
if (previous !== null) {
|
|
236
291
|
if (terminal && !queue) this.store.requestWake(address, messageId);
|
|
237
292
|
return outcome(0, `Already sent with --id ${id ?? ''}: ${previous} Nothing was sent again.`, this.#deliveryReceipt(address, messageId, terminal));
|
|
@@ -250,7 +305,14 @@ export class MailSend {
|
|
|
250
305
|
});
|
|
251
306
|
const plan = await this.#plan(sent, address, {});
|
|
252
307
|
if (plan.outcome) return plan.outcome;
|
|
253
|
-
|
|
308
|
+
const recipients = plan.plan ? [...new Set([address, ...(plan.plan.agent ? [plan.plan.agent] : []),
|
|
309
|
+
...plan.plan.recipients.filter(item => item.address !== caller.address).map(item => item.address)])] : [address];
|
|
310
|
+
const refused = await this.#authorizeReceiver(context, [...(receiver.continuation?.chain ?? []), ...recipients]);
|
|
311
|
+
if (refused) return refused;
|
|
312
|
+
if (plan.plan) {
|
|
313
|
+
const answer = await this.#deliverPlanned(caller, sent, plan.plan, { queue, notify_when_idle, continuation: receiver.continuation });
|
|
314
|
+
return { ...answer, receipt: answer.receipt ? { ...answer.receipt, recipients } : null };
|
|
315
|
+
}
|
|
254
316
|
// the thread this message is in, kept in the send log: a later reply to it continues that thread
|
|
255
317
|
const sentIn = threadOf(sent);
|
|
256
318
|
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 +325,7 @@ export class MailSend {
|
|
|
263
325
|
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
326
|
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
327
|
// 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)); }
|
|
328
|
+
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
329
|
return outcome(EXIT_FAILED, `Not sent to ${name}: ${error.message}`);
|
|
268
330
|
}
|
|
269
331
|
const [code, what] = {
|
|
@@ -281,12 +343,12 @@ export class MailSend {
|
|
|
281
343
|
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
344
|
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
345
|
}[delivered];
|
|
284
|
-
this.#recordSend(caller.address, address, messageId, sentIn);
|
|
346
|
+
this.#recordSend(caller.address, address, messageId, sentIn, receiver.continuation);
|
|
285
347
|
const harness = parseAddress(address).harness;
|
|
286
348
|
const text = code === 0
|
|
287
349
|
? `sent to ${name} (${harness}, ${door.kind}): ${what}. Message id ${shortId(messageId)}.${receiver.alias ?? ''}${idleNote} Don't poll; carry on.`
|
|
288
350
|
: `${name} (${harness}): ${what}. Message id ${shortId(messageId)}.${idleNote}`;
|
|
289
|
-
if (code === 0 && id != null) { try {
|
|
351
|
+
if (code === 0 && id != null) { try { this.store.setSentMarker(caller.address, messageId, text); } catch { /* the marker is best kept */ } }
|
|
290
352
|
return outcome(code, text, code === 0 ? this.#deliveryReceipt(address, messageId, terminal) : null);
|
|
291
353
|
}
|
|
292
354
|
|
|
@@ -305,6 +367,7 @@ export class MailSend {
|
|
|
305
367
|
* refused before anything was sent, and an Error with what failed.
|
|
306
368
|
*/
|
|
307
369
|
async route(sent, to, door, wake, notifyWhenIdle) {
|
|
370
|
+
this.#active();
|
|
308
371
|
this.store.open(to);
|
|
309
372
|
let finalReply = false;
|
|
310
373
|
let delivered;
|
|
@@ -368,7 +431,7 @@ export class MailSend {
|
|
|
368
431
|
if (filedHere && wake) { try { this.store.requestWake(to, handed.id); } catch { /* the wake filed above stands */ } }
|
|
369
432
|
throw Object.assign(new Error(relayed.detail), { unanswered: true });
|
|
370
433
|
}
|
|
371
|
-
if (!relayed.delivered && !received) { if (filedHere) { try {
|
|
434
|
+
if (!relayed.delivered && !received) { if (filedHere) { try { this.store.remove(to, filed); } catch { /* gone */ } } throw new Error(relayed.detail); }
|
|
372
435
|
// relayed: a copy filed unread before this delivery (an earlier unanswered or queued one, held for the receiver's
|
|
373
436
|
// own read) is moved to cur/, so that read never hands it over a second time
|
|
374
437
|
if (relayed.delivered && !received) {
|
|
@@ -408,7 +471,7 @@ export class MailSend {
|
|
|
408
471
|
*/
|
|
409
472
|
async deliverPlanned(caller, sent, plan, { queue = false, notify_when_idle = false } = {}) { return this.#deliverPlanned(caller, sent, plan, { queue, notify_when_idle }); }
|
|
410
473
|
|
|
411
|
-
async #deliverPlanned(caller, sent, plan, { queue, notify_when_idle }) {
|
|
474
|
+
async #deliverPlanned(caller, sent, plan, { queue, notify_when_idle, continuation = null }) {
|
|
412
475
|
const reached = [], copied = [], failed = [];
|
|
413
476
|
let recorded = false;
|
|
414
477
|
const held = []; // recipients whose own copy is held unread with its wake (an unanswered relay)
|
|
@@ -428,7 +491,7 @@ export class MailSend {
|
|
|
428
491
|
const sessions = this.live.sessions();
|
|
429
492
|
for (const recipient of plan.recipients.filter((item) => item.address !== caller.address)) {
|
|
430
493
|
const label = this.#label(sessions, recipient.address);
|
|
431
|
-
this.#recordSend(caller.address, recipient.address, sent.id, threadOf(sent));
|
|
494
|
+
this.#recordSend(caller.address, recipient.address, sent.id, threadOf(sent), continuation);
|
|
432
495
|
if (!recipient.wake) {
|
|
433
496
|
try { this.#fileUnread(recipient.address, sent); copied.push(label); } catch (error) { failed.push(`${label} (${error.message})`); }
|
|
434
497
|
continue;
|
|
@@ -512,6 +575,7 @@ export class MailSend {
|
|
|
512
575
|
try { await this.agents.resume(to); } catch (error) { throw new Error(`not resumed: ${error.message}`); }
|
|
513
576
|
const started = Date.now();
|
|
514
577
|
for (;;) {
|
|
578
|
+
this.#active();
|
|
515
579
|
door = this.#door(to, this.live.sessions());
|
|
516
580
|
if (door) break;
|
|
517
581
|
if (Date.now() - started > RESUME_WAIT_MS) return { how: 'not_running', text: 'resumed; the message waits in its mailbox' };
|
|
@@ -597,7 +661,7 @@ export class MailSend {
|
|
|
597
661
|
if (typeof key !== 'string' || !key || Buffer.byteLength(key) > 256) return refused('invalid_key', 'idempotency_key needs 1–256 bytes');
|
|
598
662
|
id = `m-${blake3Hex(JSON.stringify([sender, receiver, key]))}`.slice(0, 26);
|
|
599
663
|
const previous = this.store.findIn(receiver, id);
|
|
600
|
-
const saved = previous ?
|
|
664
|
+
const saved = previous ? this.store.body(previous) : null;
|
|
601
665
|
if (saved && (saved.body !== text || (saved.subject ?? null) !== subject || saved.kind !== (params.channel ? 'channel' : 'peer') || saved.from_name !== (params.sender_name ?? `${fromName}@${this.machine}`) || (saved.in_reply_to ?? null) !== (params.in_reply_to ?? null) || (saved.voice_for ?? null) !== (params.voice_for ?? null))) {
|
|
602
666
|
return refused('idempotency_conflict', 'this key already names different mail');
|
|
603
667
|
}
|
|
@@ -732,9 +796,11 @@ export class MailSend {
|
|
|
732
796
|
* (mail_route.rs `type_user_turns`).
|
|
733
797
|
*/
|
|
734
798
|
async typeUserTurns(address, pane) {
|
|
799
|
+
this.#active();
|
|
735
800
|
const typed = [];
|
|
736
801
|
const harness = parseAddress(address).harness;
|
|
737
802
|
for (const waiting of this.store.userTurns(address)) {
|
|
803
|
+
this.#active();
|
|
738
804
|
const claimed = this.store.claimUserTurn(address, waiting);
|
|
739
805
|
if (!claimed) break;
|
|
740
806
|
let result;
|
|
@@ -764,9 +830,10 @@ export class MailSend {
|
|
|
764
830
|
* `{ outcome: 'delivered' | 'filed' | 'later', detail }`.
|
|
765
831
|
*/
|
|
766
832
|
async handOver(address, id, { wake = false } = {}) {
|
|
833
|
+
this.#active();
|
|
767
834
|
const found = this.store.findIn(address, id);
|
|
768
835
|
if (!found) return { outcome: 'filed', detail: 'not in its mailbox' };
|
|
769
|
-
const sent =
|
|
836
|
+
const sent = this.store.body(found);
|
|
770
837
|
if (!sent) return { outcome: 'later', detail: 'its envelope could not be read' };
|
|
771
838
|
if (found.state !== 'unread') return { outcome: 'filed', detail: 'already read' };
|
|
772
839
|
// one already handed to its receiver's door (that delivery's claim, the door's or the carrier's: a daemon that ended
|
|
@@ -815,8 +882,10 @@ export class MailSend {
|
|
|
815
882
|
return error instanceof Refused ? { outcome: 'filed', detail: error.kind } : { outcome: 'later', detail: error.message };
|
|
816
883
|
} finally {
|
|
817
884
|
this.handing.delete(key);
|
|
818
|
-
|
|
819
|
-
|
|
885
|
+
if (!this.stopped) {
|
|
886
|
+
const after = this.store.wakeState(address, id);
|
|
887
|
+
this.store.setWakeState(address, id, { ...after, handover_at_ms: null });
|
|
888
|
+
}
|
|
820
889
|
}
|
|
821
890
|
}
|
|
822
891
|
|
|
@@ -824,13 +893,13 @@ export class MailSend {
|
|
|
824
893
|
|
|
825
894
|
/** Answer the native question `id` asked in `to` (mail_question.rs `reply`): only its recorded creator answers, once. */
|
|
826
895
|
async #questionReply(caller, to, id, body, link) {
|
|
827
|
-
const
|
|
828
|
-
const
|
|
829
|
-
const question = readJson(path('json'));
|
|
896
|
+
const key = (suffix) => `${id}.${suffix}`;
|
|
897
|
+
const question = this.store.record('native_questions', key('json'));
|
|
830
898
|
if (!question) return outcome(EXIT_FAILED, `Not answered: question ${id} could not be read.`);
|
|
831
899
|
if (question.binding?.session !== to || question.binding?.creator !== caller.address) return outcome(EXIT_REFUSED, 'Not answered: this question belongs to a different session or recipient.');
|
|
900
|
+
if (this.store.record('native_questions', key('claim'))) return outcome(EXIT_REFUSED, 'An answer is already recorded for this question. Nothing was submitted again; inspect its thread for delivery.');
|
|
832
901
|
if (parseAddress(to).harness === 'claude-code') {
|
|
833
|
-
const waiting =
|
|
902
|
+
const waiting = this.store.record('native_questions', key('waiting'));
|
|
834
903
|
if (!waiting) return outcome(EXIT_REFUSED, 'Not answered: the native question hook is no longer waiting.');
|
|
835
904
|
let alive = false; try { process.kill(waiting.pid, 0); alive = true; } catch (error) { alive = error.code === 'EPERM'; }
|
|
836
905
|
if (!Number.isInteger(waiting.pid) || !alive) return outcome(EXIT_REFUSED, 'Not answered: the native question hook has ended.');
|
|
@@ -841,24 +910,25 @@ export class MailSend {
|
|
|
841
910
|
envelope: makeEnvelope({ id: `u-${blake3Hex(`${id}\n${caller.address}`).slice(0, 24)}`, from: caller.address, from_name: caller.name, sender_identity: senderIdentity(caller.address, link, this.machine), kind: 'user', reply_via: { mode: 'none' }, in_reply_to: id, thread: id, body }),
|
|
842
911
|
answers,
|
|
843
912
|
};
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
linkSync(path('claim'), path('reply'));
|
|
913
|
+
const claimed = this.store.transaction(() => {
|
|
914
|
+
if (!this.store.putRecord('native_questions', key('claim'), reply, { create: true })) return false;
|
|
915
|
+
this.store.deliver(to, reply.envelope);
|
|
916
|
+
this.store.putRecord('native_questions', key('reply'), reply, { create: true });
|
|
917
|
+
return true;
|
|
918
|
+
});
|
|
919
|
+
if (!claimed) return outcome(EXIT_REFUSED, 'An answer is already recorded for this question. Nothing was submitted again; inspect its thread for delivery.');
|
|
852
920
|
if (parseAddress(to).harness === 'claude-code') return outcome(0, `Answer ${shortId(reply.envelope.id)} recorded in reply to ${id}; awaiting confirmation from the native question hook. It is not composer input.`);
|
|
853
|
-
const binding =
|
|
921
|
+
const binding = this.store.record('native_questions', `binding-${blake3Hex(to)}.json`);
|
|
854
922
|
if (!binding?.socket_path) return outcome(EXIT_STORED, 'Answer recorded but NOT delivered: this launch has no native question endpoint. Nothing was typed.');
|
|
855
923
|
let result;
|
|
856
924
|
try { result = await this.codexAnswer({ socketPath: binding.socket_path, threadId: parseAddress(to).session, answer: { question: question.native, answers: reply.answers } }); }
|
|
857
925
|
catch (error) { result = { delivered: false, reason: error.code ?? 'native-question-error' }; }
|
|
858
926
|
if (result?.delivered === true) {
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
927
|
+
this.store.transaction(() => {
|
|
928
|
+
const filed = this.store.findIn(to, reply.envelope.id);
|
|
929
|
+
if (filed?.state === 'unread') this.store.markRead(to, filed.path);
|
|
930
|
+
this.store.putRecord('native_questions', key('delivered'), Date.now(), { create: true });
|
|
931
|
+
});
|
|
862
932
|
return outcome(0, `Answer ${shortId(reply.envelope.id)} delivered to native question ${id}.`);
|
|
863
933
|
}
|
|
864
934
|
return outcome(EXIT_STORED, `Answer recorded but NOT confirmed by the native question: ${JSON.stringify(result?.reason ?? null)}. It will not be typed or automatically resent.`);
|
|
@@ -866,29 +936,10 @@ export class MailSend {
|
|
|
866
936
|
|
|
867
937
|
// ---- the send log and its guards ---------------------------------------------------------------------------------
|
|
868
938
|
|
|
869
|
-
#
|
|
870
|
-
#sentMarker(sender, id) { return join(this.#sentDir(sender), id); }
|
|
871
|
-
|
|
872
|
-
#logOf(sender) {
|
|
873
|
-
let text = '';
|
|
874
|
-
try { text = readFileSync(join(this.#sentDir(sender), 'log.jsonl'), 'utf8'); } catch { return []; }
|
|
875
|
-
return text.split('\n').map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
#sentIds(sender) { return this.#logOf(sender).map((entry) => entry.id).filter((id) => typeof id === 'string'); }
|
|
879
|
-
#sentThread(sender, id) { return this.#logOf(sender).find((entry) => entry.id === id && typeof entry.thread === 'string')?.thread ?? null; }
|
|
939
|
+
#sentThread(sender, id) { return this.store.sentThread(sender, id); }
|
|
880
940
|
|
|
881
941
|
/** The thread any author on this machine sent `id` in, as its send log keeps it. */
|
|
882
|
-
#sentThreadHere(id) {
|
|
883
|
-
let names = [];
|
|
884
|
-
try { names = readdirSync(join(this.mailRoot, 'sent')); } catch { return null; }
|
|
885
|
-
for (const name of names) {
|
|
886
|
-
let text = '';
|
|
887
|
-
try { text = readFileSync(join(this.mailRoot, 'sent', name, 'log.jsonl'), 'utf8'); } catch { continue; }
|
|
888
|
-
for (const line of text.split('\n')) { let entry = null; try { entry = JSON.parse(line); } catch { continue; } if (entry?.id === id && typeof entry.thread === 'string') return entry.thread; }
|
|
889
|
-
}
|
|
890
|
-
return null;
|
|
891
|
-
}
|
|
942
|
+
#sentThreadHere(id) { return this.store.sentThreadHere(id); }
|
|
892
943
|
|
|
893
944
|
/** The thread `parent` is filed in on this machine (mailbox.rs `filed_thread`). */
|
|
894
945
|
#filedThread(parent) {
|
|
@@ -906,19 +957,19 @@ export class MailSend {
|
|
|
906
957
|
return parent;
|
|
907
958
|
}
|
|
908
959
|
|
|
909
|
-
/**
|
|
960
|
+
/** The bounded reply chain between `a` and `b`, from exact message keys. */
|
|
910
961
|
#replyChainDepth(a, b, answered) {
|
|
911
|
-
const links = new Map([...this.store.links(a), ...this.store.links(b)]);
|
|
912
962
|
let depth = 0, current = answered;
|
|
913
|
-
while (current != null && depth <= REPLY_CHAIN_LIMIT
|
|
963
|
+
while (current != null && depth <= REPLY_CHAIN_LIMIT) {
|
|
964
|
+
const parent = this.store.replyParent(a, b, current);
|
|
965
|
+
if (parent === undefined) break;
|
|
966
|
+
depth += 1; current = parent;
|
|
967
|
+
}
|
|
914
968
|
return depth;
|
|
915
969
|
}
|
|
916
970
|
|
|
917
|
-
#recordSend(sender, to, id, thread) {
|
|
918
|
-
try {
|
|
919
|
-
mkdirSync(this.#sentDir(sender), { recursive: true });
|
|
920
|
-
appendFileSync(join(this.#sentDir(sender), 'log.jsonl'), `${JSON.stringify({ to, at_ms: Date.now(), id, thread })}\n`);
|
|
921
|
-
} catch { /* the log is best kept */ }
|
|
971
|
+
#recordSend(sender, to, id, thread, continuation = null) {
|
|
972
|
+
try { this.store.recordSend(sender, to, id, thread, continuation); } catch { /* the log is best kept */ }
|
|
922
973
|
}
|
|
923
974
|
|
|
924
975
|
/** A terminal's acknowledgement from the native watch's retained receipts, never from a filing (mail_send.rs
|
|
@@ -927,11 +978,8 @@ export class MailSend {
|
|
|
927
978
|
if (!terminal) return { delivered: true, message_ids: [id], receipt: 'native-mail' };
|
|
928
979
|
const parsed = parseAddress(address);
|
|
929
980
|
const recipient = `${parsed.harness}:${parsed.session}`;
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
for (const name of names) {
|
|
933
|
-
const state = readJson(join(this.mailRoot, 'terminal-delivery', name, 'queue.json'));
|
|
934
|
-
if (state?.recipient !== recipient) continue;
|
|
981
|
+
const state = this.store.terminalState(recipient);
|
|
982
|
+
if (state) {
|
|
935
983
|
const receipt = (Array.isArray(state.receipts) ? state.receipts : []).find((item) => Array.isArray(item?.message_ids) && item.message_ids.includes(id));
|
|
936
984
|
if (receipt) return receipt;
|
|
937
985
|
return { delivered: false, queued: true, message_ids: [id], reason: state.active?.error ?? null };
|
|
@@ -950,7 +998,7 @@ export class MailSend {
|
|
|
950
998
|
|
|
951
999
|
/** rememberedName for each of `addresses`, the ledger read once. */
|
|
952
1000
|
rememberedNames(addresses) {
|
|
953
|
-
const names =
|
|
1001
|
+
const names = Object.fromEntries(this.store.records('names').map(({ key, value }) => [key, value]));
|
|
954
1002
|
const wanted = new Set(addresses), best = new Map();
|
|
955
1003
|
if (names && typeof names === 'object') {
|
|
956
1004
|
for (const [name, record] of Object.entries(names)) {
|
|
@@ -963,7 +1011,7 @@ export class MailSend {
|
|
|
963
1011
|
}
|
|
964
1012
|
|
|
965
1013
|
#rememberedAddress(name) {
|
|
966
|
-
const record =
|
|
1014
|
+
const record = this.store.record('names', name);
|
|
967
1015
|
if (!record || Date.now() - Math.max(record.seen_ms ?? 0, record.since_ms ?? 0) > NAME_KEPT_MS) return null;
|
|
968
1016
|
return parseAddress(record.address)?.address ?? null;
|
|
969
1017
|
}
|
|
@@ -971,10 +1019,7 @@ export class MailSend {
|
|
|
971
1019
|
/** Each running session's name recorded (mail_route.rs `remember_names`): a name two sessions carry is left as it was;
|
|
972
1020
|
* written only when a name is new, names another session, or its last listing is an hour old. */
|
|
973
1021
|
rememberNames(sessions) {
|
|
974
|
-
const
|
|
975
|
-
let names;
|
|
976
|
-
try { names = JSON.parse(readFileSync(path, 'utf8')); } catch (error) { if (error.code !== 'ENOENT') return; names = {}; }
|
|
977
|
-
if (!names || typeof names !== 'object') return;
|
|
1022
|
+
const names = Object.fromEntries(this.store.records('names').map(({ key, value }) => [key, value]));
|
|
978
1023
|
const carried = new Map();
|
|
979
1024
|
for (const row of sessions) {
|
|
980
1025
|
const name = row.name.split('@')[0];
|
|
@@ -982,7 +1027,7 @@ export class MailSend {
|
|
|
982
1027
|
}
|
|
983
1028
|
const now = Date.now();
|
|
984
1029
|
const latest = (address) => Object.entries(names).filter(([, record]) => record.address === address).sort((x, y) => y[1].since_ms - x[1].since_ms)[0]?.[0] ?? null;
|
|
985
|
-
|
|
1030
|
+
const changed = [];
|
|
986
1031
|
for (const [name, addresses] of carried) {
|
|
987
1032
|
if (addresses.length !== 1) continue;
|
|
988
1033
|
const [address] = addresses;
|
|
@@ -991,11 +1036,11 @@ export class MailSend {
|
|
|
991
1036
|
if (current && latest(address) === name) continue;
|
|
992
1037
|
const renewed = record?.address === address && latest(address) === name;
|
|
993
1038
|
names[name] = { address, since_ms: renewed ? record.since_ms : now, seen_ms: now };
|
|
994
|
-
changed
|
|
1039
|
+
changed.push(name);
|
|
995
1040
|
}
|
|
996
|
-
if (!changed) return;
|
|
997
|
-
const
|
|
998
|
-
|
|
1041
|
+
if (!changed.length) return;
|
|
1042
|
+
try { this.store.transaction(() => { for (const name of changed) this.store.putRecord('names', name, names[name]); }); }
|
|
1043
|
+
catch { /* kept for the next listing */ }
|
|
999
1044
|
}
|
|
1000
1045
|
}
|
|
1001
1046
|
|
|
@@ -1017,11 +1062,3 @@ function questionAnswers(question, body) {
|
|
|
1017
1062
|
if (!values || typeof values !== 'object' || Object.keys(values).length !== Object.keys(out).length) throw new Error('answer only the question ids shown');
|
|
1018
1063
|
return out;
|
|
1019
1064
|
}
|
|
1020
|
-
|
|
1021
|
-
/** A file made once, owner-only, written whole and synced (mail_question.rs `create`). */
|
|
1022
|
-
function createPrivate(dir, path, value) {
|
|
1023
|
-
mkdirSync(dir, { recursive: true });
|
|
1024
|
-
try { chmodSync(dir, 0o700); } catch { /* not ours to set */ }
|
|
1025
|
-
const fd = openSync(path, 'wx', 0o600);
|
|
1026
|
-
try { writeSync(fd, JSON.stringify(value)); fsyncSync(fd); } finally { closeSync(fd); }
|
|
1027
|
-
}
|