@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
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// What a session's transcript says about its mail (crates/harness/src/mail_transcript.rs, ported line for line): the
|
|
2
|
+
// lines its own user typed, the lines it took from its harness's own channels, its answers, and when each filed message
|
|
3
|
+
// first reached the model. The mailbox records a message as sent; only the transcript records it delivered. A transcript
|
|
4
|
+
// is read only when a call names the message or the session it is about (one named read, never a sweep).
|
|
5
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
6
|
+
import { readFile } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { blake3Hex } from './envelope.mjs';
|
|
9
|
+
|
|
10
|
+
// A transcript is read off the daemon's event loop (its file read awaited) and parsed in turns of YIELD_LINES lines: one
|
|
11
|
+
// large session's history never holds every other door while it is read.
|
|
12
|
+
const YIELD_LINES = 2000;
|
|
13
|
+
const yieldTurn = () => new Promise((resolve) => setImmediate(resolve));
|
|
14
|
+
|
|
15
|
+
const TYPED_ID_PREFIX = 'u-';
|
|
16
|
+
const CHANNEL_ID_PREFIX = 'c-';
|
|
17
|
+
const ANSWER_ID_PREFIX = 'a-';
|
|
18
|
+
|
|
19
|
+
/** The id of a line the user of the session at `address` typed at `sentAtMs`: `u-` and 24 hex digits. */
|
|
20
|
+
export const typedLineId = (address, sentAtMs, text) => `${TYPED_ID_PREFIX}${blake3Hex(`${address}\n${sentAtMs}\n${text}`).slice(0, 24)}`;
|
|
21
|
+
/** The id of the channel line with `header` in the session at `address`. */
|
|
22
|
+
export const channelLineId = (address, header) => `${CHANNEL_ID_PREFIX}${blake3Hex(`${address}\n${header}`).slice(0, 24)}`;
|
|
23
|
+
/** The id of the answer the transcript entry `nativeId` records in the session at `address`. */
|
|
24
|
+
export const answerId = (address, nativeId) => `${ANSWER_ID_PREFIX}${blake3Hex(`${address}\n${nativeId}`).slice(0, 24)}`;
|
|
25
|
+
|
|
26
|
+
/** An RFC 3339 time as epoch milliseconds (0 when it is not one): the native binary's own reading of a stamp. */
|
|
27
|
+
function atMs(value) {
|
|
28
|
+
if (typeof value !== 'string') return 0;
|
|
29
|
+
const match = /^(\d+)-(\d+)-(\d+)T(\d+):(\d+):(\d+)(?:\.(\d+))?Z?$/.exec(value.trim());
|
|
30
|
+
if (!match) return 0;
|
|
31
|
+
const [, y, mo, d, h, mi, s, fraction] = match;
|
|
32
|
+
const millis = fraction ? Number(fraction.slice(0, 3).padEnd(3, '0')) : 0;
|
|
33
|
+
const ms = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s), millis);
|
|
34
|
+
return Number.isFinite(ms) && ms > 0 ? ms : 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A prompt's words: a string, or its text parts joined; none when blank. */
|
|
38
|
+
function promptText(content) {
|
|
39
|
+
let text;
|
|
40
|
+
if (typeof content === 'string') text = content;
|
|
41
|
+
else if (Array.isArray(content)) text = content.filter((part) => part?.type === 'text' && typeof part.text === 'string').map((part) => part.text).join('\n');
|
|
42
|
+
else return null;
|
|
43
|
+
return text.trim() ? text : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The channel lines in a prompt, each from its `[channel: …]` header to the next. */
|
|
47
|
+
function channelLines(text, fallbackMs, deliveredAtMs) {
|
|
48
|
+
const starts = [];
|
|
49
|
+
for (let at = text.indexOf('[channel: '); at >= 0; at = text.indexOf('[channel: ', at + 1)) if (at === 0 || text[at - 1] === '\n') starts.push(at);
|
|
50
|
+
const lines = [];
|
|
51
|
+
starts.forEach((start, n) => {
|
|
52
|
+
const end = starts[n + 1] ?? text.length;
|
|
53
|
+
const line = text.slice(start, end).trimEnd();
|
|
54
|
+
const close = line.indexOf(']');
|
|
55
|
+
if (close < 0) return;
|
|
56
|
+
const header = line.slice(0, close + 1);
|
|
57
|
+
const field = (name) => { const part = header.split(' · ').find((item) => item.startsWith(name)); return part === undefined ? null : part.slice(name.length).replace(/\]+$/, '').trim(); };
|
|
58
|
+
const sent = field('at: ');
|
|
59
|
+
lines.push({ header, from: field('from: ') ?? 'a channel', sent_at_ms: (sent !== null && atMs(sent)) || fallbackMs, delivered_at_ms: deliveredAtMs, text: line });
|
|
60
|
+
});
|
|
61
|
+
return lines;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Mail ids rendered into a Claude transcript line: the envelope's own (escaped once) or the relay's `(message m-…)`. */
|
|
65
|
+
function renderedMailIds(line) {
|
|
66
|
+
const ids = [];
|
|
67
|
+
for (const mark of ['cross-session-message id=\\"', '(message ']) {
|
|
68
|
+
for (let at = line.indexOf(mark); at >= 0; at = line.indexOf(mark, at + 1)) {
|
|
69
|
+
const rest = line.slice(at + mark.length);
|
|
70
|
+
const id = /^[A-Za-z0-9-]*/.exec(rest)[0];
|
|
71
|
+
if (id.startsWith('m-')) ids.push(id);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return ids;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Whether a line a harness queued is machine-made on its face. */
|
|
78
|
+
const machineMade = (text) => text.includes('<cross-session-message') || text.startsWith('[Cross-session') || text.startsWith('[channel: ') || text.startsWith('<task-notification>');
|
|
79
|
+
|
|
80
|
+
/** Read a Claude Code transcript for its typed lines, channel lines, answers and delivered mail (`read_claude`). */
|
|
81
|
+
export async function readClaude(path) {
|
|
82
|
+
const mail = { typed: [], answers: [], channel: [], delivered: new Map() };
|
|
83
|
+
const queued = []; // [words, index of mail.typed or null]
|
|
84
|
+
let dequeued = 0;
|
|
85
|
+
const absorbed = [];
|
|
86
|
+
const take = (waiting, text) => { const at = waiting.findIndex(([words]) => words === text); if (at < 0) return undefined; return waiting.splice(at, 1)[0][1]; };
|
|
87
|
+
const notTyped = new Set();
|
|
88
|
+
const takenIn = new Map(); // words → [times]
|
|
89
|
+
const early = []; // [words, ids, index, at]
|
|
90
|
+
const deliver = (id, at) => { if (!mail.delivered.has(id)) mail.delivered.set(id, at); };
|
|
91
|
+
const lines = (await readFile(path, 'utf8')).split('\n');
|
|
92
|
+
for (let index = 0; index < lines.length; index++) {
|
|
93
|
+
if (index && index % YIELD_LINES === 0) await yieldTurn();
|
|
94
|
+
const line = lines[index];
|
|
95
|
+
const prompt = line.includes('"promptSource"') || line.includes('"type":"user"');
|
|
96
|
+
const queue = line.includes('"queue-operation"');
|
|
97
|
+
const attachment = line.includes('"queued_command"');
|
|
98
|
+
const answer = line.includes('"stop_reason":"end_turn"') || line.includes('"stop_reason":"stop_sequence"');
|
|
99
|
+
const rendered = line.includes('cross-session-message id=') || line.includes('(message m-');
|
|
100
|
+
if (!(prompt || queue || attachment || rendered || answer)) continue;
|
|
101
|
+
let record; try { record = JSON.parse(line); } catch { continue; }
|
|
102
|
+
if (record?.isSidechain === true) continue;
|
|
103
|
+
const kind = typeof record.type === 'string' ? record.type : '';
|
|
104
|
+
let at = atMs(record.timestamp);
|
|
105
|
+
if (kind === 'queue-operation' && record.operation === 'remove' && typeof record.content === 'string') {
|
|
106
|
+
const found = early.findIndex(([words]) => words === record.content);
|
|
107
|
+
if (found >= 0) {
|
|
108
|
+
const [, ids, index] = early.splice(found, 1)[0];
|
|
109
|
+
for (const id of ids) deliver(id, at);
|
|
110
|
+
if (index !== null && index !== undefined) mail.typed[index].delivered_at_ms = at;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (!takenIn.has(record.content)) takenIn.set(record.content, []);
|
|
114
|
+
takenIn.get(record.content).push(at);
|
|
115
|
+
}
|
|
116
|
+
let waitsForRemove = false;
|
|
117
|
+
if (kind === 'attachment' && record.attachment?.type === 'queued_command') {
|
|
118
|
+
const taken = typeof record.attachment.prompt === 'string' ? takenIn.get(record.attachment.prompt)?.shift() : undefined;
|
|
119
|
+
if (taken !== undefined) at = taken; else waitsForRemove = true;
|
|
120
|
+
}
|
|
121
|
+
if (waitsForRemove) {
|
|
122
|
+
const attached = record.attachment;
|
|
123
|
+
const words = typeof attached.prompt === 'string' ? attached.prompt : '';
|
|
124
|
+
const human = attached.origin?.kind === 'human' || attached.humanTurn === true;
|
|
125
|
+
const ids = renderedMailIds(line);
|
|
126
|
+
let index = null;
|
|
127
|
+
const took = take(queued, words);
|
|
128
|
+
if (took !== undefined) {
|
|
129
|
+
if (took !== null && !human) notTyped.add(took);
|
|
130
|
+
else index = took;
|
|
131
|
+
} else if (human && words.trim()) {
|
|
132
|
+
mail.typed.push({ sent_at_ms: at, delivered_at_ms: null, withdrawn: false, text: words });
|
|
133
|
+
index = mail.typed.length - 1;
|
|
134
|
+
}
|
|
135
|
+
early.push([words, ids, index, at]);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (rendered && (kind === 'user' || kind === 'attachment')) for (const id of renderedMailIds(line)) deliver(id, at);
|
|
139
|
+
if (kind === 'assistant' && answer && ['end_turn', 'stop_sequence'].includes(record.message?.stop_reason)) {
|
|
140
|
+
const words = promptText(record.message?.content);
|
|
141
|
+
if (typeof record.uuid === 'string' && words !== null) mail.answers.push({ native_id: record.uuid, at_ms: at, text: words });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (kind === 'queue-operation' && record.operation === 'dequeue') { dequeued += 1; continue; }
|
|
145
|
+
if (kind === 'queue-operation') {
|
|
146
|
+
if (typeof record.content !== 'string') continue;
|
|
147
|
+
const words = record.content;
|
|
148
|
+
if (record.operation === 'enqueue') {
|
|
149
|
+
let index = null;
|
|
150
|
+
if (!machineMade(words) && words.trim()) { mail.typed.push({ sent_at_ms: at, delivered_at_ms: null, withdrawn: false, text: words }); index = mail.typed.length - 1; }
|
|
151
|
+
queued.push([words, index]);
|
|
152
|
+
} else if (record.operation === 'remove') {
|
|
153
|
+
const index = take(queued, words);
|
|
154
|
+
if (index === undefined) continue;
|
|
155
|
+
if (['absorbed_mid_turn', 'delivered_to_agent'].includes(record.reason)) {
|
|
156
|
+
if (index !== null) mail.typed[index].delivered_at_ms = at;
|
|
157
|
+
absorbed.push([words, index]);
|
|
158
|
+
} else if (index !== null) mail.typed[index].withdrawn = true;
|
|
159
|
+
}
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (kind === 'attachment') {
|
|
163
|
+
const attached = record.attachment ?? {};
|
|
164
|
+
const words = attached.prompt;
|
|
165
|
+
if (typeof words !== 'string' || attached.type !== 'queued_command' || !words.trim()) continue;
|
|
166
|
+
const human = attached.origin?.kind === 'human' || attached.humanTurn === true;
|
|
167
|
+
const took = take(absorbed, words);
|
|
168
|
+
if (took !== undefined) { if (took !== null && !human) notTyped.add(took); }
|
|
169
|
+
else if (human) mail.typed.push({ sent_at_ms: Math.min(atMs(attached.timestamp), at), delivered_at_ms: at, withdrawn: false, text: words });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (kind === 'user') {
|
|
173
|
+
const words = promptText(record.message?.content);
|
|
174
|
+
if (words === null) continue;
|
|
175
|
+
const human = ['typed', 'queued'].includes(record.promptSource) && record.isMeta !== true;
|
|
176
|
+
if (!human) mail.channel.push(...channelLines(words, at, at));
|
|
177
|
+
const landing = [];
|
|
178
|
+
const letGo = dequeued; dequeued = 0;
|
|
179
|
+
if (letGo === 0) { const took = take(queued, words); if (took !== undefined) landing.push(took); }
|
|
180
|
+
while (landing.length < letGo) {
|
|
181
|
+
const found = queued.findIndex(([queuedWords]) => words.includes(queuedWords));
|
|
182
|
+
if (found < 0) break;
|
|
183
|
+
landing.push(queued.splice(found, 1)[0][1]);
|
|
184
|
+
}
|
|
185
|
+
if (!landing.length && letGo > 0) for (let n = 0; n < letGo; n++) { const head = queued.shift(); if (head) landing.push(head[1]); }
|
|
186
|
+
if (landing.length) {
|
|
187
|
+
for (const index of landing) { if (index === null) continue; if (human) mail.typed[index].delivered_at_ms = at; else notTyped.add(index); }
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (human) mail.typed.push({ sent_at_ms: at, delivered_at_ms: at, withdrawn: false, text: words });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
for (const [, ids, index, at] of early) {
|
|
194
|
+
for (const id of ids) deliver(id, at);
|
|
195
|
+
if (index !== null && index !== undefined) mail.typed[index].delivered_at_ms ??= at;
|
|
196
|
+
}
|
|
197
|
+
for (const [words] of queued) if (words.startsWith('[channel: ')) mail.channel.push(...channelLines(words, 0, null));
|
|
198
|
+
mail.typed = mail.typed.filter((_, index) => !notTyped.has(index));
|
|
199
|
+
return mail;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Mail ids rendered into a Codex rollout line, its quote escaped once or more. */
|
|
203
|
+
function codexRenderedMailIds(line) {
|
|
204
|
+
const ids = [];
|
|
205
|
+
const mark = 'cross-session-message id=';
|
|
206
|
+
for (let at = line.indexOf(mark); at >= 0; at = line.indexOf(mark, at + 1)) {
|
|
207
|
+
const rest = line.slice(at + mark.length).replace(/^[\\"]+/, '');
|
|
208
|
+
const id = /^[A-Za-z0-9-]*/.exec(rest)[0];
|
|
209
|
+
if (id.startsWith('m-')) ids.push(id);
|
|
210
|
+
}
|
|
211
|
+
return ids;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Final answers and delivered mail from a Codex session's own transcript (`read_codex_answers`). */
|
|
215
|
+
export async function readCodex(path) {
|
|
216
|
+
const mail = { typed: [], answers: [], channel: [], delivered: new Map() };
|
|
217
|
+
const lines = (await readFile(path, 'utf8')).split('\n');
|
|
218
|
+
for (let index = 0; index < lines.length; index++) {
|
|
219
|
+
if (index && index % YIELD_LINES === 0) await yieldTurn();
|
|
220
|
+
const line = lines[index];
|
|
221
|
+
const rendered = line.includes('cross-session-message id=');
|
|
222
|
+
if (!rendered && !line.includes('"final_answer"')) continue;
|
|
223
|
+
let record; try { record = JSON.parse(line); } catch { continue; }
|
|
224
|
+
const item = record?.payload ?? {};
|
|
225
|
+
if (rendered && record.type === 'response_item' && ((item.type === 'message' && item.role === 'user') || ['function_call_output', 'custom_tool_call_output'].includes(item.type))) {
|
|
226
|
+
const at = atMs(record.timestamp);
|
|
227
|
+
for (const id of codexRenderedMailIds(line)) if (!mail.delivered.has(id)) mail.delivered.set(id, at);
|
|
228
|
+
}
|
|
229
|
+
if (record.type !== 'response_item' || item.type !== 'message' || item.role !== 'assistant' || item.phase !== 'final_answer' || typeof item.id !== 'string') continue;
|
|
230
|
+
const words = (Array.isArray(item.content) ? item.content : []).filter((part) => part?.type === 'output_text' && typeof part.text === 'string').map((part) => part.text).join('\n');
|
|
231
|
+
if (words) mail.answers.push({ native_id: item.id, at_ms: atMs(record.timestamp), text: words });
|
|
232
|
+
}
|
|
233
|
+
return mail;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The transcript of the session at `address` on this machine, when its harness's records are read here. */
|
|
237
|
+
export function transcriptPath(address, { claudeProjects, mailRoot }) {
|
|
238
|
+
const [, , harness, sessionId] = String(address).split(':');
|
|
239
|
+
if (harness === 'codex') {
|
|
240
|
+
try { return JSON.parse(readFileSync(join(mailRoot, 'native-questions', `binding-${blake3Hex(address)}.json`), 'utf8')).transcript_path ?? null; }
|
|
241
|
+
catch { return null; }
|
|
242
|
+
}
|
|
243
|
+
if (harness !== 'claude-code') return null;
|
|
244
|
+
const file = `${sessionId}.jsonl`;
|
|
245
|
+
for (const roots of claudeProjects) {
|
|
246
|
+
let projects = [];
|
|
247
|
+
try { projects = readdirSync(roots); } catch { continue; }
|
|
248
|
+
for (const project of projects) { const path = join(roots, project, file); try { if (statSync(path).isFile()) return path; } catch { /* not here */ } }
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The transcript's reading of the session at `address`, when it can be read (`mail_transcript::read`). */
|
|
254
|
+
export async function readTranscript(address, homes) {
|
|
255
|
+
const path = transcriptPath(address, homes);
|
|
256
|
+
if (!path) return null;
|
|
257
|
+
try { return await (String(address).split(':')[2] === 'codex' ? readCodex(path) : readClaude(path)); } catch { return null; }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Where each of `stored` (the mailbox of the session at `address`) stands, by id, as the transcript reading `mail`
|
|
261
|
+
* shows it (`deliveries`). `deliveredAt(envelope)` is a native question reply's own delivery. */
|
|
262
|
+
export function deliveries(address, stored, mail, deliveredAt) {
|
|
263
|
+
const result = new Map();
|
|
264
|
+
if (!mail) {
|
|
265
|
+
for (const { envelope } of stored) { const at = deliveredAt(envelope); result.set(envelope.id, at !== null ? { state: 'delivered', at_ms: at } : { state: 'unknown' }); }
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
const typed = new Map(mail.typed.map((line) => [typedLineId(address, line.sent_at_ms, line.text), line]));
|
|
269
|
+
const channel = new Map(mail.channel.map((line) => [channelLineId(address, line.header), line]));
|
|
270
|
+
const used = new Set();
|
|
271
|
+
const short = (id) => { const at = id.indexOf('-'); return at >= 0 && id.length - at - 1 > 8 ? id.slice(0, at + 9) : id; };
|
|
272
|
+
for (const { envelope } of stored) {
|
|
273
|
+
const line = typed.get(envelope.id);
|
|
274
|
+
if (line?.withdrawn) { result.set(envelope.id, { state: 'withdrawn' }); continue; }
|
|
275
|
+
let at;
|
|
276
|
+
if (line) at = line.delivered_at_ms;
|
|
277
|
+
else if (channel.has(envelope.id)) at = channel.get(envelope.id).delivered_at_ms;
|
|
278
|
+
else if (mail.delivered.has(envelope.id) || mail.delivered.has(short(envelope.id))) at = mail.delivered.get(envelope.id) ?? mail.delivered.get(short(envelope.id));
|
|
279
|
+
else if (envelope.kind === 'user' && typeof envelope.in_reply_to === 'string' && envelope.in_reply_to.startsWith('q-')) at = deliveredAt(envelope);
|
|
280
|
+
else if (envelope.kind === 'user') {
|
|
281
|
+
const index = mail.typed.findIndex((typedLine, n) => !used.has(n) && typedLine.sent_at_ms + 1000 >= envelope.created_at_ms && typedLine.text.trim() === String(envelope.body ?? '').trim());
|
|
282
|
+
if (index >= 0) { used.add(index); at = mail.typed[index].delivered_at_ms; }
|
|
283
|
+
}
|
|
284
|
+
result.set(envelope.id, at !== null && at !== undefined ? { state: 'delivered', at_ms: at } : { state: 'sent' });
|
|
285
|
+
}
|
|
286
|
+
return result;
|
|
287
|
+
}
|
package/mail/verbs.mjs
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// An agent's verbs (docs/architecture/overview.md, "The mail operations", 3b-2): reading a conversation (`message
|
|
2
|
+
// thread`), listing an agent's threads (`message threads`), handing a root to a new session of the agent (`message
|
|
3
|
+
// delegate`), and declaring or showing an agent (`supercode agent declare|show`), answered by the daemon as
|
|
4
|
+
// crates/cli/src/message.rs answered them, their texts kept. Each read answers from the header index; a body is read from
|
|
5
|
+
// its one file when its message is shown.
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { randomBytes } from 'node:crypto';
|
|
8
|
+
import { realpathSync } from 'node:fs';
|
|
9
|
+
import { listed, messageRow, rfc3339, shortId } from './envelope.mjs';
|
|
10
|
+
import { makeAddress, parseAddress, threadOf } from './compose.mjs';
|
|
11
|
+
|
|
12
|
+
export const EXIT_REFUSED = 2;
|
|
13
|
+
export const EXIT_UNKNOWN = 3;
|
|
14
|
+
export const EXIT_FAILED = 5;
|
|
15
|
+
|
|
16
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
|
+
|
|
18
|
+
export class AgentVerbs {
|
|
19
|
+
/** `machine` this machine's name; `store` the mail store; `agents` an agent's records; `live` the live-session map;
|
|
20
|
+
* `supercodeBin` the native binary (`supercode open` starts a session in a pane); `paneOf(session)` the daemon pane a
|
|
21
|
+
* live session runs in; `mailRoot` the mail folder. */
|
|
22
|
+
constructor({ machine, store, agents, live, supercodeBin, env = process.env, paneOf, mailRoot }) {
|
|
23
|
+
Object.assign(this, { machine, store, agents, live, supercodeBin, env, paneOf, mailRoot });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#whole(stored) { const envelope = this.store.body(stored); return envelope ? { ...stored, envelope } : null; }
|
|
27
|
+
|
|
28
|
+
// ---- message thread -------------------------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The conversation a message belongs to, oldest first (message.rs `thread`): every message here is a node, a reply's
|
|
32
|
+
* `in_reply_to` its edge to what it answers, and a stored thread id joins a message whose parent is not filed here.
|
|
33
|
+
* Answers `{ code, rows, rendered }` or `{ code, text }`.
|
|
34
|
+
*/
|
|
35
|
+
thread(id) {
|
|
36
|
+
const all = new Map(); // id → { address, stored }
|
|
37
|
+
for (const address of this.store.addresses()) {
|
|
38
|
+
for (const stored of this.store.inventory(address).stored) if (!all.has(stored.envelope.id)) all.set(stored.envelope.id, { address, stored });
|
|
39
|
+
}
|
|
40
|
+
const matches = [...all.keys()].filter((key) => key.startsWith(id));
|
|
41
|
+
if (matches.length !== 1) return { code: EXIT_UNKNOWN, text: `${id} names ${matches.length} messages here; give one message's id.` };
|
|
42
|
+
// up to the root: the oldest ancestor filed here
|
|
43
|
+
let root = matches[0];
|
|
44
|
+
const seen = new Set();
|
|
45
|
+
for (;;) {
|
|
46
|
+
const parent = all.get(root)?.stored.envelope.in_reply_to;
|
|
47
|
+
if (!parent || !all.has(parent) || seen.has(parent)) break;
|
|
48
|
+
seen.add(parent); root = parent;
|
|
49
|
+
}
|
|
50
|
+
const threadOfRoot = threadOf(all.get(root).stored.envelope);
|
|
51
|
+
// down from the root: every reply, and every message naming the same thread
|
|
52
|
+
const children = new Map();
|
|
53
|
+
for (const [key, { stored }] of all) {
|
|
54
|
+
const parent = stored.envelope.in_reply_to;
|
|
55
|
+
if (parent) children.set(parent, [...(children.get(parent) ?? []), key]);
|
|
56
|
+
}
|
|
57
|
+
const members = new Set();
|
|
58
|
+
const queue = [root, ...[...all].filter(([, { stored }]) => stored.envelope.thread === threadOfRoot).map(([key]) => key)];
|
|
59
|
+
while (queue.length) {
|
|
60
|
+
const next = queue.pop();
|
|
61
|
+
if (members.has(next)) continue;
|
|
62
|
+
members.add(next);
|
|
63
|
+
queue.push(...(children.get(next) ?? []));
|
|
64
|
+
}
|
|
65
|
+
const conversation = [...members].map((key) => all.get(key)).map(({ address, stored }) => ({ address, stored: this.#whole(stored) })).filter((item) => item.stored)
|
|
66
|
+
.sort((a, b) => a.stored.envelope.created_at_ms - b.stored.envelope.created_at_ms);
|
|
67
|
+
const deliveries = new Map();
|
|
68
|
+
return {
|
|
69
|
+
code: 0,
|
|
70
|
+
rows: conversation.map(({ address, stored }) => messageRow(address, stored, deliveries, this.mailRoot)),
|
|
71
|
+
rendered: conversation.map(({ address, stored }) => `To ${address}:\n${listed(stored.envelope, deliveries, this.mailRoot)}\n`),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---- message threads ------------------------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/** An agent's threads, oldest first (message.rs `threads`): the agent named, else the caller's own agent. */
|
|
78
|
+
threads(agent, caller) {
|
|
79
|
+
let name = agent;
|
|
80
|
+
if (!name) {
|
|
81
|
+
name = caller ? this.agents.agentOfSession(caller)?.name : null;
|
|
82
|
+
if (!name) return { code: EXIT_REFUSED, text: 'Name the agent with --agent: this session is not one of an agent\'s.' };
|
|
83
|
+
}
|
|
84
|
+
const threads = this.agents.threadsOf(name);
|
|
85
|
+
// every root's first line from the index in one pass: no walk of the index per thread, no body read
|
|
86
|
+
const roots = this.store.firstLines(threads.map((thread) => thread.id));
|
|
87
|
+
const rows = threads.map((thread) => ({
|
|
88
|
+
id: thread.id, holder: thread.holder,
|
|
89
|
+
participants: thread.participants.map((p) => ({ address: p.address, role: p.role })),
|
|
90
|
+
surface: thread.surface ?? null, opened_at: rfc3339(thread.created_at_ms),
|
|
91
|
+
root: roots.has(thread.id) ? roots.get(thread.id) : null,
|
|
92
|
+
}));
|
|
93
|
+
const text = rows.length
|
|
94
|
+
? rows.map((row) => `${shortId(row.id)} opened ${row.opened_at} held by ${row.holder}\n ${row.participants.map((p) => `${p.address} (${p.role})`).join(', ')}\n ${row.root ?? '(no root message)'}`).join('\n')
|
|
95
|
+
: `Agent ${name} has no threads.`;
|
|
96
|
+
return { code: 0, rows, text };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---- a new session of an agent ----------------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Start `program` in a new pane in `directory`, typed `input` once ready, and answer the session it runs (message.rs
|
|
103
|
+
* `open_session`): a Claude session is given its id up front, so `ready(address)` runs before it starts; another is
|
|
104
|
+
* found in the live-session map by the pane it opened in, within a minute.
|
|
105
|
+
*/
|
|
106
|
+
async openSession(program, directory, key, input, ready) {
|
|
107
|
+
const stem = String(program).split(/[\\/]/).pop().replace(/\.[^.]*$/, '');
|
|
108
|
+
const claude = stem === 'claude' || stem === 'claude-code';
|
|
109
|
+
let known = null;
|
|
110
|
+
const extra = [];
|
|
111
|
+
if (claude) {
|
|
112
|
+
const bytes = randomBytes(16);
|
|
113
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
114
|
+
const hex = bytes.toString('hex');
|
|
115
|
+
const id = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
116
|
+
known = makeAddress(this.machine, 'claude-code', id);
|
|
117
|
+
extra.push('--', '--session-id', id);
|
|
118
|
+
await ready(known);
|
|
119
|
+
}
|
|
120
|
+
const run = await new Promise((resolve) => {
|
|
121
|
+
const child = spawn(this.supercodeBin, ['--cwd', directory, 'open', '--new', program, '--key', key, '--input', input, '--detach', ...extra], { stdio: ['ignore', 'pipe', 'pipe'], env: this.env });
|
|
122
|
+
let out = '', err = '';
|
|
123
|
+
child.stdout.on('data', (chunk) => { out += chunk; });
|
|
124
|
+
child.stderr.on('data', (chunk) => { err += chunk; });
|
|
125
|
+
child.on('error', (error) => resolve({ code: 1, out, err: error.message }));
|
|
126
|
+
child.on('close', (code) => resolve({ code, out, err }));
|
|
127
|
+
});
|
|
128
|
+
if (run.code !== 0) throw new Error(`the session did not start: ${run.err.trim()}`);
|
|
129
|
+
if (known) return known;
|
|
130
|
+
let pane = null;
|
|
131
|
+
try { pane = JSON.parse(run.out).pane; } catch { /* not reported */ }
|
|
132
|
+
if (typeof pane !== 'string') throw new Error('the new pane was not reported');
|
|
133
|
+
const started = Date.now();
|
|
134
|
+
while (Date.now() - started < 60_000) {
|
|
135
|
+
const session = this.live.sessions().find((row) => this.paneOf(row) === pane);
|
|
136
|
+
if (session) { await ready(session.address); return session.address; }
|
|
137
|
+
await sleep(500);
|
|
138
|
+
}
|
|
139
|
+
throw new Error(`${program} started in pane ${pane}, but no session appeared there within a minute`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Hand a root the caller received as an agent's main session to a new session of the agent (message.rs `delegate`):
|
|
144
|
+
* it opens in a pane with the root, holds the thread (every reply wakes it), and main stays CC. Answers `{ code, text }`.
|
|
145
|
+
*/
|
|
146
|
+
async delegate(caller, root, { folder = null, harness = null } = {}) {
|
|
147
|
+
const found = this.store.findPrefixIn(caller, root);
|
|
148
|
+
if (found.length !== 1) return { code: EXIT_REFUSED, text: `Not delegated: ${root} names ${found.length} messages in your mailbox. Name the root as your inbox shows it.` };
|
|
149
|
+
const stored = found[0].envelope;
|
|
150
|
+
const threadId = threadOf(stored);
|
|
151
|
+
// a line in main's own session (its terminal, its DM) is a root of the agent: main opens its thread to delegate it
|
|
152
|
+
const thread = this.agents.thread(threadId) ?? await this.agents.rootOfMain(caller, stored);
|
|
153
|
+
if (!thread) return { code: EXIT_REFUSED, text: `Not delegated: ${shortId(stored.id)} is not in a thread of an agent. Only a root an agent received can be delegated.` };
|
|
154
|
+
const agent = this.agents.load(thread.agent);
|
|
155
|
+
if (!agent) return { code: EXIT_REFUSED, text: `Not delegated: agent ${thread.agent} is no longer declared.` };
|
|
156
|
+
if (agent.main_session !== caller || thread.holder !== caller) return { code: EXIT_REFUSED, text: `Not delegated: only ${agent.name}'s main session delegates a thread it holds.` };
|
|
157
|
+
const program = harness ?? agent.harness ?? 'claude';
|
|
158
|
+
const directory = folder ?? agent.folder ?? null;
|
|
159
|
+
if (!directory) return { code: EXIT_FAILED, text: 'Not delegated: no directory to start the session in (give --folder, or declare the agent with one).' };
|
|
160
|
+
const shownRoot = shortId(thread.id);
|
|
161
|
+
const input = `You are a session of agent ${agent.name}, holding its thread ${shownRoot}. Read it: supercode message thread ${shownRoot}. Answer in it with supercode message reply <id>; what you send stays in this thread, and ${agent.name}'s main session is CC on it.`;
|
|
162
|
+
// the new session holds the thread and has its messages before it starts, so its first turn finds its root
|
|
163
|
+
const handOver = async (holder) => {
|
|
164
|
+
for (const line of this.store.inventory(caller).stored) {
|
|
165
|
+
if (threadOf(line.envelope) !== thread.id) continue;
|
|
166
|
+
const whole = this.#whole(line);
|
|
167
|
+
if (!whole) continue;
|
|
168
|
+
// a line typed into main's terminal reaches the holder as one typed in another session (`typed`, ADR 0008)
|
|
169
|
+
const envelope = whole.envelope.kind === 'user' ? { ...whole.envelope, kind: 'typed' } : whole.envelope;
|
|
170
|
+
this.store.deliverRead(holder, envelope);
|
|
171
|
+
}
|
|
172
|
+
await this.agents.delegateTo(thread.id, holder, true);
|
|
173
|
+
};
|
|
174
|
+
let holder;
|
|
175
|
+
try { holder = await this.openSession(program, directory, `delegate:${thread.id}`, input, handOver); }
|
|
176
|
+
catch (error) {
|
|
177
|
+
// the thread goes back to main, so nothing waits on a session that never started
|
|
178
|
+
await this.agents.delegateTo(thread.id, caller, false);
|
|
179
|
+
return { code: EXIT_FAILED, text: `Not delegated: ${error.message}` };
|
|
180
|
+
}
|
|
181
|
+
const sessionId = parseAddress(holder).session;
|
|
182
|
+
return { code: 0, text: `Delegated thread ${shownRoot} to a new session of ${agent.name} (${holder}) in ${directory}. You are CC on it. Continue there: supercode open ${sessionId}` };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---- supercode agent declare | show ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
/** Declare an agent, or replace its record (message.rs `run_agent` Declare): its main session an address, a name
|
|
188
|
+
* running here, or one started (`open`) when the declared main is neither running nor resumable. */
|
|
189
|
+
async declare({ name, main = null, open = null, input = null, folder = null, harness = null, idle_minutes = null, owners_account_manager = false }) {
|
|
190
|
+
const directory = folder ? (() => { try { return realpathSync(folder); } catch { return null; } })() : null;
|
|
191
|
+
if (folder && !directory) return { code: 1, text: `${folder}: no such directory` };
|
|
192
|
+
let mainSession = main;
|
|
193
|
+
if (!mainSession) {
|
|
194
|
+
if (!open) return { code: EXIT_REFUSED, text: 'Name its main session with --main, or start one with --open.' };
|
|
195
|
+
const kept = this.agents.load(name)?.main_session ?? null;
|
|
196
|
+
const live = kept && this.live.sessions().some((row) => row.address === kept);
|
|
197
|
+
if (kept && (live || await this.agents.resumable(kept))) mainSession = kept;
|
|
198
|
+
else {
|
|
199
|
+
const first = input ?? `You are the main session of agent ${name}. Roots addressed to it reach you: answer each or delegate it (supercode message delegate).`;
|
|
200
|
+
try { mainSession = await this.openSession(open, directory ?? process.cwd(), `agent:${name}`, first, async () => {}); }
|
|
201
|
+
catch (error) { return { code: EXIT_FAILED, text: `Not declared: ${error.message}` }; }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (!parseAddress(mainSession)) {
|
|
205
|
+
const sessions = this.live.sessions();
|
|
206
|
+
const wanted = mainSession.split('@')[0];
|
|
207
|
+
const matching = sessions.filter((row) => row.name.split('@')[0] === wanted);
|
|
208
|
+
if (matching.length !== 1) return { code: EXIT_UNKNOWN, text: matching.length ? `${mainSession} names ${matching.length} sessions here; give its address.` : `No session named "${mainSession}" is reachable. Run supercode message list. Nothing was sent.` };
|
|
209
|
+
mainSession = matching[0].address;
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
this.agents.declare({ name, main_session: mainSession, folder: directory, harness, idle_minutes });
|
|
213
|
+
if (owners_account_manager) this.agents.setOwnersAccountManager(name);
|
|
214
|
+
} catch (error) { return { code: 1, text: error.message }; }
|
|
215
|
+
return { code: 0, text: `Declared agent ${name} (${makeAddress(this.machine, 'agent', name)}): roots go to ${mainSession}.${owners_account_manager ? ' It is the owner\'s account manager.' : ''}` };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** One declared agent, or all of them (message.rs `run_agent` Show). */
|
|
219
|
+
show(name) {
|
|
220
|
+
let agents;
|
|
221
|
+
if (name) {
|
|
222
|
+
const agent = this.agents.load(name);
|
|
223
|
+
if (!agent) return { code: EXIT_UNKNOWN, text: `No agent named ${name} is declared on this machine.` };
|
|
224
|
+
agents = [agent];
|
|
225
|
+
} else agents = this.agents.all();
|
|
226
|
+
const manager = this.agents.ownersAccountManager()?.name ?? null;
|
|
227
|
+
const text = agents.length
|
|
228
|
+
? agents.map((agent) => `${agent.name} ${makeAddress(this.machine, 'agent', agent.name)}\n main: ${agent.main_session}\n folder: ${agent.folder ?? '(none)'}\n threads: ${this.agents.threadsOf(agent.name).length}${manager === agent.name ? '\n the owner\'s account manager' : ''}`).join('\n')
|
|
229
|
+
: 'No agents are declared on this machine.';
|
|
230
|
+
return { code: 0, agents, text };
|
|
231
|
+
}
|
|
232
|
+
}
|
package/mail-history.mjs
CHANGED
|
@@ -67,7 +67,7 @@ export function machineName(env) {
|
|
|
67
67
|
export async function readMailHistory({ mail, env, harness, session }) {
|
|
68
68
|
if (!/^[a-z0-9-]+$/.test(harness) || !/^[a-zA-Z0-9_-]+$/.test(session)) throw new Error('invalid session address');
|
|
69
69
|
const address = `sc:${machineName(env)}:${harness}:${session}`;
|
|
70
|
-
const rows = mail.inboxRows(address, 100).filter(row => ['peer', 'channel'].includes(row.envelope?.kind));
|
|
70
|
+
const rows = (await mail.inboxRows(address, 100)).filter(row => ['peer', 'channel'].includes(row.envelope?.kind));
|
|
71
71
|
const [sessions, answered] = await Promise.all([sessionDirectory(mail), answeredMail(rows, mail)]);
|
|
72
72
|
return mailActors(rows, sessions, address, answered);
|
|
73
73
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.87",
|
|
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",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"server",
|
|
52
52
|
"worker",
|
|
53
53
|
"connector",
|
|
54
|
+
"mail",
|
|
54
55
|
"contracts",
|
|
55
56
|
"remote",
|
|
56
57
|
"README.md"
|