@volter/supercode-teams 0.3.85 → 0.3.87
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/bin/workspace/calendar.mjs +13 -1
- package/bin/workspace.mjs +5 -0
- package/connector/index.mjs +20 -2
- package/execution.mjs +3 -1
- package/live-sessions.mjs +56 -4
- package/machine-events.mjs +7 -9
- package/machine.mjs +269 -64
- package/mail/agents.mjs +386 -0
- package/mail/compose.mjs +97 -0
- package/mail/envelope.mjs +155 -0
- package/mail/reads.mjs +389 -0
- package/mail/relay.mjs +233 -0
- package/mail/runtime.mjs +20 -0
- package/mail/send.mjs +723 -0
- package/mail/store.mjs +419 -0
- package/mail/transcript.mjs +287 -0
- package/mail/verbs.mjs +232 -0
- package/mail-history.mjs +1 -1
- package/package.json +2 -1
package/mail/reads.mjs
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
// The mail reads the daemon answers (docs/architecture/overview.md, "The mail operations", 3a): `message list`, `show`,
|
|
2
|
+
// `inbox` and the machine record's mail, from the live-session map, the header index and the records of the team's
|
|
3
|
+
// other machines the daemon holds. One local call, no process. Ported from crates/cli/src/message.rs (session_rows,
|
|
4
|
+
// team_rows, list, show_rows, show, show_replies, inbox, inbox_rows): the same rows, the same text.
|
|
5
|
+
import { statSync, openSync, readSync, closeSync } from 'node:fs';
|
|
6
|
+
import { listed, messageRow, questionDeliveredAt } from './envelope.mjs';
|
|
7
|
+
import { deliveries, readTranscript, transcriptPath } from './transcript.mjs';
|
|
8
|
+
|
|
9
|
+
export const EXIT_REFUSED = 2;
|
|
10
|
+
export const EXIT_UNKNOWN = 3;
|
|
11
|
+
export const EXIT_FAILED = 5;
|
|
12
|
+
/** A whole message id: its kind, a dash and 24 hex digits. */
|
|
13
|
+
const FULL_ID_LEN = 26;
|
|
14
|
+
/** How many receivers' transcripts one listing reads for the state of the messages it sent. */
|
|
15
|
+
const MAX_RECEIVERS_READ = 6;
|
|
16
|
+
/** A message held in a record's mail window: each mailbox's unread mail and its newest RECENT_PER_MAILBOX. */
|
|
17
|
+
export const RECENT_PER_MAILBOX = 20;
|
|
18
|
+
|
|
19
|
+
const isDaemonPane = (name) => /^p_[A-Za-z0-9_-]+$/.test(name);
|
|
20
|
+
/** The parts of a machine's record its session list comes from: one not read leaves the list not current. */
|
|
21
|
+
const SESSION_SOURCES = ['sessions', 'sessions_index', 'sessions_index_parts', 'claude_registry_watch'];
|
|
22
|
+
const at = (ms) => (Number.isFinite(ms) ? new Date(ms).toISOString() : 'never');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The records of the other machines this daemon follows, each with how current it is (mailbox.rs `held_records`): a
|
|
26
|
+
* machine that is offline is left out (never a reading that is incomplete); a record whose stream is not open is stale,
|
|
27
|
+
* with why; a machine never read is unread, with why; and `*` when the daemon follows no machine at all.
|
|
28
|
+
*/
|
|
29
|
+
export function heldRecords({ records, unread, self }) {
|
|
30
|
+
const held = Object.entries(records ?? {}).filter(([machine, entry]) => machine !== self && entry?.stream !== 'unlinked').map(([machine, entry]) => {
|
|
31
|
+
const stream = entry?.stream ?? 'unknown';
|
|
32
|
+
const why = entry?.why ?? 'no reason given';
|
|
33
|
+
const record = entry?.record && typeof entry.record === 'object' ? entry.record : null;
|
|
34
|
+
const stale = stream !== 'open' && record ? (stream === 'too-old' ? `its record is held from ${at(entry.received_at)} and may be out of date: too old to read (${why})` : `its record is held from ${at(entry.received_at)} and may be out of date: its record stream is ${stream} since ${at(entry.since)}: ${why}`) : null;
|
|
35
|
+
const notRead = record ? null : stream === 'too-old' ? `too old to read (${why})` : `not read: its record stream ${stream} since ${at(entry.since)}: ${why}`;
|
|
36
|
+
return { machine, record, stale, unread: notRead };
|
|
37
|
+
});
|
|
38
|
+
held.sort((a, b) => (a.machine < b.machine ? -1 : a.machine > b.machine ? 1 : 0));
|
|
39
|
+
if (typeof unread === 'string') held.push({ machine: '*', record: null, stale: null, unread });
|
|
40
|
+
return held;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A held machine's session list when it is current; otherwise why not (mailbox.rs `current_sessions`). */
|
|
44
|
+
function currentSessions(entry) {
|
|
45
|
+
if (entry.unread) return { why: entry.unread };
|
|
46
|
+
if (entry.stale) return { why: entry.stale };
|
|
47
|
+
if (!entry.record) return { why: 'its record was never read' };
|
|
48
|
+
const lost = SESSION_SOURCES.filter((source) => typeof entry.record.unread?.[source] === 'string').map((source) => `${source}: ${entry.record.unread[source]}`);
|
|
49
|
+
if (lost.length) return { why: `its session list may be out of date (${lost.join('; ')})` };
|
|
50
|
+
return Array.isArray(entry.record.sessions) ? { sessions: entry.record.sessions } : { why: 'its record holds no session list' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What a pane's screen asks, when it shows a selector (mail_route.rs `pane_prompt`): the options' block of non-empty
|
|
54
|
+
* lines and the block above it (the question), at most 20 lines; null when it shows none. */
|
|
55
|
+
export function panePrompt(screen) {
|
|
56
|
+
if (typeof screen !== 'string') return null;
|
|
57
|
+
const lines = screen.split('\n').map((line) => line.trimEnd());
|
|
58
|
+
const isOption = (line) => {
|
|
59
|
+
let rest = line.trimStart();
|
|
60
|
+
for (const mark of ['❯', '›', '>']) if (rest.startsWith(mark)) { rest = rest.slice(mark.length); break; }
|
|
61
|
+
rest = rest.trimStart();
|
|
62
|
+
const digits = /^\d*/.exec(rest)[0].length;
|
|
63
|
+
return digits > 0 && ['.', ')'].includes(rest[digits]);
|
|
64
|
+
};
|
|
65
|
+
const selected = lines.findIndex((line) => { const trimmed = line.trimStart(); return ['❯', '›', '>'].some((mark) => trimmed.startsWith(mark)) && isOption(line); });
|
|
66
|
+
if (selected < 0) return null;
|
|
67
|
+
const blockStart = (end) => { for (let n = end - 1; n >= 0; n--) if (!lines[n].trim()) return n + 1; return 0; };
|
|
68
|
+
const options = blockStart(selected);
|
|
69
|
+
let last = -1; for (let n = options - 1; n >= 0; n--) if (lines[n].trim()) { last = n; break; }
|
|
70
|
+
const start = last >= 0 ? blockStart(last) : options;
|
|
71
|
+
let end = lines.length; for (let n = selected; n < lines.length; n++) if (!lines[n].trim()) { end = n; break; }
|
|
72
|
+
return lines.slice(start, end).map((line) => line.trim()).filter(Boolean).slice(0, 20);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The end of a file, at most `bytes` (a transcript's tail). */
|
|
76
|
+
function tail(path, bytes) {
|
|
77
|
+
const fd = openSync(path, 'r');
|
|
78
|
+
try {
|
|
79
|
+
const size = statSync(path).size;
|
|
80
|
+
const start = Math.max(0, size - bytes);
|
|
81
|
+
const buffer = Buffer.alloc(size - start);
|
|
82
|
+
readSync(fd, buffer, 0, buffer.length, start);
|
|
83
|
+
return buffer.toString('utf8');
|
|
84
|
+
} finally { closeSync(fd); }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The newest tool call in a Claude transcript with no result yet (mail_route.rs `pending_request`). */
|
|
88
|
+
function pendingFromTranscript(path) {
|
|
89
|
+
let text; try { text = tail(path, 512 * 1024); } catch { return null; }
|
|
90
|
+
const answered = new Set();
|
|
91
|
+
for (const line of text.split('\n').reverse()) {
|
|
92
|
+
let record; try { record = JSON.parse(line); } catch { continue; }
|
|
93
|
+
if (record?.isSidechain === true || !Array.isArray(record?.message?.content)) continue;
|
|
94
|
+
const content = record.message.content;
|
|
95
|
+
if (record.type === 'user') { for (const item of content) if (item?.type === 'tool_result' && typeof item.tool_use_id === 'string') answered.add(item.tool_use_id); continue; }
|
|
96
|
+
if (record.type !== 'assistant') continue;
|
|
97
|
+
const call = [...content].reverse().find((item) => item?.type === 'tool_use');
|
|
98
|
+
if (!call) continue;
|
|
99
|
+
if (typeof call.id === 'string' && answered.has(call.id)) return null;
|
|
100
|
+
const tool = typeof call.name === 'string' ? call.name : '';
|
|
101
|
+
if (tool === 'AskUserQuestion') {
|
|
102
|
+
const questions = (Array.isArray(call.input?.questions) ? call.input.questions : []).map((question) => ({ question: question?.question ?? null, header: question?.header ?? null, options: (Array.isArray(question?.options) ? question.options : []).map((option) => option?.label ?? null) }));
|
|
103
|
+
return { tool, questions };
|
|
104
|
+
}
|
|
105
|
+
return { tool, input: [...JSON.stringify(call.input ?? null)].slice(0, 500).join('') };
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The mail reads, over `live` (the live-session map), `store` (the header index), `records` (the records of the team's
|
|
112
|
+
* other machines), `descriptors` (the session index's rows by address), `homes` (where transcripts are),
|
|
113
|
+
* `paneQuestion(pane)` (what a waiting session's pane asks, read off its screen) and `askMachine(machine, request)`
|
|
114
|
+
* (one named read of another machine's mail door).
|
|
115
|
+
*/
|
|
116
|
+
export class MailReads {
|
|
117
|
+
/** `records()` answers `{ records, unread, self }`: the records this daemon holds (machine-records.mjs `read`), why
|
|
118
|
+
* it holds none when it follows no machine, and this machine's name. */
|
|
119
|
+
constructor({ machine, live, store, records, descriptors, homes, paneQuestion = async () => null, askMachine }) {
|
|
120
|
+
Object.assign(this, { machine, live, store, records, descriptors, homes, paneQuestion, askMachine });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---- list ------------------------------------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
/** What a waiting session asks: its transcript's pending tool call, else its pane's prompt (`pending_request`). */
|
|
126
|
+
async #pending(session) {
|
|
127
|
+
if (session.status !== 'waiting') return null;
|
|
128
|
+
const harness = session.address.split(':')[2];
|
|
129
|
+
if (harness === 'claude-code') {
|
|
130
|
+
const path = transcriptPath(session.address, this.homes);
|
|
131
|
+
const found = path ? pendingFromTranscript(path) : null;
|
|
132
|
+
if (found) return found;
|
|
133
|
+
} else if (harness !== 'codex') return null;
|
|
134
|
+
const pane = this.#paneOf(session);
|
|
135
|
+
const prompt = pane ? await this.paneQuestion(pane) : null;
|
|
136
|
+
return prompt ? { prompt, source: 'screen' } : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The daemon pane a live session runs in, or null. */
|
|
140
|
+
paneOf(session) { return this.#paneOf(session); }
|
|
141
|
+
|
|
142
|
+
#paneOf(session) {
|
|
143
|
+
const pane = typeof session.pane === 'string' ? session.pane.split(':')[0] : null;
|
|
144
|
+
return pane && isDaemonPane(pane) ? pane : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** This machine's live sessions as `message list` rows (`session_rows`). */
|
|
148
|
+
async localRows() {
|
|
149
|
+
return Promise.all(this.live.sessions().map(async (session) => ({
|
|
150
|
+
name: session.name,
|
|
151
|
+
harness: session.address.split(':')[2],
|
|
152
|
+
status: session.status,
|
|
153
|
+
delivery: session.door,
|
|
154
|
+
address: session.address,
|
|
155
|
+
pane: this.#paneOf(session),
|
|
156
|
+
last_message_at_ms: this.descriptors.get(session.address)?.updated_at_ms ?? null,
|
|
157
|
+
pending_request: await this.#pending(session),
|
|
158
|
+
})));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The team's other machines' sessions from the records this daemon holds (`team_rows`): a machine not read is
|
|
162
|
+
* unknown (with why), never one with no sessions; a record not current is listed and said stale. */
|
|
163
|
+
teamRows() {
|
|
164
|
+
const team = { rows: [], unread: [], stale: [] };
|
|
165
|
+
for (const machine of heldRecords(this.records())) {
|
|
166
|
+
if (machine.unread) { team.unread.push([machine.machine, machine.unread]); continue; }
|
|
167
|
+
const current = currentSessions(machine);
|
|
168
|
+
if (current.sessions) { team.rows.push(...current.sessions); continue; }
|
|
169
|
+
const sessions = machine.record?.sessions;
|
|
170
|
+
if (Array.isArray(sessions)) { team.rows.push(...sessions.map((row) => ({ ...row, stale: current.why }))); team.stale.push([machine.machine, current.why]); }
|
|
171
|
+
else team.unread.push([machine.machine, current.why]);
|
|
172
|
+
}
|
|
173
|
+
return team;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A bare name (or a daemon pane's id, `p_…`) no session here answers to, looked up in the records this daemon holds of
|
|
178
|
+
* the team's other machines (message.rs `team_address`): `{ to }` its address there (with `notes` for the sender:
|
|
179
|
+
* found only in a stale record, or unique only among the machines read), `{ to: null }` when it is this machine's to
|
|
180
|
+
* resolve, or `{ error }`. `remembered(name)` is the names ledger's address for a name.
|
|
181
|
+
*/
|
|
182
|
+
teamAddress(to, remembered) {
|
|
183
|
+
if (to.includes('@') || to.startsWith('sc:') || to.startsWith('operator:')) return { to: null };
|
|
184
|
+
const pane = to.startsWith('p_');
|
|
185
|
+
const names = (row) => (pane ? row.pane === to : String(row.name ?? '').split('@')[0] === to);
|
|
186
|
+
const here = this.live.sessions().map((session) => ({ name: session.name, pane: this.#paneOf(session), address: session.address }));
|
|
187
|
+
if (here.some(names)) return { to: null };
|
|
188
|
+
// a name a session running here ran under before is the mail door's to resolve and say
|
|
189
|
+
if (!pane) { const address = remembered(to); if (address && here.some((row) => row.address === address)) return { to: null }; }
|
|
190
|
+
const team = this.teamRows();
|
|
191
|
+
const incomplete = team.unread.length ? team.unread.map(([machine, why]) => `${machine}: ${why}`).join('; ') : null;
|
|
192
|
+
const held = (stale) => {
|
|
193
|
+
const found = [];
|
|
194
|
+
for (const row of team.rows.filter((item) => names(item) && (item.stale !== undefined) === stale)) {
|
|
195
|
+
if (typeof row.address !== 'string') continue;
|
|
196
|
+
const machine = row.address.split(':')[1] ?? '';
|
|
197
|
+
if (!found.some(([known]) => known === machine)) found.push([machine, row.address]);
|
|
198
|
+
}
|
|
199
|
+
return found;
|
|
200
|
+
};
|
|
201
|
+
const current = held(false);
|
|
202
|
+
const [found, stale] = current.length ? [current, false] : [held(true), true];
|
|
203
|
+
if (found.length === 1) {
|
|
204
|
+
const [[machine, only]] = found;
|
|
205
|
+
const notes = [];
|
|
206
|
+
if (stale) notes.push(`\`${to}\` is ${only}, found only in a stale record of ${machine}: it may no longer run there`);
|
|
207
|
+
if (!pane && incomplete) notes.push(`\`${to}\` is ${only}, the only session of that name on the machines read; not read: ${incomplete}`);
|
|
208
|
+
return { to: only, notes };
|
|
209
|
+
}
|
|
210
|
+
if (!found.length) return incomplete ? { error: `\`${to}\` is on no machine of the team that was read; not read: ${incomplete}` } : { to: null };
|
|
211
|
+
return { error: `\`${to}\` names a session on ${found.length} machines (${found.map(([, address]) => address).join(', ')})${stale ? ', each in a stale record' : ''}: not a unique answer` };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** `message list`: this machine's sessions, then the team's, then each machine not read or not current (`list`). */
|
|
215
|
+
async list() {
|
|
216
|
+
const rows = await this.localRows();
|
|
217
|
+
const team = this.teamRows();
|
|
218
|
+
rows.push(...team.rows);
|
|
219
|
+
for (const [machine, why] of team.unread) rows.push({ machine: machine === '*' ? null : machine, status: 'unavailable', error: why });
|
|
220
|
+
for (const [machine, why] of team.stale) rows.push({ machine, status: 'stale', error: why });
|
|
221
|
+
return rows;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---- show ------------------------------------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
/** One stored envelope whole (its body from its file); null when it is gone. */
|
|
227
|
+
#whole(stored) { const envelope = this.store.body(stored); return envelope ? { ...stored, envelope } : null; }
|
|
228
|
+
|
|
229
|
+
/** `show` rows for the messages this machine holds (`show_rows`). */
|
|
230
|
+
async showRows(ids) {
|
|
231
|
+
const transcripts = new Map();
|
|
232
|
+
return Promise.all(ids.map(async (id) => {
|
|
233
|
+
const found = this.store.find(id);
|
|
234
|
+
if (found.length === 0) return { id, found: false };
|
|
235
|
+
if (found.length > 1) return { id, found: false, ambiguous: found.map(({ stored }) => stored.envelope.id) };
|
|
236
|
+
const { address } = found[0];
|
|
237
|
+
const stored = this.#whole(found[0].stored);
|
|
238
|
+
if (!stored) return { id, found: false };
|
|
239
|
+
if (!transcripts.has(address)) transcripts.set(address, readTranscript(address, this.homes));
|
|
240
|
+
const delivered = deliveries(address, [stored], await transcripts.get(address), (envelope) => questionDeliveredAt(envelope, this.homes.mailRoot));
|
|
241
|
+
return { id, found: true, machine: this.machine, message: messageRow(address, stored, delivered, this.homes.mailRoot) };
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* `message show`: each id here, else on the one machine whose held record names it (one named, bounded read, never
|
|
247
|
+
* every machine). A machine that could not be searched makes an id not found unknown, never absent (`show`). Answers
|
|
248
|
+
* `{ rows, code }`.
|
|
249
|
+
*/
|
|
250
|
+
async show(ids) {
|
|
251
|
+
const rows = await this.showRows(ids);
|
|
252
|
+
const wanted = rows.filter((row) => row.found === false && !row.ambiguous).map((row) => row.id);
|
|
253
|
+
const unsearched = [];
|
|
254
|
+
if (wanted.length) {
|
|
255
|
+
const held = heldRecords(this.records());
|
|
256
|
+
const holds = (record, id) => Object.values(record?.mail ?? {}).some((box) => ['unread', 'recent'].some((part) => (Array.isArray(box?.[part]) ? box[part] : []).some((header) => typeof header?.id === 'string' && (header.id === id || header.id.startsWith(id)))));
|
|
257
|
+
const asks = new Map(), placed = new Set(), shorts = [];
|
|
258
|
+
for (const entry of held) {
|
|
259
|
+
if (entry.unread) { unsearched.push([entry.machine, entry.unread]); continue; }
|
|
260
|
+
const record = entry.record;
|
|
261
|
+
if (!record) continue;
|
|
262
|
+
const short = [];
|
|
263
|
+
if (entry.stale) short.push(entry.stale);
|
|
264
|
+
for (const source of ['mail', 'mail_watch', 'mail_boxes']) if (typeof record.unread?.[source] === 'string') short.push(record.unread[source]);
|
|
265
|
+
for (const [address, box] of Object.entries(record.mail ?? {})) { const why = box?.unread_reason ?? box?.partial; if (typeof why === 'string') short.push(`${address}: ${why}`); }
|
|
266
|
+
for (const id of wanted) if (holds(record, id)) { if (!asks.has(entry.machine)) asks.set(entry.machine, []); asks.get(entry.machine).push(id); placed.add(id); }
|
|
267
|
+
if (short.length) shorts.push([entry.machine, short]);
|
|
268
|
+
}
|
|
269
|
+
if (wanted.some((id) => id.length < FULL_ID_LEN || !placed.has(id))) for (const [machine, short] of shorts) unsearched.push([machine, `its mail window may be incomplete: ${short.join('; ')}`]);
|
|
270
|
+
const answers = await Promise.all([...asks].map(async ([machine, asked]) => {
|
|
271
|
+
try { return [machine, await this.askMachine(machine, { op: 'show', ids: asked })]; } catch (error) { return [machine, error]; }
|
|
272
|
+
}));
|
|
273
|
+
for (const [machine, answer] of answers) {
|
|
274
|
+
if (answer instanceof Error) { unsearched.push([machine, answer.message]); continue; }
|
|
275
|
+
if (!Array.isArray(answer?.rows)) { unsearched.push([machine, 'it answered no search result']); continue; }
|
|
276
|
+
for (const there of answer.rows) {
|
|
277
|
+
if (there?.found !== true && !there?.ambiguous) continue;
|
|
278
|
+
const at = rows.findIndex((row) => row.id === there.id && row.found === false);
|
|
279
|
+
if (at >= 0) rows[at] = there;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const older = wanted.filter((id) => !placed.has(id));
|
|
283
|
+
if (older.length) {
|
|
284
|
+
const followed = held.filter((entry) => entry.machine !== '*').map((entry) => entry.machine);
|
|
285
|
+
unsearched.push(['*', `${older.join(', ')} ${older.length === 1 ? 'is' : 'are'} in no pushed mail window (each machine's record holds its unread mail and the last ${RECENT_PER_MAILBOX} messages of each mailbox): an older message is shown by \`supercode message show\` on the machine that holds it${followed.length ? ` (${followed.join(', ')})` : ''}`]);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const missing = rows.some((row) => row.found !== true);
|
|
289
|
+
const notSearched = unsearched.map(([machine, why]) => (machine === '*' ? why : `${machine}: ${why}`));
|
|
290
|
+
if (notSearched.length) for (const row of rows) if (row.found === false && !row.ambiguous) { row.found = null; row.unsearched = notSearched; }
|
|
291
|
+
return { rows, unsearched: notSearched, code: missing && notSearched.length ? EXIT_FAILED : missing ? EXIT_UNKNOWN : 0 };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** `show --replies`: for each message here, the messages in its sender's mailbox that answer it (`show_replies`). */
|
|
295
|
+
showReplies(ids) {
|
|
296
|
+
const rows = ids.map((id) => {
|
|
297
|
+
const found = this.store.find(id);
|
|
298
|
+
if (found.length !== 1) return { id, found: false };
|
|
299
|
+
const asked = found[0].stored.envelope;
|
|
300
|
+
const namesIt = (reply) => reply === asked.id || (reply.length >= 10 && asked.id.startsWith(reply));
|
|
301
|
+
const replies = this.store.inventory(asked.from).stored
|
|
302
|
+
.filter((stored) => typeof stored.envelope.in_reply_to === 'string' && namesIt(stored.envelope.in_reply_to))
|
|
303
|
+
.map((stored) => this.#whole(stored)).filter(Boolean)
|
|
304
|
+
.map(({ envelope }) => ({ id: envelope.id, from: envelope.from, from_name: envelope.from_name, created_at_ms: envelope.created_at_ms, body: envelope.body }));
|
|
305
|
+
return { id, found: true, message: asked.id, session: found[0].address, asker: asked.from, replies };
|
|
306
|
+
});
|
|
307
|
+
return { rows, code: rows.some((row) => row.found !== true) ? EXIT_UNKNOWN : 0 };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---- inbox -----------------------------------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* A session's own `message inbox`: its unread mail claimed first (the claim hands it over), then, with `all`, its read
|
|
314
|
+
* mail with when each reached the model, from its transcript; the newest `last` of the read only (`inbox`). Answers
|
|
315
|
+
* `{ stored, deliveries, claimed }`; the caller acknowledges each claimed one once it is printed (`acknowledge`).
|
|
316
|
+
*/
|
|
317
|
+
async inbox(address, { all = false, last = null, pid = process.pid } = {}) {
|
|
318
|
+
const claimed = this.store.claimUnread(address, 'inbox', address, pid).map((stored) => this.#whole(stored) ?? stored);
|
|
319
|
+
const transcript = all ? await readTranscript(address, this.homes) : null;
|
|
320
|
+
let stored;
|
|
321
|
+
if (all) {
|
|
322
|
+
stored = this.store.inventory(address).stored
|
|
323
|
+
.filter((item) => item.state === 'read' || (item.envelope.kind === 'user' && item.part === 'claimed'))
|
|
324
|
+
.map((item) => this.#whole(item)).filter(Boolean);
|
|
325
|
+
if (last !== null) stored = stored.slice(Math.max(0, stored.length - last));
|
|
326
|
+
stored.push(...claimed);
|
|
327
|
+
stored.sort((a, b) => a.envelope.created_at_ms - b.envelope.created_at_ms);
|
|
328
|
+
} else stored = claimed;
|
|
329
|
+
const delivered = deliveries(address, stored, transcript, (envelope) => questionDeliveredAt(envelope, this.homes.mailRoot));
|
|
330
|
+
const now = Date.now();
|
|
331
|
+
for (const item of claimed) delivered.set(item.envelope.id, { state: 'by_this_read', at_ms: now });
|
|
332
|
+
return { stored, deliveries: delivered, claimed, rendered: stored.map((item) => listed(item.envelope, delivered, this.homes.mailRoot)), rows: stored.map((item) => messageRow(address, item, delivered, this.homes.mailRoot)) };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** The live session a name or address names here (mail_route.rs `resolve`, without the ledger of earlier names). */
|
|
336
|
+
resolve(name) {
|
|
337
|
+
const sessions = this.live.sessions();
|
|
338
|
+
if (String(name).startsWith('sc:')) return sessions.find((session) => session.address === name) ? { address: name } : { error: `${name} is no longer running. Nothing was sent. Run supercode message list for the live sessions.` };
|
|
339
|
+
const [wanted, at] = String(name).split('@');
|
|
340
|
+
if (at !== undefined && at !== this.machine) return { error: `Not sent: ${name} is on machine ${at}, not this one. Nothing was sent.` };
|
|
341
|
+
const matching = sessions.filter((session) => session.name.split('@')[0] === wanted);
|
|
342
|
+
return matching.length === 1 ? { address: matching[0].address } : { error: matching.length ? `${name} names ${matching.length} sessions here; give its address.` : `No running session here is named "${name}".` };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Claimed mail printed: it reached its reader (moved to `cur/`). */
|
|
346
|
+
acknowledge(address, claimed) { for (const item of claimed) this.store.acknowledge(address, item); }
|
|
347
|
+
|
|
348
|
+
/** Everything into and out of the session at `address` here, oldest first, read without marking anything read:
|
|
349
|
+
* `inbox --session` rows (`inbox_rows`), the newest `last` only when given. */
|
|
350
|
+
async inboxRows(address, last = null) {
|
|
351
|
+
const transcript = await readTranscript(address, this.homes);
|
|
352
|
+
const received = this.store.inventory(address).stored.filter((item) => !(item.envelope.kind === 'question' && item.envelope.from === address)).map((item) => this.#whole(item)).filter(Boolean);
|
|
353
|
+
const delivered = deliveries(address, received, transcript, (envelope) => questionDeliveredAt(envelope, this.homes.mailRoot));
|
|
354
|
+
const sent = [];
|
|
355
|
+
for (const to of this.store.addresses()) {
|
|
356
|
+
if (to === address) continue;
|
|
357
|
+
for (const item of this.store.inventory(to).stored) if (item.envelope.from === address) { const whole = this.#whole(item); if (whole) sent.push([to, whole]); }
|
|
358
|
+
}
|
|
359
|
+
let timeline = [...received.map((item) => [address, item, 'in']), ...sent.map(([to, item]) => [to, item, 'out'])];
|
|
360
|
+
timeline.sort((a, b) => a[1].envelope.created_at_ms - b[1].envelope.created_at_ms);
|
|
361
|
+
if (last !== null) timeline = timeline.slice(Math.max(0, timeline.length - last));
|
|
362
|
+
// where a message it sent stands is read from its receiver's transcript, the newest receivers' only
|
|
363
|
+
const receivers = [];
|
|
364
|
+
for (const [to, , direction] of [...timeline].reverse()) if (direction === 'out' && !receivers.includes(to) && receivers.length < MAX_RECEIVERS_READ) receivers.push(to);
|
|
365
|
+
for (const to of receivers) {
|
|
366
|
+
const theirs = timeline.filter(([receiver, , direction]) => direction === 'out' && receiver === to).map(([, item]) => item);
|
|
367
|
+
for (const [id, delivery] of deliveries(to, theirs, await readTranscript(to, this.homes), (envelope) => questionDeliveredAt(envelope, this.homes.mailRoot))) delivered.set(id, delivery);
|
|
368
|
+
}
|
|
369
|
+
for (const [, item, direction] of timeline) if (direction === 'out' && !delivered.has(item.envelope.id)) delivered.set(item.envelope.id, { state: 'not_read' });
|
|
370
|
+
return timeline.map(([box, item, direction]) => ({ ...messageRow(box, item, delivered, this.homes.mailRoot), direction }));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ---- the machine record's mail ---------------------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
/** Each named mailbox's headers (all with none named): its unread mail, its newest `recent` and how many it holds,
|
|
376
|
+
* never a body; a mailbox read partially says so (the `headers` operation). */
|
|
377
|
+
headers({ recent = RECENT_PER_MAILBOX, mailboxes = null } = {}) {
|
|
378
|
+
const header = (item) => ({ id: item.envelope.id, from: item.envelope.from, from_name: item.envelope.from_name, kind: item.envelope.kind, subject: item.envelope.subject ?? null, thread: item.envelope.thread ?? item.envelope.id, in_reply_to: item.envelope.in_reply_to ?? null, created_at_ms: item.envelope.created_at_ms, unread: item.state === 'unread' });
|
|
379
|
+
const mail = {};
|
|
380
|
+
for (const address of mailboxes ?? this.store.addresses()) {
|
|
381
|
+
const { stored, skipped } = this.store.inventory(address);
|
|
382
|
+
const answer = { unread: stored.filter((item) => item.state === 'unread').map(header), recent: [...stored].reverse().slice(0, recent).map(header), count: stored.length };
|
|
383
|
+
if (skipped.length) answer.partial = `${skipped.length} envelope file${skipped.length === 1 ? '' : 's'} could not be read (${skipped.slice(0, 5).join(', ')}); its unread mail may be more than listed`;
|
|
384
|
+
mail[address] = answer;
|
|
385
|
+
}
|
|
386
|
+
return { mail, unread_boxes: this.store.unread ? [this.store.unread] : [] };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
package/mail/relay.mjs
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// A Claude session supercode does not control is reached through Claude's own SendMessage, made by a relay: a `claude
|
|
2
|
+
// --print` runtime that speaks for the sender, hosted by this daemon's `harness serve` (crates/harness/src/claude_relay.rs).
|
|
3
|
+
// The daemon drives a relay send through the relay's folder, as the native sender did: `queue.json` names the one send its
|
|
4
|
+
// gate allows, the relay's hooks write Claude's answer to `receipt.json`, and the turn that makes the send goes in by
|
|
5
|
+
// harness serve's own `runtimes.send_input`; its runtime is started and closed by `runtimes.start` and `runtimes.close`.
|
|
6
|
+
// No native process runs per delivery. The relay's hooks (`message gate`, `relay-inbound`, `relay-receipt`) stay native
|
|
7
|
+
// doors, run by the relay's Claude (docs/architecture/overview.md, "Pinned native doors"). Its folder is the native
|
|
8
|
+
// sender's own (`<mail>/relays/<blake3(name)[..24]>/`, its record, settings and send log), so the native `message
|
|
9
|
+
// watch`, which retires relays until step 4, reads what the daemon writes.
|
|
10
|
+
import { mkdirSync, readFileSync, rmSync, utimesSync, watch, writeFileSync } from 'node:fs';
|
|
11
|
+
import { connect } from 'node:net';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { blake3Hex } from './envelope.mjs';
|
|
14
|
+
|
|
15
|
+
/** Model name a relay's requests carry; the relay endpoint answers them without any model. */
|
|
16
|
+
const RELAY_MODEL = 'haiku';
|
|
17
|
+
/** Prefix marking a registry name as a relay rather than a session. */
|
|
18
|
+
export const RELAY_NAME_PREFIX = 'sc-';
|
|
19
|
+
/** How long a send waits for Claude's own receipt. */
|
|
20
|
+
const SEND_TIMEOUT_MS = 90_000;
|
|
21
|
+
/** Largest message relayed into a Claude session: the relay copies it into a model turn byte for byte. */
|
|
22
|
+
export const MAX_RELAYED_BYTES = 100_000;
|
|
23
|
+
|
|
24
|
+
const alive = (pid) => { if (!Number.isInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
|
|
25
|
+
const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
|
|
26
|
+
const shellQuote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
27
|
+
|
|
28
|
+
/** Registry name of the relay that represents `name` on `machine` (not `name@machine`: SendMessage refuses an `@`). */
|
|
29
|
+
export function relayName(name, machine) {
|
|
30
|
+
const base = name.startsWith(RELAY_NAME_PREFIX) ? name.slice(RELAY_NAME_PREFIX.length) : name;
|
|
31
|
+
return `${RELAY_NAME_PREFIX}${base}-on-${machine}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The host turn asking a relay to make one send (claude_relay.rs `send_turn`). */
|
|
35
|
+
const sendTurn = ({ to, message }) => `Send one message with SendMessage.\nto: ${to}\nmessage: the exact text between the markers, without the markers\n---BEGIN MESSAGE---\n${message}\n---END MESSAGE---`;
|
|
36
|
+
|
|
37
|
+
/** Claude's answer as the relay's hooks wrote it (claude_relay.rs `read_receipt`): `{ delivered: true }` or `{ delivered:
|
|
38
|
+
* false, detail }`. */
|
|
39
|
+
export function readReceipt(value) {
|
|
40
|
+
if (typeof value?.denied === 'string') return { delivered: false, detail: `the relay's gate refused the send: ${value.denied}` };
|
|
41
|
+
if (typeof value?.send_failed === 'string') return { delivered: false, detail: `Claude Code refused the relay's send: ${value.send_failed.trim()}` };
|
|
42
|
+
if (value?.turn_ended === true) {
|
|
43
|
+
const said = typeof value.said === 'string' && value.said.trim() ? `; it said: ${value.said.trim()}` : '';
|
|
44
|
+
return { delivered: false, detail: `the Claude relay ended its turn without sending; nothing was sent${said}` };
|
|
45
|
+
}
|
|
46
|
+
let response = value?.tool_response ?? null;
|
|
47
|
+
if (typeof response === 'string') { try { response = JSON.parse(response); } catch { /* a text answer */ } }
|
|
48
|
+
else if (Array.isArray(response)) { const text = response.find((block) => typeof block?.text === 'string')?.text; if (text) { try { response = JSON.parse(text); } catch { /* as it is */ } } }
|
|
49
|
+
if (response?.success !== true) return { delivered: false, detail: `Claude did not report the send as successful: ${JSON.stringify(response)}` };
|
|
50
|
+
return { delivered: true, detail: typeof response.message === 'string' ? response.message : '', native_msg_id: typeof response.msg_id === 'string' ? response.msg_id : null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class Relays {
|
|
54
|
+
/**
|
|
55
|
+
* `serve(method, params, timeoutMs)` is one `harness.v1` request to this daemon's harness serve; `runtimeOf(harness,
|
|
56
|
+
* id)` the live-runtime receipt the live-session map holds for a runtime id (its file read whole), or null;
|
|
57
|
+
* `runtimeNamed(receiptId)` one receipt read by its own name.
|
|
58
|
+
*/
|
|
59
|
+
constructor({ mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed = () => null }) {
|
|
60
|
+
Object.assign(this, { mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed });
|
|
61
|
+
this.sending = new Map(); // relay folder → the send in progress through it (one at a time per relay)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The relay that speaks for `represented`, known as `representedName`: its name and its files. */
|
|
65
|
+
spec(represented, representedName) {
|
|
66
|
+
const name = relayName(String(representedName).split('@')[0], String(represented).split(':')[1]);
|
|
67
|
+
const directory = join(this.mailRoot, 'relays', blake3Hex(name).slice(0, 24));
|
|
68
|
+
return {
|
|
69
|
+
represented, name, directory,
|
|
70
|
+
queue: join(directory, 'queue.json'), receipt: join(directory, 'receipt.json'), settings: join(directory, 'settings.json'),
|
|
71
|
+
record: join(directory, 'relay.json'), sent: join(directory, 'sent.json'),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The relay endpoint's base URL, when it answers (claude_relay.rs `relay_endpoint`; the native watch serves it until
|
|
76
|
+
* step 4, its port in `<mail>/relay-endpoint.json`). */
|
|
77
|
+
async endpoint() {
|
|
78
|
+
const port = readJson(join(this.mailRoot, 'relay-endpoint.json'))?.port;
|
|
79
|
+
if (!Number.isInteger(port)) return null;
|
|
80
|
+
return (await answers(port)) ? `http://127.0.0.1:${port}` : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Settings installing a relay's hooks: its gate on every tool, its receipt on SendMessage and on a turn's end, and its
|
|
84
|
+
* inbound filing (claude_relay.rs `relay_settings`). */
|
|
85
|
+
#settings(spec) {
|
|
86
|
+
const program = shellQuote(this.supercodeBin), directory = shellQuote(spec.directory), represented = shellQuote(spec.represented);
|
|
87
|
+
const command = (verb) => `${program} message ${verb} ${directory}`;
|
|
88
|
+
const receipt = { hooks: [{ type: 'command', command: command('relay-receipt') }] };
|
|
89
|
+
return {
|
|
90
|
+
hooks: {
|
|
91
|
+
PreToolUse: [{ matcher: '.*', hooks: [{ type: 'command', command: command('gate') }] }],
|
|
92
|
+
PostToolUse: [{ matcher: 'SendMessage', ...receipt }],
|
|
93
|
+
PostToolUseFailure: [{ matcher: 'SendMessage', ...receipt }],
|
|
94
|
+
Stop: [receipt],
|
|
95
|
+
StopFailure: [receipt],
|
|
96
|
+
UserPromptSubmit: [{ hooks: [{ type: 'command', command: `${program} message relay-inbound ${represented} ${directory}` }] }],
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The relay's process: the runtime receipt's child, else its record's, so a relay that ended is seen. */
|
|
102
|
+
#pid(spec, runtime) {
|
|
103
|
+
const child = runtime?.metadata?.child_pid;
|
|
104
|
+
if (Number.isInteger(child)) return child;
|
|
105
|
+
const record = readJson(spec.record);
|
|
106
|
+
return record?.runtime_id === runtime?.runtime_session_id && Number.isInteger(record?.pid) ? record.pid : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The relay's runtime, started in this daemon's harness serve when it is not running (claude_relay.rs
|
|
110
|
+
* `ensure_relay_runtime`): `{ runtime (its receipt), connection }`. */
|
|
111
|
+
async #ensure(spec) {
|
|
112
|
+
const endpoint = await this.endpoint();
|
|
113
|
+
if (!endpoint) throw new Error('the relay endpoint is not answering; the machine daemon\'s `supercode message watch` serves it (see mail/machine-daemon.log)');
|
|
114
|
+
// written on every send: Claude Code reloads a changed settings file, so a running relay takes the hooks this
|
|
115
|
+
// supercode installs
|
|
116
|
+
writeFileSync(spec.settings, JSON.stringify(this.#settings(spec), null, 2));
|
|
117
|
+
const record = readJson(spec.record);
|
|
118
|
+
if (record?.runtime_id) {
|
|
119
|
+
const runtime = this.runtimeOf('claude-code', record.runtime_id);
|
|
120
|
+
if (runtime && alive(this.#pid(spec, runtime)) && record.connection && (record.endpoint === endpoint || await answers(Number(String(record.endpoint ?? '').split(':').pop())))) {
|
|
121
|
+
return { runtime, connection: record.connection };
|
|
122
|
+
}
|
|
123
|
+
// closed through the owner's door even after its receipt went
|
|
124
|
+
try { await this.serve('harness.v1.runtimes.close', { connection: record.connection, runtime_id: record.runtime_id }, 15_000); }
|
|
125
|
+
catch (error) { if (this.runtimeOf('claude-code', record.runtime_id)) throw new Error(`could not retire the old relay: ${error.message}`); }
|
|
126
|
+
}
|
|
127
|
+
const key = spec.directory.split(/[\\/]/).pop();
|
|
128
|
+
const started = await this.serve('harness.v1.runtimes.start', {
|
|
129
|
+
harness: 'claude-code',
|
|
130
|
+
launch: {
|
|
131
|
+
program: 'claude',
|
|
132
|
+
arguments: ['--print', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--permission-prompt-tool', 'stdio', '--model', RELAY_MODEL, '--name', spec.name, '--permission-mode', 'bypassPermissions', '--setting-sources', 'project', '--settings', spec.settings, '--tools', 'ListAgents,SendMessage', '--no-session-persistence'],
|
|
133
|
+
// its API is the relay endpoint, its key the relay's folder there (which also keeps account connectors out)
|
|
134
|
+
env: { ANTHROPIC_API_KEY: key, ANTHROPIC_BASE_URL: endpoint, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', SUPERCODE_CLAUDE_RELAY: '1' },
|
|
135
|
+
},
|
|
136
|
+
cwd: spec.directory,
|
|
137
|
+
}, 60_000);
|
|
138
|
+
const runtimeId = started?.handle?.runtime_id;
|
|
139
|
+
if (typeof runtimeId !== 'string') throw new Error(`the machine daemon did not start the relay: ${JSON.stringify(started)}`);
|
|
140
|
+
const connection = typeof started.connection === 'string' ? started.connection : '';
|
|
141
|
+
const pid = Number.isInteger(started?.handle?.endpoint?.pid) ? started.handle.endpoint.pid : null;
|
|
142
|
+
writeFileSync(spec.record, JSON.stringify({ runtime_id: runtimeId, pid, endpoint, connection }));
|
|
143
|
+
// harness serve writes its receipt before it answers the start; the map learns it from its watch a moment later, so
|
|
144
|
+
// one it does not hold yet is read by its own name (the receipt the runtime's terminal launch opens), never waited on
|
|
145
|
+
const known = this.runtimeOf('claude-code', runtimeId);
|
|
146
|
+
if (known) return { runtime: known, connection };
|
|
147
|
+
let receiptId = null;
|
|
148
|
+
try {
|
|
149
|
+
const launch = (await this.serve('harness.v1.runtimes.terminal_instructions', { connection }, 5000))?.launch;
|
|
150
|
+
receiptId = (Array.isArray(launch?.arguments) ? launch.arguments : []).find((arg) => typeof arg === 'string' && arg.startsWith('supercode-live://'))?.slice('supercode-live://'.length) ?? null;
|
|
151
|
+
} catch { /* said below */ }
|
|
152
|
+
const runtime = receiptId ? this.runtimeNamed(receiptId) : null;
|
|
153
|
+
if (!runtime) throw new Error('the relay started but registered no live runtime');
|
|
154
|
+
return { runtime, connection };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Claude's receipt for the queued send, waited on by a watch of the relay's folder (and its process checked at least
|
|
158
|
+
* every half second): a relay that ended or a send not confirmed within SEND_TIMEOUT_MS is said. */
|
|
159
|
+
#receipt(spec, pid) {
|
|
160
|
+
return new Promise((resolve) => {
|
|
161
|
+
let done = false, watcher = null, timer = null, deadline = null;
|
|
162
|
+
const finish = (answer) => { if (done) return; done = true; clearInterval(timer); clearTimeout(deadline); try { watcher?.close(); } catch { /* closed */ } resolve(answer); };
|
|
163
|
+
const check = () => {
|
|
164
|
+
const value = readJson(spec.receipt);
|
|
165
|
+
if (value) return finish(readReceipt(value));
|
|
166
|
+
if (pid && !alive(pid)) finish({ delivered: false, detail: 'the Claude relay process exited before confirming the send' });
|
|
167
|
+
};
|
|
168
|
+
try { watcher = watch(spec.directory, () => check()); watcher.on('error', () => {}); } catch { /* checked on the timer */ }
|
|
169
|
+
timer = setInterval(check, 500);
|
|
170
|
+
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);
|
|
171
|
+
check();
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Send `message` to the Claude session `receiver` (`{ sessionId, name, socket }`) from `sender` through its relay
|
|
177
|
+
* (claude_relay.rs `send_through_relay`): the receiver is named by its own socket (`uds:<path>`), unique to it, never
|
|
178
|
+
* by its name. One send at a time per relay; a relay whose process is proven dead is started again once.
|
|
179
|
+
*/
|
|
180
|
+
async send(sender, senderName, receiver, message, messageId) {
|
|
181
|
+
const spec = this.spec(sender, senderName);
|
|
182
|
+
mkdirSync(spec.directory, { recursive: true });
|
|
183
|
+
const previous = this.sending.get(spec.directory) ?? Promise.resolve();
|
|
184
|
+
let release;
|
|
185
|
+
const mine = new Promise((resolve) => { release = resolve; });
|
|
186
|
+
const turn = previous.then(() => mine);
|
|
187
|
+
this.sending.set(spec.directory, turn);
|
|
188
|
+
await previous;
|
|
189
|
+
try {
|
|
190
|
+
// its record is the relay's last use: the native sweep that keeps the most recently used relays running reads its
|
|
191
|
+
// time, and a relay carrying a send is the most recent
|
|
192
|
+
try { const now = new Date(); utimesSync(spec.record, now, now); } catch { /* none yet */ }
|
|
193
|
+
const queued = { to: receiver.socket ? `uds:${receiver.socket}` : receiver.name, message };
|
|
194
|
+
let { runtime, connection } = await this.#ensure(spec);
|
|
195
|
+
rmSync(spec.receipt, { force: true });
|
|
196
|
+
writeFileSync(spec.queue, JSON.stringify(queued));
|
|
197
|
+
let receipt;
|
|
198
|
+
for (let attempt = 0; ; attempt++) {
|
|
199
|
+
try { await this.serve('harness.v1.runtimes.send_input', { connection, text: sendTurn(queued) }, 30_000); receipt = await this.#receipt(spec, this.#pid(spec, runtime)); }
|
|
200
|
+
catch (error) { receipt = { delivered: false, detail: `could not reach the Claude relay: ${error.message}` }; }
|
|
201
|
+
const pid = this.#pid(spec, runtime);
|
|
202
|
+
// only a relay proven dead is started again; one alive but slow may still deliver
|
|
203
|
+
if (receipt.delivered || attempt === 1 || pid == null || alive(pid)) break;
|
|
204
|
+
try { ({ runtime, connection } = await this.#ensure(spec)); } catch (error) { receipt = { delivered: false, detail: error.message }; break; }
|
|
205
|
+
rmSync(spec.receipt, { force: true });
|
|
206
|
+
}
|
|
207
|
+
rmSync(spec.queue, { force: true });
|
|
208
|
+
if (receipt.delivered) {
|
|
209
|
+
const sent = readJson(spec.sent) ?? {};
|
|
210
|
+
sent[receiver.sessionId] = messageId;
|
|
211
|
+
writeFileSync(spec.sent, JSON.stringify(sent));
|
|
212
|
+
}
|
|
213
|
+
return receipt;
|
|
214
|
+
} catch (error) {
|
|
215
|
+
rmSync(spec.queue, { force: true });
|
|
216
|
+
return { delivered: false, detail: error.message };
|
|
217
|
+
} finally {
|
|
218
|
+
release();
|
|
219
|
+
if (this.sending.get(spec.directory) === turn) this.sending.delete(spec.directory);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Whether a local port answers a connection within 300 ms. */
|
|
225
|
+
function answers(port) {
|
|
226
|
+
if (!Number.isInteger(port) || port <= 0) return Promise.resolve(false);
|
|
227
|
+
return new Promise((resolve) => {
|
|
228
|
+
const socket = connect({ host: '127.0.0.1', port });
|
|
229
|
+
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, 300);
|
|
230
|
+
socket.once('connect', () => { clearTimeout(timer); socket.end(); resolve(true); });
|
|
231
|
+
socket.once('error', () => { clearTimeout(timer); resolve(false); });
|
|
232
|
+
});
|
|
233
|
+
}
|
package/mail/runtime.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The default tier: a session supercode hosts as a runtime is reached through the runtime's own doors, the ones every
|
|
2
|
+
// frontend uses: `steer` while a turn runs, `send_input` to start one (crates/harness/src/runtime_mail.rs). The text is
|
|
3
|
+
// the rendered envelope. The bearer token is read from the runtime's receipt at the delivery, never kept.
|
|
4
|
+
import { FrontendClient } from '@volter/supercode-frontend';
|
|
5
|
+
|
|
6
|
+
/** Deliver `text` into the hosted runtime `receipt` names: steered into a running turn, or a turn started when `wake`
|
|
7
|
+
* (an idle runtime is left alone otherwise). Answers 'steered', 'started' or 'not_woken'. */
|
|
8
|
+
export async function deliverToRuntime(receipt, text, wake) {
|
|
9
|
+
const client = new FrontendClient({ baseUrl: receipt.base_url, token: receipt.token, clientId: `mail-${receipt.receipt_id}`, permissions: ['observe', 'interact'] });
|
|
10
|
+
const descriptor = await client.describe();
|
|
11
|
+
if (descriptor.session_id !== receipt.runtime_session_id) throw new Error('the live runtime\'s identity did not match its receipt');
|
|
12
|
+
if (descriptor.turn_state === 'busy') {
|
|
13
|
+
if (!descriptor.actions?.steer) throw new Error('a turn is running and this runtime does not accept steering; the message was not delivered into it');
|
|
14
|
+
await client.steer(text);
|
|
15
|
+
return 'steered';
|
|
16
|
+
}
|
|
17
|
+
if (!wake) return 'not_woken';
|
|
18
|
+
await client.sendInput(text);
|
|
19
|
+
return 'started';
|
|
20
|
+
}
|