@volter/supercode-teams 0.3.150 → 0.3.152
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -1
- package/bin/teams.mjs +10 -2
- package/machine.mjs +143 -46
- package/mail/agents.mjs +40 -119
- package/mail/carrier.mjs +72 -111
- package/mail/continuation.mjs +101 -0
- package/mail/envelope.mjs +8 -10
- package/mail/hooks.mjs +43 -27
- package/mail/idle.mjs +14 -5
- package/mail/import-maildir.mjs +350 -0
- package/mail/questions.mjs +38 -46
- package/mail/reads.mjs +15 -26
- package/mail/relay-endpoint.mjs +14 -22
- package/mail/relay.mjs +100 -65
- package/mail/retire-maildir.mjs +122 -0
- package/mail/send.mjs +141 -104
- package/mail/sqlite-store.mjs +476 -0
- package/mail/sqlite.mjs +353 -0
- package/mail/transcript.mjs +3 -3
- package/mail/upkeep.mjs +46 -26
- package/mail/verbs.mjs +14 -36
- package/native-codex-pane.mjs +6 -6
- package/native-initial-input.mjs +6 -6
- package/native-launch-binding.mjs +35 -0
- package/package.json +2 -2
- package/session-people.mjs +7 -8
- package/terminal-mail.mjs +8 -19
- package/mail/store.mjs +0 -531
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Native continuation is a mail route, never a caller identity or an authority alias. Only Claude's own terminal
|
|
2
|
+
// continued-in row links two UUIDs. Read the requested filenames and their tails, not every transcript in a home.
|
|
3
|
+
import { open, opendir, realpath, stat } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { makeAddress, parseAddress } from './compose.mjs';
|
|
6
|
+
|
|
7
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8
|
+
const TAIL_BYTES = 64 * 1024;
|
|
9
|
+
const MAX_PROJECTS = 4096;
|
|
10
|
+
const MAX_HOPS = 16;
|
|
11
|
+
const LOOKUP_MS = 750;
|
|
12
|
+
const unchanged = (a, b) => a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs && a.ino === b.ino && a.dev === b.dev;
|
|
13
|
+
const problem = (reason) => Object.assign(new Error(reason), { continuation: true });
|
|
14
|
+
|
|
15
|
+
/** All followed homes must agree on the named transcript: a UUID copied to another project/home is ambiguous. */
|
|
16
|
+
async function namedTranscript(id, roots, budget) {
|
|
17
|
+
const found = new Set();
|
|
18
|
+
for (const root of new Set(roots)) {
|
|
19
|
+
budget.check();
|
|
20
|
+
let directory;
|
|
21
|
+
try { directory = await opendir(root); }
|
|
22
|
+
catch (error) { if (error.code === 'ENOENT') continue; throw error; }
|
|
23
|
+
for await (const project of directory) {
|
|
24
|
+
budget.check();
|
|
25
|
+
if (++budget.projects > MAX_PROJECTS) throw problem('the named transcript lookup exceeded its project bound');
|
|
26
|
+
if (!project.isDirectory() && !project.isSymbolicLink()) continue;
|
|
27
|
+
const path = join(root, project.name, `${id}.jsonl`);
|
|
28
|
+
try {
|
|
29
|
+
if ((await stat(path)).isFile()) found.add(await realpath(path));
|
|
30
|
+
} catch (error) { if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error; }
|
|
31
|
+
if (found.size > 1) throw problem('more than one native transcript has that session id');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
budget.check();
|
|
35
|
+
return [...found][0] ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A complete, unchanged final JSONL row. A partial write, large final row or changed file cannot establish a route. */
|
|
39
|
+
async function terminalContinuation(path, id, budget) {
|
|
40
|
+
budget.check();
|
|
41
|
+
const file = await open(path, 'r');
|
|
42
|
+
try {
|
|
43
|
+
const before = await file.stat({ bigint: true });
|
|
44
|
+
const size = Number(before.size);
|
|
45
|
+
if (!Number.isSafeInteger(size)) throw problem('the native transcript size is not readable');
|
|
46
|
+
if (!size) return null;
|
|
47
|
+
const start = Math.max(0, size - TAIL_BYTES);
|
|
48
|
+
const bytes = Buffer.alloc(size - start);
|
|
49
|
+
const { bytesRead } = await file.read(bytes, 0, bytes.length, start);
|
|
50
|
+
const after = await file.stat({ bigint: true });
|
|
51
|
+
budget.check();
|
|
52
|
+
if (bytesRead !== bytes.length || !unchanged(before, after)) throw problem('the native transcript changed during its continuation read');
|
|
53
|
+
let end = bytes.length;
|
|
54
|
+
if (bytes[end - 1] !== 10) throw problem('the native transcript has an incomplete final row');
|
|
55
|
+
while (end && (bytes[end - 1] === 10 || bytes[end - 1] === 13)) end--;
|
|
56
|
+
const boundary = bytes.lastIndexOf(10, end - 1);
|
|
57
|
+
if (start && boundary < 0) throw problem('the native transcript final row exceeds the continuation read bound');
|
|
58
|
+
let row;
|
|
59
|
+
try { row = JSON.parse(bytes.subarray(boundary + 1, end).toString('utf8')); }
|
|
60
|
+
catch { throw problem('the native transcript final row is unreadable'); }
|
|
61
|
+
if (row?.type !== 'continued-in') return null;
|
|
62
|
+
if (row.sessionId !== id || typeof row.continuedInSessionId !== 'string' || !UUID.test(row.continuedInSessionId) || row.continuedInSessionId === id || row.isSidechain === true) {
|
|
63
|
+
throw problem('the native continuation row does not identify a distinct continuation of that session');
|
|
64
|
+
}
|
|
65
|
+
return row.continuedInSessionId;
|
|
66
|
+
} finally { await file.close(); }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Resolve an exact old native address to a currently live receiver. No route is cached, guessed or persisted.
|
|
70
|
+
* The shared budget bounds project entries, hops and elapsed lookup work, and each file read is at most 64 KiB.
|
|
71
|
+
* Filesystem calls are asynchronous; the elapsed bound is checked after each await, not an OS I/O cancellation.
|
|
72
|
+
*/
|
|
73
|
+
export async function nativeMailContinuation(address, { machine, claudeProjects, sessions }) {
|
|
74
|
+
const parsed = parseAddress(address);
|
|
75
|
+
if (parsed?.machine !== machine || parsed.harness !== 'claude-code' || !UUID.test(parsed.session)) return null;
|
|
76
|
+
const budget = { projects: 0, until: performance.now() + LOOKUP_MS, check() {
|
|
77
|
+
if (performance.now() > this.until) throw problem('the named transcript continuation lookup exceeded its time bound');
|
|
78
|
+
} };
|
|
79
|
+
const chain = [address];
|
|
80
|
+
try {
|
|
81
|
+
for (let hop = 0; hop <= MAX_HOPS; hop++) {
|
|
82
|
+
// The exact address may resume while its old transcript is being read. A currently live exact receiver wins.
|
|
83
|
+
if (sessions().some((row) => row.address === address)) return null;
|
|
84
|
+
const current = chain.at(-1);
|
|
85
|
+
const session = sessions().find((row) => row.address === current);
|
|
86
|
+
if (session) return chain.length > 1 ? { session, addressed: address, chain } : null;
|
|
87
|
+
if (hop === MAX_HOPS) throw problem('the native continuation chain exceeded its hop bound');
|
|
88
|
+
const id = parseAddress(current).session;
|
|
89
|
+
const path = await namedTranscript(id, claudeProjects(), budget);
|
|
90
|
+
const next = path ? await terminalContinuation(path, id, budget) : null;
|
|
91
|
+
if (!next) {
|
|
92
|
+
if (chain.length > 1) throw problem(`the recorded continuation ${current} is not running and records no further continuation`);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const target = makeAddress(machine, 'claude-code', next);
|
|
96
|
+
if (chain.includes(target)) throw problem('the native continuation chain contains a cycle');
|
|
97
|
+
chain.push(target);
|
|
98
|
+
}
|
|
99
|
+
} catch (error) { return { error: error.continuation ? error.message : `the native continuation could not be read (${error.code ?? 'filesystem error'})` }; }
|
|
100
|
+
return null;
|
|
101
|
+
}
|
package/mail/envelope.mjs
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
// reads of it (`render`). Ported line for line: what a session reads does not depend on which process read it out.
|
|
3
3
|
import { blake3 } from '@noble/hashes/blake3.js';
|
|
4
4
|
import { bytesToHex } from '@noble/hashes/utils.js';
|
|
5
|
-
import { readFileSync } from 'node:fs';
|
|
6
|
-
import { join } from 'node:path';
|
|
7
5
|
|
|
8
6
|
/** Command an agent runs to send or reply. */
|
|
9
7
|
export const SEND_COMMAND = 'supercode message send';
|
|
@@ -101,13 +99,13 @@ export const threadId = (envelope) => envelope.thread ?? envelope.id;
|
|
|
101
99
|
* When a native question's reply reached its session (mail_question.rs `delivered_at`): only for a reply to a `q-`
|
|
102
100
|
* question whose recorded reply is this envelope.
|
|
103
101
|
*/
|
|
104
|
-
export function questionDeliveredAt(envelope,
|
|
102
|
+
export function questionDeliveredAt(envelope, store) {
|
|
105
103
|
const id = envelope.in_reply_to;
|
|
106
104
|
if (typeof id !== 'string' || !id.startsWith('q-')) return null;
|
|
107
105
|
try {
|
|
108
|
-
const reply =
|
|
106
|
+
const reply = store.record('native_questions', `${id}.reply`);
|
|
109
107
|
if (reply?.envelope?.id !== envelope.id) return null;
|
|
110
|
-
const delivered =
|
|
108
|
+
const delivered = store.record('native_questions', `${id}.delivered`);
|
|
111
109
|
return Number.isFinite(delivered) ? delivered : null;
|
|
112
110
|
} catch { return null; }
|
|
113
111
|
}
|
|
@@ -128,9 +126,9 @@ export function describe(delivery) {
|
|
|
128
126
|
const printable = (name) => [...String(name)].filter((character) => /^[A-Za-z0-9\-_.@ ]$/.test(character)).slice(0, 80).join('');
|
|
129
127
|
|
|
130
128
|
/** A message as an inbox lists it (message.rs `listed`). */
|
|
131
|
-
export function listed(envelope, deliveries,
|
|
129
|
+
export function listed(envelope, deliveries, store) {
|
|
132
130
|
const sent = rfc3339(envelope.created_at_ms);
|
|
133
|
-
const native = questionDeliveredAt(envelope,
|
|
131
|
+
const native = questionDeliveredAt(envelope, store);
|
|
134
132
|
const delivered = describe(native !== null ? { state: 'delivered', at_ms: native } : deliveries.get(envelope.id) ?? null);
|
|
135
133
|
if (envelope.kind === 'answer') return `${render(envelope)}\n(sent ${sent})`;
|
|
136
134
|
if (envelope.kind !== 'user') return `${render(envelope)}\n(sent ${sent}; delivered to the model: ${delivered})`;
|
|
@@ -143,13 +141,13 @@ export function listed(envelope, deliveries, mailRoot) {
|
|
|
143
141
|
}
|
|
144
142
|
|
|
145
143
|
/** A message as `show` and `inbox --json` give it (message.rs `message_row`). */
|
|
146
|
-
export function messageRow(address, stored, deliveries,
|
|
147
|
-
const native = questionDeliveredAt(stored.envelope,
|
|
144
|
+
export function messageRow(address, stored, deliveries, store) {
|
|
145
|
+
const native = questionDeliveredAt(stored.envelope, store);
|
|
148
146
|
return {
|
|
149
147
|
session: address,
|
|
150
148
|
state: stored.state,
|
|
151
149
|
delivery: native !== null ? { state: 'delivered', at_ms: native } : deliveries.get(stored.envelope.id) ?? null,
|
|
152
150
|
envelope: stored.envelope,
|
|
153
|
-
rendered: listed(stored.envelope, deliveries,
|
|
151
|
+
rendered: listed(stored.envelope, deliveries, store),
|
|
154
152
|
};
|
|
155
153
|
}
|
package/mail/hooks.mjs
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
// that send went, `relay-inbound` files mail a relay's Claude was handed in the mailbox of the session it speaks for.
|
|
8
8
|
// - `message native-question-hook` (mail_question.rs `hook`): a native session's creator recorded at its start, and its
|
|
9
9
|
// AskUserQuestion asked as mail and answered by the reply.
|
|
10
|
-
import {
|
|
11
|
-
import { join, resolve, sep } from 'node:path';
|
|
10
|
+
import { readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
12
12
|
import { envelope as makeEnvelope, makeAddress, newMessageId, parseAddress } from './compose.mjs';
|
|
13
13
|
import { blake3Hex } from './envelope.mjs';
|
|
14
14
|
import { typedLineId } from './transcript.mjs';
|
|
@@ -34,13 +34,14 @@ export class MailHooks {
|
|
|
34
34
|
/** `handOver(address, id)` a filed message to its session's door; `threadOf(id)` the thread a reply to `id` joins. */
|
|
35
35
|
constructor({ machine, mailRoot, store, live, handOver, threadOf, stateDir = null, log = () => {} }) {
|
|
36
36
|
Object.assign(this, { machine, mailRoot, store, live, handOver, threadOf, log });
|
|
37
|
+
this.stopped = false;
|
|
38
|
+
this.waiting = new Set();
|
|
37
39
|
// Windows: each Codex session's last hook (the process it ran under, the rollout it named, when), which serve's
|
|
38
40
|
// Codex listing reads through this daemon (codex_peer.rs `hook_recorded_rollouts`); kept beside this daemon's state,
|
|
39
41
|
// never in the mail folder (mail step 1c)
|
|
40
42
|
this.codexHooksFile = stateDir ? join(stateDir, 'codex-hooks.json') : null;
|
|
41
43
|
this.codexHooks = new Map(Object.entries((this.codexHooksFile && readJson(this.codexHooksFile)) || {}));
|
|
42
|
-
this.
|
|
43
|
-
this.questions = join(mailRoot, 'native-questions');
|
|
44
|
+
this.relaysRoots = [resolve(mailRoot, 'relays'), resolve(dirname(mailRoot), 'relays')];
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
// ---- `message hook <harness>` -------------------------------------------------------------------------------------
|
|
@@ -50,7 +51,7 @@ export class MailHooks {
|
|
|
50
51
|
const session = typeof input?.session_id === 'string' ? input.session_id : null;
|
|
51
52
|
const address = session ? makeAddress(this.machine, harness, session) : null;
|
|
52
53
|
if (!address) return null;
|
|
53
|
-
|
|
54
|
+
this.store.open(address);
|
|
54
55
|
// Windows: where the listing cannot see a Codex's open rollout, the session's own hook says it runs (the process
|
|
55
56
|
// that ran it, and the rollout it names), until the live map knows Codex there (step 1c)
|
|
56
57
|
if (process.platform === 'win32' && harness === 'codex' && Number.isInteger(claim?.ppid)) {
|
|
@@ -64,9 +65,8 @@ export class MailHooks {
|
|
|
64
65
|
return null;
|
|
65
66
|
}
|
|
66
67
|
if (event !== 'PostToolUse' && !(event === 'Stop' && input.stop_hook_active !== true)) return null;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const unread = this.store.inventory(address).stored.filter((item) => item.state === 'unread' && !pending.includes(item.envelope.id));
|
|
68
|
+
const pending = new Set(this.store.wakes(address));
|
|
69
|
+
const unread = this.store.unreadMessages(address).filter((item) => !pending.has(item.envelope.id));
|
|
70
70
|
if (!unread.length) return null;
|
|
71
71
|
const pointer = this.#pointer(unread);
|
|
72
72
|
return event === 'PostToolUse'
|
|
@@ -112,42 +112,45 @@ export class MailHooks {
|
|
|
112
112
|
|
|
113
113
|
// ---- a relay's hooks ----------------------------------------------------------------------------------------------
|
|
114
114
|
|
|
115
|
-
/** A relay's
|
|
115
|
+
/** A relay's runtime directory identifies its row. Older hooks can keep their
|
|
116
|
+
* Maildir-era identifier; neither spelling opens that directory for mail. */
|
|
116
117
|
#relay(directory) {
|
|
117
118
|
const dir = resolve(String(directory ?? ''));
|
|
118
|
-
|
|
119
|
-
|
|
119
|
+
const root = this.relaysRoots.find((root) => dir.startsWith(root + sep) && !dir.slice(root.length + 1).includes(sep));
|
|
120
|
+
if (!root) throw Object.assign(new Error(`${dir} is not a relay's folder`), { code: 400 });
|
|
121
|
+
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` };
|
|
120
123
|
}
|
|
121
124
|
|
|
122
125
|
/** `message gate`: only a SendMessage equal to the queued one passes (ListAgents always); anything else is denied, and a
|
|
123
126
|
* queued send refused still answers its sender (claude_relay.rs `gate_decision`). */
|
|
124
127
|
gate(directory, input) {
|
|
125
128
|
const paths = this.#relay(directory);
|
|
126
|
-
const queued =
|
|
129
|
+
const queued = this.store.record('relays', paths.queue);
|
|
127
130
|
const reason = gateDecision(input ?? {}, queued);
|
|
128
131
|
if (reason == null) return null;
|
|
129
|
-
if (queued
|
|
132
|
+
if (queued) { try { this.store.putRecord('relays', paths.receipt, { denied: reason }, { create: true }); } catch { /* best kept */ } }
|
|
130
133
|
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } };
|
|
131
134
|
}
|
|
132
135
|
|
|
133
136
|
/** `message relay-receipt`: Claude's own answer to the queued send, its error, or that the turn ended without one. */
|
|
134
137
|
receipt(directory, input) {
|
|
135
138
|
const paths = this.#relay(directory);
|
|
136
|
-
if (!
|
|
139
|
+
if (!this.store.record('relays', paths.queue) || this.store.record('relays', paths.receipt)) return null;
|
|
137
140
|
const event = input?.hook_event_name;
|
|
138
141
|
let receipt;
|
|
139
142
|
if (event === 'PostToolUse') receipt = { tool_response: input.tool_response ?? null };
|
|
140
143
|
else if (event === 'PostToolUseFailure') receipt = { send_failed: typeof input.error === 'string' ? input.error : input.error == null ? '' : JSON.stringify(input.error) };
|
|
141
144
|
else if (event === 'Stop' || event === 'StopFailure') {
|
|
142
145
|
// a turn that began before this send was queued (the last send's, still ending) is not its answer
|
|
143
|
-
const queued =
|
|
144
|
-
|
|
146
|
+
const queued = this.store.record('relays', paths.queue);
|
|
147
|
+
const prompt = this.store.record('relays', paths.turn);
|
|
145
148
|
if (queued && prompt != null && !prompt.includes(sendTurn(queued))) return null;
|
|
146
149
|
const text = (key) => (typeof input[key] === 'string' ? input[key] : null);
|
|
147
150
|
const said = event === 'StopFailure' ? `${text('error_details') ?? text('last_assistant_message') ?? ''} (${text('error') ?? 'error'})` : text('last_assistant_message');
|
|
148
151
|
receipt = { turn_ended: true, said: said == null ? null : Array.from(said).slice(0, 300).join('') };
|
|
149
152
|
} else return null;
|
|
150
|
-
|
|
153
|
+
this.store.putRecord('relays', paths.receipt, receipt, { create: true });
|
|
151
154
|
return null;
|
|
152
155
|
}
|
|
153
156
|
|
|
@@ -158,7 +161,7 @@ export class MailHooks {
|
|
|
158
161
|
const prompt = typeof input?.prompt === 'string' ? input.prompt : '';
|
|
159
162
|
const trimmed = prompt.trimStart();
|
|
160
163
|
if (!(trimmed.startsWith(NATIVE_PEER_OPENING) || trimmed.startsWith(NATIVE_IDLE_NOTICE) || trimmed.startsWith(NATIVE_DELIVERY_NOTICE))) {
|
|
161
|
-
try {
|
|
164
|
+
try { this.store.putRecord('relays', paths.turn, prompt); } catch { /* best kept */ }
|
|
162
165
|
return null;
|
|
163
166
|
}
|
|
164
167
|
if (!parseAddress(represented)) throw Object.assign(new Error(`not an address: ${represented}`), { code: 400 });
|
|
@@ -169,7 +172,7 @@ export class MailHooks {
|
|
|
169
172
|
// the replying Claude session runs beside the relay, on this machine
|
|
170
173
|
const sender = this.live.sessions().find((row) => row.door === 'native' && row.socket && row.socket === event.from.replace(/^uds:/, ''));
|
|
171
174
|
const sessionId = sender ? sender.address.split(':').slice(3).join(':') : null;
|
|
172
|
-
const lastSent =
|
|
175
|
+
const lastSent = this.store.record('relays', paths.sent) ?? {};
|
|
173
176
|
const inReplyTo = sessionId ? lastSent[sessionId] ?? null : null;
|
|
174
177
|
this.#fileHome(represented, makeEnvelope({ id, from: makeAddress(this.machine, 'claude-code', sessionId ?? 'unknown'),
|
|
175
178
|
from_name: sender ? sender.name : event.fromName ?? 'an unknown Claude session', kind: 'peer', reply_via: { mode: 'command' },
|
|
@@ -225,14 +228,15 @@ export class MailHooks {
|
|
|
225
228
|
const seedFile = resolve(String(seedPath ?? ''));
|
|
226
229
|
const launches = resolve(this.mailRoot, 'native-launches');
|
|
227
230
|
if (!seedFile.startsWith(launches + sep)) throw Object.assign(new Error(`${seedFile} is not a native launch's binding`), { code: 400 });
|
|
228
|
-
const
|
|
231
|
+
const seedKey = seedFile.slice(launches.length + 1);
|
|
232
|
+
const seed = this.store.record('native_launches', seedKey);
|
|
229
233
|
if (!seed || (seed.kind !== 'codex' && seed.kind !== 'claude-code')) throw new Error('unsupported native question harness');
|
|
230
234
|
const binding = questions.bind(seed, { session_id: input?.session_id, transcript_path: typeof input?.transcript_path === 'string' ? input.transcript_path : null }, seed.kind);
|
|
231
235
|
if (input?.hook_event_name === 'SessionStart' && binding.transcript_path) {
|
|
232
236
|
// this launch's baseline precedes its native initial prompt, including on resume
|
|
233
|
-
const receipt = `${
|
|
237
|
+
const receipt = `${seedKey.replace(/\.json$/, '')}.session.json`;
|
|
234
238
|
let offset = 0; try { offset = statSync(binding.transcript_path).size; } catch { /* not there yet */ }
|
|
235
|
-
|
|
239
|
+
this.store.putRecord('native_launches', receipt, { path: binding.transcript_path, offset }, { create: true });
|
|
236
240
|
}
|
|
237
241
|
if (input?.tool_name !== 'AskUserQuestion') return {};
|
|
238
242
|
const tool = input.tool_input ?? {};
|
|
@@ -242,23 +246,35 @@ export class MailHooks {
|
|
|
242
246
|
const id = questions.idOf(binding.session, native);
|
|
243
247
|
const answersOf = (reply) => Object.fromEntries(Object.entries(reply?.answers ?? {}).map(([key, value]) => [key, value?.answers?.[0] ?? null]));
|
|
244
248
|
if (input.hook_event_name === 'PostToolUse') {
|
|
245
|
-
const reply =
|
|
249
|
+
const reply = this.store.record('native_questions', `${id}.reply`);
|
|
246
250
|
if (reply && JSON.stringify(input.tool_response?.answers ?? null) === JSON.stringify(answersOf(reply))) questions.confirm(id, reply);
|
|
247
251
|
return {};
|
|
248
252
|
}
|
|
249
253
|
if (input.hook_event_name !== 'PreToolUse') return {};
|
|
254
|
+
if (this.store.record('native_questions', `${id}.reply`)?.body_expired_at_ms != null) {
|
|
255
|
+
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny',
|
|
256
|
+
permissionDecisionReason: 'This native question was already answered; its answer text expired under mail retention. Start a new question instead of reusing its tool id.' } };
|
|
257
|
+
}
|
|
250
258
|
// one hook owns this wait, marked by its process: a hook its harness killed ends the wait, and never answers
|
|
251
|
-
const waiting =
|
|
252
|
-
|
|
259
|
+
const waiting = `${id}.waiting`;
|
|
260
|
+
if (!this.store.putRecord('native_questions', waiting, { pid: claim?.pid ?? null }, { create: true })) throw Object.assign(new Error(`native question already has a waiting hook: ${id}`), { code: 'EEXIST' });
|
|
261
|
+
this.waiting.add(waiting);
|
|
253
262
|
try {
|
|
254
263
|
questions.file(binding, native);
|
|
255
264
|
for (;;) {
|
|
256
|
-
|
|
265
|
+
if (this.stopped) throw Object.assign(new Error('the daemon stopped while waiting for a native question reply'), { code: 503 });
|
|
266
|
+
const reply = this.store.record('native_questions', `${id}.reply`);
|
|
257
267
|
if (reply) return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', updatedInput: { ...tool, answers: answersOf(reply) } } };
|
|
258
268
|
if (claim?.pid && !alive(Number(claim.pid))) return {};
|
|
259
269
|
await new Promise((done) => setTimeout(done, 250));
|
|
260
270
|
}
|
|
261
|
-
} finally {
|
|
271
|
+
} finally { if (!this.stopped) this.store.removeRecord('native_questions', waiting); this.waiting.delete(waiting); }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
stop() {
|
|
275
|
+
this.stopped = true;
|
|
276
|
+
for (const key of this.waiting) this.store.removeRecord('native_questions', key);
|
|
277
|
+
this.waiting.clear();
|
|
262
278
|
}
|
|
263
279
|
}
|
|
264
280
|
|
package/mail/idle.mjs
CHANGED
|
@@ -28,6 +28,7 @@ export class IdleNotices {
|
|
|
28
28
|
*/
|
|
29
29
|
constructor({ machine, store, live, serveRequest, locatorOf, log = () => {} }) {
|
|
30
30
|
Object.assign(this, { machine, store, live, serveRequest, locatorOf, log });
|
|
31
|
+
this.stopped = false;
|
|
31
32
|
this.targets = new Set(); // addresses here with a subscription on them
|
|
32
33
|
this.activity = new Map(); // address → { turn, presence } as serve last reported it
|
|
33
34
|
this.subscription = null; this.followed = '';
|
|
@@ -36,14 +37,15 @@ export class IdleNotices {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
start() {
|
|
39
|
-
this.
|
|
40
|
-
|
|
40
|
+
this.onSubscriptions = (address) => this.#track(address);
|
|
41
|
+
this.store.on('subscriptions', this.onSubscriptions);
|
|
42
|
+
for (const address of this.store.subscribed()) this.#track(address);
|
|
41
43
|
this.sweeper = setInterval(() => { for (const address of this.targets) void this.#settle(address); }, SWEEP_MS);
|
|
42
44
|
this.sweeper.unref?.();
|
|
43
45
|
return this;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
stop() { clearInterval(this.sweeper); clearInterval(this.runtimeTimer); this.#unfollow(); }
|
|
48
|
+
stop() { this.stopped = true; this.store.off('subscriptions', this.onSubscriptions); clearInterval(this.sweeper); clearInterval(this.runtimeTimer); this.#unfollow(); }
|
|
47
49
|
|
|
48
50
|
/** Serve's activity notification for this subscription: each changed session settled. */
|
|
49
51
|
notified(params) {
|
|
@@ -56,6 +58,7 @@ export class IdleNotices {
|
|
|
56
58
|
serveExited() { this.subscription = null; this.followed = ''; this.#follow(); }
|
|
57
59
|
|
|
58
60
|
#track(address) {
|
|
61
|
+
if (this.stopped) return;
|
|
59
62
|
const parsed = parseAddress(address);
|
|
60
63
|
if (!parsed || parsed.machine !== this.machine) return;
|
|
61
64
|
const has = this.store.subscriptions(address).length > 0;
|
|
@@ -66,6 +69,7 @@ export class IdleNotices {
|
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
#seen(activity) {
|
|
72
|
+
if (this.stopped) return;
|
|
69
73
|
const address = `sc:${this.machine}:${activity.harness}:${activity.session_id}`;
|
|
70
74
|
if (!this.targets.has(address)) return;
|
|
71
75
|
this.activity.set(address, { turn: activity.turn, presence: activity.presence });
|
|
@@ -76,6 +80,7 @@ export class IdleNotices {
|
|
|
76
80
|
|
|
77
81
|
/** The subscribed sessions' activity followed (one subscription for all of them, made again when the set changes). */
|
|
78
82
|
async #follow() {
|
|
83
|
+
if (this.stopped) return;
|
|
79
84
|
const locators = [];
|
|
80
85
|
for (const address of this.targets) {
|
|
81
86
|
if (this.#runtime(address)) continue; // a hosted runtime is asked itself
|
|
@@ -90,7 +95,7 @@ export class IdleNotices {
|
|
|
90
95
|
if (locators.length) {
|
|
91
96
|
try {
|
|
92
97
|
const answer = await this.serveRequest('harness.v1.sessions.activity.subscribe', { locators: locators.slice(0, MAX_FOLLOWED) }, 30_000);
|
|
93
|
-
if (this.followed !== fingerprint) { if (answer?.subscription) void this.serveRequest('harness.v1.sessions.activity.unsubscribe', { subscription: answer.subscription }, 30_000).catch(() => {}); return; }
|
|
98
|
+
if (this.stopped || this.followed !== fingerprint) { if (answer?.subscription) void this.serveRequest('harness.v1.sessions.activity.unsubscribe', { subscription: answer.subscription }, 30_000).catch(() => {}); return; }
|
|
94
99
|
this.subscription = answer?.subscription ?? null;
|
|
95
100
|
if (locators.length > MAX_FOLLOWED) this.log(`idle notices: ${locators.length - MAX_FOLLOWED} subscribed sessions are not followed (at most ${MAX_FOLLOWED}); they settle on expiry`);
|
|
96
101
|
for (const activity of answer?.initial ?? []) this.#seen(activity);
|
|
@@ -110,6 +115,7 @@ export class IdleNotices {
|
|
|
110
115
|
|
|
111
116
|
/** The hosted runtimes with a final reply waiting, asked for their turn every RUNTIME_POLL_MS while one waits. */
|
|
112
117
|
#pollRuntimes() {
|
|
118
|
+
if (this.stopped) return;
|
|
113
119
|
const waiting = [...this.targets].filter((address) => this.#runtime(address) && this.store.subscriptions(address).some((item) => item.final_reply));
|
|
114
120
|
this.polling = new Set(waiting);
|
|
115
121
|
if (!waiting.length) { clearInterval(this.runtimeTimer); this.runtimeTimer = null; return; }
|
|
@@ -130,7 +136,7 @@ export class IdleNotices {
|
|
|
130
136
|
|
|
131
137
|
/** One pass over one receiver's subscriptions (mail_watch.rs `tick`, for one mailbox). */
|
|
132
138
|
async #settle(address) {
|
|
133
|
-
if (this.settling.has(address)) return;
|
|
139
|
+
if (this.stopped || this.settling.has(address)) return;
|
|
134
140
|
this.settling.add(address);
|
|
135
141
|
try {
|
|
136
142
|
const subscriptions = this.store.subscriptions(address);
|
|
@@ -151,6 +157,7 @@ export class IdleNotices {
|
|
|
151
157
|
const seen = this.activity.get(address);
|
|
152
158
|
if (seen) { working = seen.turn === 'working'; idle = seen.turn === 'idle'; ended = seen.presence === 'persisted'; }
|
|
153
159
|
}
|
|
160
|
+
if (this.stopped) return;
|
|
154
161
|
// one turn settles every message a sender subscribed with; the sender hears it once, about its latest message,
|
|
155
162
|
// and its earlier subscriptions settle with it silently
|
|
156
163
|
subscriptions.sort((a, b) => (b.created_at_ms ?? 0) - (a.created_at_ms ?? 0));
|
|
@@ -162,6 +169,7 @@ export class IdleNotices {
|
|
|
162
169
|
// however fast the turn was
|
|
163
170
|
if (subscription.final_reply && !ended && !expired) {
|
|
164
171
|
const answer = receipt && idle ? await this.#answerTo(address, receipt, subscription.message_id) : null;
|
|
172
|
+
if (this.stopped) return;
|
|
165
173
|
if (answer && this.store.removeSubscription(address, subscription.message_id)) {
|
|
166
174
|
this.#reply(address, subscription, answer);
|
|
167
175
|
if (subscription.notice !== false) this.#notice(address, subscription, 'idle');
|
|
@@ -237,6 +245,7 @@ export class IdleNotices {
|
|
|
237
245
|
|
|
238
246
|
/** Filed in the subscriber's mailbox with a wake: the carrier delivers it here, or carries it to its machine. */
|
|
239
247
|
#file(subscriber, envelope) {
|
|
248
|
+
if (this.stopped) return;
|
|
240
249
|
try { this.store.deliver(subscriber, envelope); this.store.requestWake(subscriber, envelope.id); }
|
|
241
250
|
catch (error) { this.log(`idle notices: ${envelope.id} for ${subscriber} was not filed: ${error.message}`); }
|
|
242
251
|
}
|