@volter/supercode-teams 0.3.159 → 0.3.161
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/connector/index.mjs +2 -2
- package/live-sessions.mjs +12 -3
- package/machine-records.mjs +113 -45
- package/machine.mjs +75 -63
- package/mail/carrier.mjs +202 -157
- package/mail/change-stream.mjs +143 -0
- package/mail/delivery-status.mjs +42 -0
- package/mail/hooks.mjs +90 -15
- package/mail/reads.mjs +2 -1
- package/mail/relay-routing.mjs +41 -0
- package/mail/relay.mjs +120 -204
- package/mail/send.mjs +56 -32
- package/mail/sqlite-store.mjs +166 -16
- package/mail/sqlite.mjs +42 -4
- package/package.json +1 -1
- package/record-state.mjs +143 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Filing and a terminal queue are not native acceptance. These facts survive
|
|
2
|
+
// a daemon restart, so callers never infer delivery from a retained paste.
|
|
3
|
+
import { envelope, makeAddress, messageIdFor, parseAddress, senderIdentity, threadOf } from './compose.mjs';
|
|
4
|
+
|
|
5
|
+
export function terminalReceipt(store, address, id) {
|
|
6
|
+
const parsed = parseAddress(address);
|
|
7
|
+
if (!parsed) return null;
|
|
8
|
+
const state = store.terminalState(`${parsed.harness}:${parsed.session}`);
|
|
9
|
+
if (!state) return null;
|
|
10
|
+
const settled = state.receipts?.find(item => item.message_ids?.includes(id))
|
|
11
|
+
?? state.refusals?.find(item => item.message_ids?.includes(id));
|
|
12
|
+
return settled ?? (state.accepted?.[id]
|
|
13
|
+
? { delivered: false, queued: true, message_ids: [id], reason: state.active?.error ?? 'awaiting native transcript confirmation' } : null);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function paneWaitReason(answer) {
|
|
17
|
+
return answer?.waits?.reason ?? answer?.refused?.reason ?? answer?.reason ?? 'pane input was not accepted';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Stop the attempt and file one notice in the same commit. Only a locally
|
|
21
|
+
* observed original sender earns an automatic return; link claims do not. */
|
|
22
|
+
export function expireDelivery(store, machine, address, id, attempts, reason) {
|
|
23
|
+
const found = store.findIn(address, id);
|
|
24
|
+
const sent = found ? store.body(found) : null;
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
const expiry = { id, to: address, from: sent?.from ?? '', subject: sent?.subject ?? null,
|
|
27
|
+
filed_at_ms: Number(sent?.created_at_ms) || 0, expired_at_ms: now, attempts, reason };
|
|
28
|
+
store.transaction(() => {
|
|
29
|
+
store.expire(address, id, expiry);
|
|
30
|
+
if (sent?.kind !== 'peer' || parseAddress(sent.from)?.machine !== machine
|
|
31
|
+
|| sent.sender_identity?.classification !== 'local' || sent.sender_identity?.observation !== `local:${machine}`) return;
|
|
32
|
+
const from = makeAddress(machine, 'operator', 'mail-delivery');
|
|
33
|
+
const notice = { ...envelope({ id: messageIdFor(from, `delivery-stopped\0${address}\0${id}`), created_at_ms: now,
|
|
34
|
+
from, from_name: 'supercode mail delivery', sender_identity: senderIdentity(from, null, machine),
|
|
35
|
+
kind: 'notice', reply_via: { mode: 'none' }, in_reply_to: id, thread: threadOf(sent), subject: 'Mail delivery stopped',
|
|
36
|
+
body: `Delivery of ${id} to ${address} stopped: ${reason}. Native acceptance was not confirmed. The original message remains unread in its mailbox; it was not discarded. Run supercode message show ${id} or supercode message expired for its recorded status. Do not resend it as a new message.` }),
|
|
37
|
+
delivery_failure_notice: { message_id: id, recipient: address } };
|
|
38
|
+
store.deliver(sent.from, notice);
|
|
39
|
+
store.requestWake(sent.from, notice.id, { keep: true });
|
|
40
|
+
});
|
|
41
|
+
return expiry;
|
|
42
|
+
}
|
package/mail/hooks.mjs
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
import { readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { dirname, join, resolve, sep } from 'node:path';
|
|
12
12
|
import { envelope as makeEnvelope, makeAddress, newMessageId, parseAddress } from './compose.mjs';
|
|
13
|
-
import { blake3Hex } from './envelope.mjs';
|
|
13
|
+
import { blake3Hex, shortId } from './envelope.mjs';
|
|
14
|
+
import { readReceipt } from './relay.mjs';
|
|
15
|
+
import { settleRelayAttempt, uniqueRelayDestination } from './relay-routing.mjs';
|
|
14
16
|
import { typedLineId } from './transcript.mjs';
|
|
15
17
|
|
|
16
18
|
const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
|
|
@@ -36,11 +38,19 @@ export class MailHooks {
|
|
|
36
38
|
Object.assign(this, { machine, mailRoot, store, live, handOver, threadOf, log });
|
|
37
39
|
this.stopped = false;
|
|
38
40
|
this.waiting = new Set();
|
|
41
|
+
this.hookViews = new Map(); // address → last change cursor and unread summary
|
|
39
42
|
// Windows: each Codex session's last hook (the process it ran under, the rollout it named, when), which serve's
|
|
40
43
|
// Codex listing reads through this daemon (codex_peer.rs `hook_recorded_rollouts`); kept beside this daemon's state,
|
|
41
44
|
// never in the mail folder (mail step 1c)
|
|
42
45
|
this.codexHooksFile = stateDir ? join(stateDir, 'codex-hooks.json') : null;
|
|
43
46
|
this.codexHooks = new Map(Object.entries((this.codexHooksFile && readJson(this.codexHooksFile)) || {}));
|
|
47
|
+
this.poolDrain = false;
|
|
48
|
+
this.onPoolRecord = ({ collection }) => {
|
|
49
|
+
if (this.stopped || this.poolDrain || !['relay_routes', 'relay_pending', 'relays'].includes(collection)) return;
|
|
50
|
+
this.poolDrain = true;
|
|
51
|
+
setImmediate(() => { this.poolDrain = false; if (!this.stopped) { try { this.drainPoolReplies(); } catch (error) { this.log(`pool replies retained: ${error.message}`); } } });
|
|
52
|
+
};
|
|
53
|
+
this.store.database.on('record', this.onPoolRecord);
|
|
44
54
|
this.relaysRoots = [resolve(mailRoot, 'relays'), resolve(dirname(mailRoot), 'relays')];
|
|
45
55
|
}
|
|
46
56
|
|
|
@@ -65,10 +75,17 @@ export class MailHooks {
|
|
|
65
75
|
return null;
|
|
66
76
|
}
|
|
67
77
|
if (event !== 'PostToolUse' && !(event === 'Stop' && input.stop_hook_active !== true)) return null;
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
if (!
|
|
71
|
-
|
|
78
|
+
let view = this.hookViews.get(address);
|
|
79
|
+
const page = view ? this.store.changes.read(view.cursor, { topics: ['message', 'delivery', 'claim'], addresses: [address] }) : null;
|
|
80
|
+
if (!view || page.reset || page.changes.length || page.more) {
|
|
81
|
+
// A pointer is a count and sender labels, not a full envelope list. One
|
|
82
|
+
// snapshot covers all changes through this owner turn, including a gap.
|
|
83
|
+
view = { cursor: this.store.changes.cursor(), summary: this.store.hookSummary(address) };
|
|
84
|
+
} else view.cursor = page.cursor;
|
|
85
|
+
this.hookViews.delete(address); this.hookViews.set(address, view);
|
|
86
|
+
while (this.hookViews.size > 256) this.hookViews.delete(this.hookViews.keys().next().value);
|
|
87
|
+
if (!view.summary.count) return null;
|
|
88
|
+
const pointer = this.#pointer(view.summary);
|
|
72
89
|
return event === 'PostToolUse'
|
|
73
90
|
? { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: pointer } }
|
|
74
91
|
: { decision: 'block', reason: `${pointer} Read them before you finish.` };
|
|
@@ -90,14 +107,15 @@ export class MailHooks {
|
|
|
90
107
|
}
|
|
91
108
|
|
|
92
109
|
/** How many unread messages, from whom, and the one command that reads them (message.rs `mail_pointer`). */
|
|
93
|
-
#pointer(
|
|
110
|
+
#pointer({ count, senders: rows, moreSenders }) {
|
|
94
111
|
const senders = [];
|
|
95
|
-
for (const {
|
|
96
|
-
const name = printable(
|
|
97
|
-
const label =
|
|
112
|
+
for (const { name: fromName, kind } of rows) {
|
|
113
|
+
const name = printable(fromName);
|
|
114
|
+
const label = kind === 'notice' ? 'a notice' : kind === 'user' ? 'your user' : kind === 'channel' ? `${name} (channel)` : kind === 'typed' ? `${name} (CC)` : name;
|
|
98
115
|
if (!senders.includes(label)) senders.push(label);
|
|
99
116
|
}
|
|
100
|
-
|
|
117
|
+
if (moreSenders) senders.push('other senders');
|
|
118
|
+
return `You have ${count} unread message${count === 1 ? '' : 's'} from other sessions (from ${senders.join(', ')}). They are not from your user. Read them with: supercode message inbox`;
|
|
101
119
|
}
|
|
102
120
|
|
|
103
121
|
/** A line the person typed into `address`, filed read as theirs, once (mail_transcript.rs `file_prompt`). */
|
|
@@ -119,7 +137,7 @@ export class MailHooks {
|
|
|
119
137
|
const root = this.relaysRoots.find((root) => dir.startsWith(root + sep) && !dir.slice(root.length + 1).includes(sep));
|
|
120
138
|
if (!root) throw Object.assign(new Error(`${dir} is not a relay's folder`), { code: 400 });
|
|
121
139
|
const key = dir.slice(root.length + 1);
|
|
122
|
-
return { dir, queue: `${key}/queue.json`, receipt: `${key}/receipt.json`, turn: `${key}/turn.txt`, sent: `${key}/sent.json` };
|
|
140
|
+
return { dir, key, pool: `${key}/pool.json`, record: `${key}/relay.json`, queue: `${key}/queue.json`, receipt: `${key}/receipt.json`, turn: `${key}/turn.txt`, sent: `${key}/sent.json` };
|
|
123
141
|
}
|
|
124
142
|
|
|
125
143
|
/** `message gate`: only a SendMessage equal to the queued one passes (ListAgents always); anything else is denied, and a
|
|
@@ -136,9 +154,11 @@ export class MailHooks {
|
|
|
136
154
|
/** `message relay-receipt`: Claude's own answer to the queued send, its error, or that the turn ended without one. */
|
|
137
155
|
receipt(directory, input) {
|
|
138
156
|
const paths = this.#relay(directory);
|
|
139
|
-
|
|
157
|
+
const queued = this.store.record('relays', paths.queue);
|
|
158
|
+
if (!queued || this.store.record('relays', paths.receipt)) return null;
|
|
140
159
|
const event = input?.hook_event_name;
|
|
141
160
|
let receipt;
|
|
161
|
+
if ((event === 'PostToolUse' || event === 'PostToolUseFailure') && (input.tool_name !== 'SendMessage' || gateDecision(input, queued) != null)) return null;
|
|
142
162
|
if (event === 'PostToolUse') receipt = { tool_response: input.tool_response ?? null };
|
|
143
163
|
else if (event === 'PostToolUseFailure') receipt = { send_failed: typeof input.error === 'string' ? input.error : input.error == null ? '' : JSON.stringify(input.error) };
|
|
144
164
|
else if (event === 'Stop' || event === 'StopFailure') {
|
|
@@ -150,7 +170,10 @@ export class MailHooks {
|
|
|
150
170
|
const said = event === 'StopFailure' ? `${text('error_details') ?? text('last_assistant_message') ?? ''} (${text('error') ?? 'error'})` : text('last_assistant_message');
|
|
151
171
|
receipt = { turn_ended: true, said: said == null ? null : Array.from(said).slice(0, 300).join('') };
|
|
152
172
|
} else return null;
|
|
153
|
-
this.store.
|
|
173
|
+
this.store.transaction(() => {
|
|
174
|
+
this.store.putRecord('relays', paths.receipt, receipt, { create: true });
|
|
175
|
+
settleRelayAttempt(this.store, queued, readReceipt(receipt));
|
|
176
|
+
});
|
|
154
177
|
return null;
|
|
155
178
|
}
|
|
156
179
|
|
|
@@ -172,6 +195,14 @@ export class MailHooks {
|
|
|
172
195
|
// the replying Claude session runs beside the relay, on this machine
|
|
173
196
|
const sender = this.live.sessions().find((row) => row.door === 'native' && row.socket && row.socket === event.from.replace(/^uds:/, ''));
|
|
174
197
|
const sessionId = sender ? sender.address.split(':').slice(3).join(':') : null;
|
|
198
|
+
// A spooled input can arrive between handle retirement and the next
|
|
199
|
+
// successful start. Its durable pool identity still selects the route
|
|
200
|
+
// ledger and the writer notice, never the legacy operator inbox path.
|
|
201
|
+
if (this.store.record('relays', paths.pool)?.pool === true || this.store.record('relays', paths.record)?.pool === true) {
|
|
202
|
+
const incoming = { represented, id, event, sender: sender ? { address: sender.address, name: sender.name, sessionId } : null };
|
|
203
|
+
if (!this.#routePoolReply(incoming)) this.store.putRecord('relay_pending', id, incoming, { create: true });
|
|
204
|
+
return { decision: 'block', reason: 'native reply processed by the shared pool; ambiguous replies are undelivered and the writer is notified through its own door' };
|
|
205
|
+
}
|
|
175
206
|
const lastSent = this.store.record('relays', paths.sent) ?? {};
|
|
176
207
|
const inReplyTo = sessionId ? lastSent[sessionId] ?? null : null;
|
|
177
208
|
this.#fileHome(represented, makeEnvelope({ id, from: makeAddress(this.machine, 'claude-code', sessionId ?? 'unknown'),
|
|
@@ -185,6 +216,49 @@ export class MailHooks {
|
|
|
185
216
|
return INBOUND_BLOCK;
|
|
186
217
|
}
|
|
187
218
|
|
|
219
|
+
/** A reply during a native send waits for its receipt, not for a guess about
|
|
220
|
+
* the intended origin. Deferred inputs survive a daemon restart. */
|
|
221
|
+
drainPoolReplies() {
|
|
222
|
+
if (this.stopped) return;
|
|
223
|
+
const rows = this.store.statement("SELECT key, record FROM mail_records WHERE collection = 'relay_pending' ORDER BY key LIMIT 256").all();
|
|
224
|
+
let routed = 0;
|
|
225
|
+
for (const row of rows) {
|
|
226
|
+
if (this.#routePoolReply(JSON.parse(row.record))) { this.store.removeRecord('relay_pending', row.key); routed++; }
|
|
227
|
+
}
|
|
228
|
+
if (routed && rows.length === 256) this.onPoolRecord({ collection: 'relay_pending' });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
#routePoolReply({ represented, id, event, sender }) {
|
|
232
|
+
const route = sender ? uniqueRelayDestination(this.store, sender.sessionId) : { origin: null, candidates: [] };
|
|
233
|
+
if (route.pending) return false;
|
|
234
|
+
const from = sender?.address ?? makeAddress(this.machine, 'claude-code', 'unknown');
|
|
235
|
+
if (route.origin) {
|
|
236
|
+
this.#fileHome(route.origin.sender, makeEnvelope({ id, from, from_name: sender.name, kind: 'peer', reply_via: { mode: 'command' },
|
|
237
|
+
in_reply_to: route.origin.id, in_reply_to_inferred: true, thread: this.threadOf(route.origin.id), native_from: event.from, body: event.body }));
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
const candidates = route.candidates.map(({ sender: address, id: message }) => ({ address, id: message }));
|
|
241
|
+
const held = { ...makeEnvelope({ id, from, from_name: sender?.name ?? event.fromName ?? 'unverified native writer', kind: 'peer',
|
|
242
|
+
reply_via: { mode: 'none' }, native_from: event.from, body: event.body }),
|
|
243
|
+
pool_reply_undelivered: { reason: sender ? 'there is no single proven sender destination' : 'the native writer is unverified', candidates } };
|
|
244
|
+
this.store.transaction(() => {
|
|
245
|
+
// Retention of the exact reply is diagnostics, never a delivery to any
|
|
246
|
+
// possible sender. The writer's notice has its own distinct message id.
|
|
247
|
+
this.store.deliver(represented, held);
|
|
248
|
+
if (sender) {
|
|
249
|
+
const noticeId = `m-${blake3Hex(`pool-undelivered\0${id}`).slice(0, 24)}`;
|
|
250
|
+
const examples = candidates.map((candidate) => `supercode message reply ${candidate.id}`).join(' or ');
|
|
251
|
+
const notice = { ...makeEnvelope({ id: noticeId, from: represented, from_name: 'supercode mail relay', kind: 'notice', reply_via: { mode: 'none' },
|
|
252
|
+
body: `Your native reply ${shortId(id)} was NOT DELIVERED to a sender: this shared relay has no single proven reply destination. None of the possible senders was marked delivered. Use supercode message reply <original-message-id>, or the send_message MCP tool with in_reply_to naming that id. ${examples ? `Known message ids: ${examples}. ` : ''}The original incoming envelope names its reply door. Do not send a plain native reply to the relay.` }),
|
|
253
|
+
pool_reply_failure_notice: { reply_id: id } };
|
|
254
|
+
this.store.deliver(sender.address, notice);
|
|
255
|
+
this.store.requestWake(sender.address, noticeId, { keep: true });
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
this.log(`pool reply ${id} undelivered: ${sender ? `writer ${sender.address}'s notice waits for its own delivery door` : 'the native writer could not be verified; exact reply retained with the refusal'}`);
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
188
262
|
/** A relay's inbound mail its hook kept while this daemon did not answer it (the front door's spool, outside the mail
|
|
189
263
|
* folder): each filed as the hook would have filed it, then let go; oldest first. */
|
|
190
264
|
drainSpool(dir) {
|
|
@@ -215,8 +289,7 @@ export class MailHooks {
|
|
|
215
289
|
#fileHome(represented, envelope) {
|
|
216
290
|
// not filed is a failed filing, never a quiet one: the hook keeps the mail (its spool), and a drain keeps its file
|
|
217
291
|
this.store.deliver(represented, envelope);
|
|
218
|
-
|
|
219
|
-
else this.store.requestWake(represented, envelope.id);
|
|
292
|
+
this.store.requestWake(represented, envelope.id, { keep: true });
|
|
220
293
|
}
|
|
221
294
|
|
|
222
295
|
// ---- `message native-question-hook <binding>` ---------------------------------------------------------------------
|
|
@@ -273,6 +346,8 @@ export class MailHooks {
|
|
|
273
346
|
|
|
274
347
|
stop() {
|
|
275
348
|
this.stopped = true;
|
|
349
|
+
this.store.database.off('record', this.onPoolRecord);
|
|
350
|
+
this.hookViews.clear();
|
|
276
351
|
for (const key of this.waiting) this.store.removeRecord('native_questions', key);
|
|
277
352
|
this.waiting.clear();
|
|
278
353
|
}
|
package/mail/reads.mjs
CHANGED
|
@@ -140,7 +140,8 @@ export class MailReads {
|
|
|
140
140
|
paneOf(session) { return this.#paneOf(session); }
|
|
141
141
|
|
|
142
142
|
#paneOf(session) {
|
|
143
|
-
const
|
|
143
|
+
const selected = session.door === 'native' ? session.delivery_pane : session.pane;
|
|
144
|
+
const pane = typeof selected === 'string' ? selected.split(':')[0] : null;
|
|
144
145
|
return pane && isDaemonPane(pane) ? pane : null;
|
|
145
146
|
}
|
|
146
147
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// A shared native identity carries no per-sender reply address. Keep at most two
|
|
2
|
+
// origins per recipient: a second origin makes ambiguity permanent. Never evict
|
|
3
|
+
// ambiguity and then infer a fresh unique origin for that same session id.
|
|
4
|
+
const COLLECTION = 'relay_routes';
|
|
5
|
+
const MAX_ROUTES = 1_000_000;
|
|
6
|
+
|
|
7
|
+
export function relayRoute(store, receiver) { return store.record(COLLECTION, receiver); }
|
|
8
|
+
|
|
9
|
+
export function beginRelayAttempt(store, queued) {
|
|
10
|
+
if (!queued.expects_reply) return;
|
|
11
|
+
const route = relayRoute(store, queued.receiver) ?? { origins: [], ambiguous: false };
|
|
12
|
+
if (!relayRoute(store, queued.receiver) && store.statement('SELECT count(*) AS count FROM mail_records WHERE collection = ?').get(COLLECTION).count >= MAX_ROUTES) {
|
|
13
|
+
throw new Error('the pool reply-address ledger reached its bound; no send was submitted');
|
|
14
|
+
}
|
|
15
|
+
if (route.pending && route.pending.id !== queued.message_id) throw new Error('the receiver has an unresolved pooled send; no send was submitted');
|
|
16
|
+
store.putRecord(COLLECTION, queued.receiver, { ...route, pending: { sender: queued.sender, id: queued.message_id } });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function settleRelayAttempt(store, queued, outcome) {
|
|
20
|
+
if (!queued?.expects_reply) return;
|
|
21
|
+
const route = relayRoute(store, queued.receiver);
|
|
22
|
+
if (!route || route.pending?.id !== queued.message_id) return;
|
|
23
|
+
const origins = [...route.origins];
|
|
24
|
+
if (outcome.delivered || outcome.unknown) {
|
|
25
|
+
const index = origins.findIndex((origin) => origin.sender === queued.sender);
|
|
26
|
+
const origin = { sender: queued.sender, id: queued.message_id, confirmed: outcome.delivered === true };
|
|
27
|
+
if (index >= 0) {
|
|
28
|
+
// An unanswered attempt never replaces the last confirmed message id.
|
|
29
|
+
if (outcome.delivered || !origins[index].confirmed) origins[index] = origin;
|
|
30
|
+
} else if (origins.length < 2) origins.push(origin);
|
|
31
|
+
route.ambiguous ||= origins.some((item) => item.sender !== queued.sender);
|
|
32
|
+
}
|
|
33
|
+
store.putRecord(COLLECTION, queued.receiver, { origins, ambiguous: route.ambiguous, pending: null });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function uniqueRelayDestination(store, receiver) {
|
|
37
|
+
const route = relayRoute(store, receiver);
|
|
38
|
+
if (route?.pending) return { pending: true, candidates: route.origins };
|
|
39
|
+
const origin = !route?.ambiguous && route?.origins.length === 1 && route.origins[0].confirmed ? route.origins[0] : null;
|
|
40
|
+
return { origin, candidates: route?.origins ?? [], ambiguous: Boolean(route?.ambiguous) };
|
|
41
|
+
}
|