@volter/supercode-teams 0.3.117 → 0.3.119
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.mjs +19 -0
- package/machine.mjs +43 -3
- package/mail/hooks.mjs +285 -0
- package/mail/questions.mjs +131 -0
- package/mail/store.mjs +3 -3
- package/package.json +2 -2
package/bin/workspace.mjs
CHANGED
|
@@ -1064,6 +1064,10 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
|
|
|
1064
1064
|
// The service's own run (no terminal; no flag, or --foreground on Windows) never adds a rule or returns early: a
|
|
1065
1065
|
// service whose folder is not among the roots (an enrollment from before roots were kept) only gets its root back.
|
|
1066
1066
|
const serviceRun = !addingRoot && !flags.install && !(process.stdin.isTTY && process.stdout.isTTY) && (!flags.foreground || process.platform === 'win32');
|
|
1067
|
+
// A service's error log keeps no time of its own: each line the connector writes there is stamped, so an outage,
|
|
1068
|
+
// a restart or a refused link can be placed against the server's and the service manager's records (2026-10-06:
|
|
1069
|
+
// a restart and a window of "temporarily unavailable" could be timed only from a killed process's uptime).
|
|
1070
|
+
if (serviceRun) stampStderr();
|
|
1067
1071
|
// A folder a person adds gets its sync rule first, whatever runs the connector, so a refused or failed rule leaves
|
|
1068
1072
|
// the enrollment as it was; a machine context adds none.
|
|
1069
1073
|
const addedRule = (addedRoot || addingRoot) && !serviceRun && !context.machine_only ? await addedRootRule({ team, saved, root, harnesses: ruleHarnesses, flags }).catch(error => { core.close(); throw error; }) : null;
|
|
@@ -1462,3 +1466,18 @@ if (import.meta.url === new URL(`file://${process.argv[1]}`).href) workspaceMain
|
|
|
1462
1466
|
// still holds the event loop open gets a moment, then the exit is forced.
|
|
1463
1467
|
setTimeout(() => process.exit(process.exitCode), 2_000).unref();
|
|
1464
1468
|
});
|
|
1469
|
+
|
|
1470
|
+
/** Every line this process writes to stderr from here on, stamped with its UTC time (a write's line starts only). */
|
|
1471
|
+
function stampStderr() {
|
|
1472
|
+
if (process.stderr.__stamped) return;
|
|
1473
|
+
const write = process.stderr.write.bind(process.stderr);
|
|
1474
|
+
let atStart = true;
|
|
1475
|
+
process.stderr.write = (chunk, ...rest) => {
|
|
1476
|
+
const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8');
|
|
1477
|
+
const stamp = new Date().toISOString();
|
|
1478
|
+
const stamped = text.replace(/(^|\n)(?=[^\n])/g, (match, newline, offset) => (offset === 0 && !atStart ? match : `${newline}${stamp} `));
|
|
1479
|
+
atStart = text.endsWith('\n');
|
|
1480
|
+
return write(stamped, ...rest);
|
|
1481
|
+
};
|
|
1482
|
+
process.stderr.__stamped = true;
|
|
1483
|
+
}
|
package/machine.mjs
CHANGED
|
@@ -42,6 +42,8 @@ import { Agents } from './mail/agents.mjs';
|
|
|
42
42
|
import { Relays } from './mail/relay.mjs';
|
|
43
43
|
import { serveRelayEndpoint } from './mail/relay-endpoint.mjs';
|
|
44
44
|
import { MailCarrier } from './mail/carrier.mjs';
|
|
45
|
+
import { MailHooks } from './mail/hooks.mjs';
|
|
46
|
+
import { NativeQuestions } from './mail/questions.mjs';
|
|
45
47
|
|
|
46
48
|
const NO_CALLER = "Can't tell which session is running this command, so replies would have nowhere to go. Nothing was sent. Run it from your agent session's own shell tool.";
|
|
47
49
|
import { MailSend } from './mail/send.mjs';
|
|
@@ -768,7 +770,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
768
770
|
this.agents = new Agents({ mailRoot, machine, supercodeBin: this.supercodeBin, env: this.env,
|
|
769
771
|
discover: (params) => this.#serveRequest('harness.v1.sessions.discover', params, 10_000),
|
|
770
772
|
recordedConfig: (locator) => this.#serveRequest('harness.v1.sessions.recorded_config', { locator }, 15_000) });
|
|
771
|
-
|
|
773
|
+
// a relay's hooks (`message gate`, `relay-receipt`, `relay-inbound`) run the machine's stable supercode: its front door
|
|
774
|
+
this.relays = new Relays({ mailRoot, machine, supercodeBin: this.#stableSupercode(), serve: (method, params, timeoutMs) => this.#serveRequest(method, params, timeoutMs),
|
|
772
775
|
runtimeOf: (harness, id) => live.runtimeReceipt(harness, id), runtimeNamed: (receiptId) => live.receiptNamed(receiptId),
|
|
773
776
|
runtimesIn: (harness, cwd) => live.runtimesIn(harness, cwd),
|
|
774
777
|
endpointOf: async () => (await this.relayEndpoint)?.url ?? null,
|
|
@@ -789,6 +792,20 @@ export class TeamsMachine extends MachineCore {
|
|
|
789
792
|
// a user's own turn typed into its pane, in process (as `panes.turn` types one)
|
|
790
793
|
typeTurn,
|
|
791
794
|
log: (line) => this.emit('mail-log', line) });
|
|
795
|
+
// native questions' files (bindings, questions, replies) as mail_question.rs keeps them; their poll is step 4c's
|
|
796
|
+
this.nativeQuestions = new NativeQuestions({ machine, mailRoot, store: this.mailStore, codexQuestions: (request) => codexQuestions(request),
|
|
797
|
+
log: (line) => this.emit('mail-log', line) });
|
|
798
|
+
// the harnesses' mail hooks (step 1b): each hook a front-door verb asking here, so none opens the mail folder
|
|
799
|
+
this.mailHooks = new MailHooks({ machine, mailRoot, store: this.mailStore, live,
|
|
800
|
+
handOver: (address, id) => this.mailSend.handOver(address, id),
|
|
801
|
+
// the thread a reply to `id` joins: its filed parent's thread, or the parent itself (mailbox.rs `thread_of_reply`)
|
|
802
|
+
threadOf: (id) => { const found = this.mailStore.find(id).find((item) => item.stored.envelope.id === id); return found?.stored.envelope.thread ?? id; },
|
|
803
|
+
log: (line) => this.emit('mail-log', line) });
|
|
804
|
+
// a relay's inbound mail its hook kept while this daemon did not answer (the front door's spool): filed now, each
|
|
805
|
+
// minute, and before each relay's inbound call
|
|
806
|
+
this.hookSpool = join(supercodeHome(this.env), 'hook-spool', 'relay-inbound');
|
|
807
|
+
this.mailHooks.drainSpool(this.hookSpool);
|
|
808
|
+
this.hookSpoolTimer = setInterval(() => this.mailHooks.drainSpool(this.hookSpool), 60_000); this.hookSpoolTimer.unref?.();
|
|
792
809
|
// the mailbox's own delivery (step 4b): each wake attempted by its session's door, or carried to its machine
|
|
793
810
|
this.carrier = new MailCarrier({ machine, mailRoot, store: this.mailStore, live,
|
|
794
811
|
handOver: (address, id) => this.mailSend.handOver(address, id),
|
|
@@ -1124,6 +1141,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1124
1141
|
this.mailWatch?.kill('SIGTERM');
|
|
1125
1142
|
void this.relayEndpoint?.then((endpoint) => endpoint?.close()).catch(() => {});
|
|
1126
1143
|
this.carrier?.stop();
|
|
1144
|
+
clearInterval(this.hookSpoolTimer);
|
|
1127
1145
|
clearInterval(this.turnsSweep);
|
|
1128
1146
|
this.stoppingServe = true;
|
|
1129
1147
|
clearTimeout(this.serveRestart);
|
|
@@ -1816,7 +1834,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
1816
1834
|
if (this.stableSupercodePath !== undefined) return this.stableSupercodePath ?? this.supercodeBin;
|
|
1817
1835
|
this.stableSupercodePath = null;
|
|
1818
1836
|
try {
|
|
1819
|
-
|
|
1837
|
+
// with this Node named, the shim it writes runs the front door (claude_relay.rs ensure_stable_program)
|
|
1838
|
+
const answer = spawnSync(this.supercodeBin, ['__stable-program'], { env: { ...this.env, SUPERCODE_NODE_BIN: this.env.SUPERCODE_NODE_BIN || process.execPath }, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
1820
1839
|
const path = answer.status === 0 ? answer.stdout.trim() : '';
|
|
1821
1840
|
if (path && isAbsolute(path) && existsSync(path)) this.stableSupercodePath = path;
|
|
1822
1841
|
} catch { /* this daemon's own supercode */ }
|
|
@@ -1902,7 +1921,15 @@ export class TeamsMachine extends MachineCore {
|
|
|
1902
1921
|
}
|
|
1903
1922
|
if (process.platform === 'win32' || !((params.kind === 'codex' && name === 'codex') ||
|
|
1904
1923
|
(params.kind === 'claude-code' && name === 'claude'))) return launchCommand(params.program, args);
|
|
1905
|
-
|
|
1924
|
+
// the hook is the front door's (mail step 1b): taken when the machine's stable supercode serves it
|
|
1925
|
+
if (this.nativeQuestionHooksSupported === undefined) {
|
|
1926
|
+
let served = false;
|
|
1927
|
+
try {
|
|
1928
|
+
const answer = spawnSync(this.#stableSupercode(), ['front-door', '--json'], { env: this.env, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
1929
|
+
served = answer.status === 0 && (JSON.parse(answer.stdout).typescript ?? []).some((row) => row.command === 'message native-question-hook');
|
|
1930
|
+
} catch { /* not served: the launch runs without it */ }
|
|
1931
|
+
this.nativeQuestionHooksSupported = served;
|
|
1932
|
+
}
|
|
1906
1933
|
if (!this.nativeQuestionHooksSupported) return launchCommand(params.program, args);
|
|
1907
1934
|
const mailHome = this.env.SUPERCODE_HOME || join(this.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'supercode');
|
|
1908
1935
|
const dir = join(mailHome, 'mail', 'native-launches');
|
|
@@ -3824,6 +3851,19 @@ export class TeamsMachine extends MachineCore {
|
|
|
3824
3851
|
this.mail.acknowledge(address, read.claimed);
|
|
3825
3852
|
return { address, messages };
|
|
3826
3853
|
}
|
|
3854
|
+
// the harnesses' mail hooks (step 1b): the hook's input in, what the hook prints out (`print`, null for nothing)
|
|
3855
|
+
case 'harness.v1.mail.hook': case 'harness.v1.mail.relay_gate': case 'harness.v1.mail.relay_receipt': case 'harness.v1.mail.relay_inbound': case 'harness.v1.mail.question_hook': {
|
|
3856
|
+
if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('a mail hook is answered here for this machine\'s own processes'), { code: 403 });
|
|
3857
|
+
const input = params.input && typeof params.input === 'object' ? params.input : {};
|
|
3858
|
+
if (method === 'harness.v1.mail.hook') {
|
|
3859
|
+
if (typeof params.harness !== 'string' || !/^[a-z0-9-]+$/.test(params.harness)) throw Object.assign(new Error('a hook names its harness'), { code: 400 });
|
|
3860
|
+
return { print: this.mailHooks.hook(params.harness, input, params.claim) };
|
|
3861
|
+
}
|
|
3862
|
+
if (method === 'harness.v1.mail.relay_gate') return { print: this.mailHooks.gate(params.directory, input) };
|
|
3863
|
+
if (method === 'harness.v1.mail.relay_receipt') return { print: this.mailHooks.receipt(params.directory, input) };
|
|
3864
|
+
if (method === 'harness.v1.mail.relay_inbound') { this.mailHooks.drainSpool(this.hookSpool); return { print: this.mailHooks.inbound(params.represented, params.directory, input, params.call) }; }
|
|
3865
|
+
return { print: await this.mailHooks.questionHook(params.binding, input, params.claim, this.nativeQuestions) };
|
|
3866
|
+
}
|
|
3827
3867
|
// `message waiting` and `message expired` (step 1): the carrier's own records
|
|
3828
3868
|
case 'harness.v1.mail.waiting': case 'harness.v1.mail.expired': {
|
|
3829
3869
|
if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('mail is read here by this machine\'s own processes'), { code: 403 });
|
package/mail/hooks.mjs
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// The harnesses' mail hooks, answered by this daemon (mail step 1b; docs/plans/typescript-architecture.md): each hook is
|
|
2
|
+
// a front-door verb that hands its hook input here and prints what comes back, so no hook opens the mail folder.
|
|
3
|
+
// - `message hook <harness>` (Codex's hooks; mail_route.rs and message.rs `hook`): the person's typed prompt filed as a
|
|
4
|
+
// typed line; on a tool's end or a turn's end, a pointer to the unread mail (never the mail itself: hook context
|
|
5
|
+
// carries the harness's own authority, and peer text travels only through `inbox`, which wraps it).
|
|
6
|
+
// - a relay's hooks (claude_relay.rs): `gate` lets the relay make only its one queued send, `relay-receipt` records how
|
|
7
|
+
// that send went, `relay-inbound` files mail a relay's Claude was handed in the mailbox of the session it speaks for.
|
|
8
|
+
// - `message native-question-hook` (mail_question.rs `hook`): a native session's creator recorded at its start, and its
|
|
9
|
+
// AskUserQuestion asked as mail and answered by the reply.
|
|
10
|
+
import { existsSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join, resolve, sep } from 'node:path';
|
|
12
|
+
import { envelope as makeEnvelope, makeAddress, newMessageId, parseAddress } from './compose.mjs';
|
|
13
|
+
import { blake3Hex } from './envelope.mjs';
|
|
14
|
+
import { typedLineId } from './transcript.mjs';
|
|
15
|
+
|
|
16
|
+
const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
|
|
17
|
+
const alive = (pid) => { if (!Number.isInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
|
|
18
|
+
|
|
19
|
+
// what Claude wraps mail in when it hands it to a relay (claude_relay.rs)
|
|
20
|
+
const NATIVE_PEER_PREAMBLE = 'Another Claude session sent a message:';
|
|
21
|
+
const NATIVE_PEER_OPENING = '<cross-session-message ';
|
|
22
|
+
const NATIVE_PEER_CLOSING = '</cross-session-message>';
|
|
23
|
+
const NATIVE_IDLE_NOTICE = '[Cross-session idle notice]';
|
|
24
|
+
const NATIVE_DELIVERY_NOTICE = '[Cross-session delivery notice]';
|
|
25
|
+
const INBOUND_BLOCK = { decision: 'block', reason: 'filed in the mailbox of the session this relay speaks for' };
|
|
26
|
+
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---`;
|
|
27
|
+
|
|
28
|
+
/** A line no person typed (mail_transcript.rs `machine_made`). */
|
|
29
|
+
const machineMade = (text) => text.includes('<cross-session-message') || text.startsWith('[Cross-session') || text.startsWith('[channel: ') || text.startsWith('<task-notification>');
|
|
30
|
+
/** A sender name reduced to characters that cannot break out of a hook's sentence (message.rs `printable`). */
|
|
31
|
+
const printable = (name) => Array.from(String(name ?? '')).filter((c) => /[A-Za-z0-9\-_.@ ]/.test(c)).slice(0, 80).join('');
|
|
32
|
+
|
|
33
|
+
export class MailHooks {
|
|
34
|
+
/** `handOver(address, id)` a filed message to its session's door; `threadOf(id)` the thread a reply to `id` joins. */
|
|
35
|
+
constructor({ machine, mailRoot, store, live, handOver, threadOf, log = () => {} }) {
|
|
36
|
+
Object.assign(this, { machine, mailRoot, store, live, handOver, threadOf, log });
|
|
37
|
+
this.relaysRoot = resolve(mailRoot, 'relays');
|
|
38
|
+
this.questions = join(mailRoot, 'native-questions');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---- `message hook <harness>` -------------------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/** One harness hook call: what it prints, or null. `claim` is the hook's own process (its parent on Windows). */
|
|
44
|
+
hook(harness, input, claim) {
|
|
45
|
+
const session = typeof input?.session_id === 'string' ? input.session_id : null;
|
|
46
|
+
const address = session ? makeAddress(this.machine, harness, session) : null;
|
|
47
|
+
if (!address) return null;
|
|
48
|
+
const box = this.store.open(address);
|
|
49
|
+
// Windows: where the listing cannot see a Codex's open rollout, the session's own hook says it runs (the process
|
|
50
|
+
// that ran it, and the rollout it names), until the live map knows Codex there (step 1c)
|
|
51
|
+
if (process.platform === 'win32' && harness === 'codex' && Number.isInteger(claim?.ppid)) {
|
|
52
|
+
try { writeFileSync(join(box.dir, 'process'), `${claim.ppid}\n${typeof input.transcript_path === 'string' ? input.transcript_path : ''}\n`); } catch { /* best kept */ }
|
|
53
|
+
}
|
|
54
|
+
const event = typeof input.hook_event_name === 'string' ? input.hook_event_name : '';
|
|
55
|
+
// the person's own prompt: filed as theirs, since the harness's transcript does not say who wrote it; no pointer
|
|
56
|
+
if (event === 'UserPromptSubmit') {
|
|
57
|
+
if (typeof input.prompt === 'string') this.filePrompt(address, input.prompt, Date.now());
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
if (event !== 'PostToolUse' && !(event === 'Stop' && input.stop_hook_active !== true)) return null;
|
|
61
|
+
let pending = [];
|
|
62
|
+
try { pending = readdirSync(join(box.dir, 'wake')); } catch { /* none */ }
|
|
63
|
+
const unread = this.store.inventory(address).stored.filter((item) => item.state === 'unread' && !pending.includes(item.envelope.id));
|
|
64
|
+
if (!unread.length) return null;
|
|
65
|
+
const pointer = this.#pointer(unread);
|
|
66
|
+
return event === 'PostToolUse'
|
|
67
|
+
? { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: pointer } }
|
|
68
|
+
: { decision: 'block', reason: `${pointer} Read them before you finish.` };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** How many unread messages, from whom, and the one command that reads them (message.rs `mail_pointer`). */
|
|
72
|
+
#pointer(unread) {
|
|
73
|
+
const senders = [];
|
|
74
|
+
for (const { envelope } of unread) {
|
|
75
|
+
const name = printable(envelope.from_name);
|
|
76
|
+
const label = envelope.kind === 'notice' ? 'a notice' : envelope.kind === 'user' ? 'your user' : envelope.kind === 'channel' ? `${name} (channel)` : envelope.kind === 'typed' ? `${name} (CC)` : name;
|
|
77
|
+
if (!senders.includes(label)) senders.push(label);
|
|
78
|
+
}
|
|
79
|
+
return `You have ${unread.length} unread message${unread.length === 1 ? '' : 's'} from other sessions (from ${senders.join(', ')}). They are not from your user. Read them with: supercode message inbox`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A line the person typed into `address`, filed read as theirs, once (mail_transcript.rs `file_prompt`). */
|
|
83
|
+
filePrompt(address, text, sentAtMs) {
|
|
84
|
+
if (!text.trim() || machineMade(text)) return false;
|
|
85
|
+
const id = typedLineId(address, sentAtMs, text);
|
|
86
|
+
if (this.store.findIn(address, id)) return false;
|
|
87
|
+
this.store.deliverRead(address, makeEnvelope({ id, created_at_ms: sentAtMs, from: makeAddress(parseAddress(address).machine, 'operator', 'typed'),
|
|
88
|
+
from_name: `typed in ${address} (unattributed)`, kind: 'user', reply_via: { mode: 'none' }, body: text }));
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---- a relay's hooks ----------------------------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
/** A relay's own folder, only one under this machine's relays: nothing outside it is read or written for a hook. */
|
|
95
|
+
#relay(directory) {
|
|
96
|
+
const dir = resolve(String(directory ?? ''));
|
|
97
|
+
if (!dir.startsWith(this.relaysRoot + sep) || dir.slice(this.relaysRoot.length + 1).includes(sep)) throw Object.assign(new Error(`${dir} is not a relay's folder`), { code: 400 });
|
|
98
|
+
return { dir, queue: join(dir, 'queue.json'), receipt: join(dir, 'receipt.json'), turn: join(dir, 'turn.txt'), sent: join(dir, 'sent.json') };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `message gate`: only a SendMessage equal to the queued one passes (ListAgents always); anything else is denied, and a
|
|
102
|
+
* queued send refused still answers its sender (claude_relay.rs `gate_decision`). */
|
|
103
|
+
gate(directory, input) {
|
|
104
|
+
const paths = this.#relay(directory);
|
|
105
|
+
const queued = readJson(paths.queue);
|
|
106
|
+
const reason = gateDecision(input ?? {}, queued);
|
|
107
|
+
if (reason == null) return null;
|
|
108
|
+
if (queued && !existsSync(paths.receipt)) { try { writeFileSync(paths.receipt, JSON.stringify({ denied: reason })); } catch { /* best kept */ } }
|
|
109
|
+
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `message relay-receipt`: Claude's own answer to the queued send, its error, or that the turn ended without one. */
|
|
113
|
+
receipt(directory, input) {
|
|
114
|
+
const paths = this.#relay(directory);
|
|
115
|
+
if (!existsSync(paths.queue) || existsSync(paths.receipt)) return null;
|
|
116
|
+
const event = input?.hook_event_name;
|
|
117
|
+
let receipt;
|
|
118
|
+
if (event === 'PostToolUse') receipt = { tool_response: input.tool_response ?? null };
|
|
119
|
+
else if (event === 'PostToolUseFailure') receipt = { send_failed: typeof input.error === 'string' ? input.error : input.error == null ? '' : JSON.stringify(input.error) };
|
|
120
|
+
else if (event === 'Stop' || event === 'StopFailure') {
|
|
121
|
+
// a turn that began before this send was queued (the last send's, still ending) is not its answer
|
|
122
|
+
const queued = readJson(paths.queue);
|
|
123
|
+
let prompt = null; try { prompt = readFileSync(paths.turn, 'utf8'); } catch { /* none recorded */ }
|
|
124
|
+
if (queued && prompt != null && !prompt.includes(sendTurn(queued))) return null;
|
|
125
|
+
const text = (key) => (typeof input[key] === 'string' ? input[key] : null);
|
|
126
|
+
const said = event === 'StopFailure' ? `${text('error_details') ?? text('last_assistant_message') ?? ''} (${text('error') ?? 'error'})` : text('last_assistant_message');
|
|
127
|
+
receipt = { turn_ended: true, said: said == null ? null : Array.from(said).slice(0, 300).join('') };
|
|
128
|
+
} else return null;
|
|
129
|
+
writeFileSync(paths.receipt, JSON.stringify(receipt));
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** `message relay-inbound`: mail a relay's Claude was handed is filed for the session it speaks for, threaded by what
|
|
134
|
+
* the relay last sent, and kept from the relay's model; its own send turns pass, recorded for the receipt. */
|
|
135
|
+
inbound(represented, directory, input, call = null) {
|
|
136
|
+
const paths = this.#relay(directory);
|
|
137
|
+
const prompt = typeof input?.prompt === 'string' ? input.prompt : '';
|
|
138
|
+
const trimmed = prompt.trimStart();
|
|
139
|
+
if (!(trimmed.startsWith(NATIVE_PEER_OPENING) || trimmed.startsWith(NATIVE_IDLE_NOTICE) || trimmed.startsWith(NATIVE_DELIVERY_NOTICE))) {
|
|
140
|
+
try { writeFileSync(paths.turn, prompt); } catch { /* best kept */ }
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
if (!parseAddress(represented)) throw Object.assign(new Error(`not an address: ${represented}`), { code: 400 });
|
|
144
|
+
const event = parseInbound(prompt);
|
|
145
|
+
// named by its hook call when it has one: filed once, however it reaches here (its answer, or its kept copy)
|
|
146
|
+
const id = typeof call === 'string' && /^[0-9a-f]{8,64}$/.test(call) ? `m-${blake3Hex(`relay-inbound\0${call}`).slice(0, 24)}` : newMessageId();
|
|
147
|
+
if (event?.kind === 'peer') {
|
|
148
|
+
// the replying Claude session runs beside the relay, on this machine
|
|
149
|
+
const sender = this.live.sessions().find((row) => row.door === 'native' && row.socket && row.socket === event.from.replace(/^uds:/, ''));
|
|
150
|
+
const sessionId = sender ? sender.address.split(':').slice(3).join(':') : null;
|
|
151
|
+
const lastSent = readJson(paths.sent) ?? {};
|
|
152
|
+
const inReplyTo = sessionId ? lastSent[sessionId] ?? null : null;
|
|
153
|
+
this.#fileHome(represented, makeEnvelope({ id, from: makeAddress(this.machine, 'claude-code', sessionId ?? 'unknown'),
|
|
154
|
+
from_name: sender ? sender.name : event.fromName ?? 'an unknown Claude session', kind: 'peer', reply_via: { mode: 'command' },
|
|
155
|
+
in_reply_to: inReplyTo, in_reply_to_inferred: inReplyTo != null, thread: inReplyTo ? this.threadOf(inReplyTo) : null,
|
|
156
|
+
native_from: event.from, body: event.body }));
|
|
157
|
+
} else if (event) {
|
|
158
|
+
this.#fileHome(represented, makeEnvelope({ id, from: makeAddress(this.machine, 'claude-code', 'notice'), from_name: 'Claude Code',
|
|
159
|
+
kind: 'notice', reply_via: { mode: 'none' }, body: event.text }));
|
|
160
|
+
}
|
|
161
|
+
return INBOUND_BLOCK;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A relay's inbound mail its hook kept while this daemon did not answer it (the front door's spool, outside the mail
|
|
165
|
+
* folder): each filed as the hook would have filed it, then let go; oldest first. */
|
|
166
|
+
drainSpool(dir) {
|
|
167
|
+
let names = [];
|
|
168
|
+
try { names = readdirSync(dir).filter((name) => name.endsWith('.json')).sort(); } catch { return 0; }
|
|
169
|
+
let filed = 0;
|
|
170
|
+
for (const name of names) {
|
|
171
|
+
const path = join(dir, name);
|
|
172
|
+
const spooled = readJson(path);
|
|
173
|
+
// one that cannot be read, or names no hook input, is no mail: let go; one whose filing fails is kept for the next
|
|
174
|
+
// drain (its id names its mail, so a filing that half happened is not made twice)
|
|
175
|
+
if (spooled?.input) {
|
|
176
|
+
try { this.inbound(spooled.represented, spooled.directory, spooled.input, spooled.call); filed++; }
|
|
177
|
+
catch (error) { this.log(`relay inbound: kept mail ${name} not filed yet (${error.message}); kept for the next drain`); continue; }
|
|
178
|
+
}
|
|
179
|
+
rmSync(path, { force: true });
|
|
180
|
+
}
|
|
181
|
+
return filed;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Filed for the session a relay speaks for: here, then handed to its door (not waited on); for another machine, with
|
|
185
|
+
* a wake, so the carrier carries it there with its retries. */
|
|
186
|
+
#fileHome(represented, envelope) {
|
|
187
|
+
// not filed is a failed filing, never a quiet one: the hook keeps the mail (its spool), and a drain keeps its file
|
|
188
|
+
this.store.deliver(represented, envelope);
|
|
189
|
+
if (parseAddress(represented).machine === this.machine) void Promise.resolve(this.handOver(represented, envelope.id)).catch((error) => this.log(`relay inbound: ${envelope.id} stays in ${represented}'s mailbox: ${error.message}`));
|
|
190
|
+
else this.store.requestWake(represented, envelope.id);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---- `message native-question-hook <binding>` ---------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
/** One native question hook call (mail_question.rs `hook`): the session's creator bound at its start; a Claude
|
|
196
|
+
* AskUserQuestion filed as mail before the tool runs and answered by its reply (waited for while the hook's process
|
|
197
|
+
* lives), and confirmed after it ran. `questions` is the daemon's NativeQuestions. */
|
|
198
|
+
async questionHook(seedPath, input, claim, questions) {
|
|
199
|
+
const seedFile = resolve(String(seedPath ?? ''));
|
|
200
|
+
const launches = resolve(this.mailRoot, 'native-launches');
|
|
201
|
+
if (!seedFile.startsWith(launches + sep)) throw Object.assign(new Error(`${seedFile} is not a native launch's binding`), { code: 400 });
|
|
202
|
+
const seed = readJson(seedFile);
|
|
203
|
+
if (!seed || (seed.kind !== 'codex' && seed.kind !== 'claude-code')) throw new Error('unsupported native question harness');
|
|
204
|
+
const binding = questions.bind(seed, { session_id: input?.session_id, transcript_path: typeof input?.transcript_path === 'string' ? input.transcript_path : null }, seed.kind);
|
|
205
|
+
if (input?.hook_event_name === 'SessionStart' && binding.transcript_path) {
|
|
206
|
+
// this launch's baseline precedes its native initial prompt, including on resume
|
|
207
|
+
const receipt = `${seedFile.replace(/\.json$/, '')}.session.json`;
|
|
208
|
+
let offset = 0; try { offset = statSync(binding.transcript_path).size; } catch { /* not there yet */ }
|
|
209
|
+
try { questions.create(receipt, { path: binding.transcript_path, offset }); } catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
210
|
+
}
|
|
211
|
+
if (input?.tool_name !== 'AskUserQuestion') return {};
|
|
212
|
+
const tool = input.tool_input ?? {};
|
|
213
|
+
const asked = (Array.isArray(tool.questions) ? tool.questions : []).map((question) => ({ ...question, id: question.question }));
|
|
214
|
+
if (typeof input.tool_use_id !== 'string') throw new Error('native question tool_use_id missing');
|
|
215
|
+
const native = { threadId: binding.session.split(':').slice(3).join(':'), turnId: 'claude', itemId: input.tool_use_id, questions: asked };
|
|
216
|
+
const id = questions.idOf(binding.session, native);
|
|
217
|
+
const answersOf = (reply) => Object.fromEntries(Object.entries(reply?.answers ?? {}).map(([key, value]) => [key, value?.answers?.[0] ?? null]));
|
|
218
|
+
if (input.hook_event_name === 'PostToolUse') {
|
|
219
|
+
const reply = readJson(join(this.questions, `${id}.reply`));
|
|
220
|
+
if (reply && JSON.stringify(input.tool_response?.answers ?? null) === JSON.stringify(answersOf(reply))) questions.confirm(id, reply);
|
|
221
|
+
return {};
|
|
222
|
+
}
|
|
223
|
+
if (input.hook_event_name !== 'PreToolUse') return {};
|
|
224
|
+
// one hook owns this wait, marked by its process: a hook its harness killed ends the wait, and never answers
|
|
225
|
+
const waiting = join(this.questions, `${id}.waiting`);
|
|
226
|
+
questions.create(waiting, { pid: claim?.pid ?? null });
|
|
227
|
+
try {
|
|
228
|
+
questions.file(binding, native);
|
|
229
|
+
for (;;) {
|
|
230
|
+
const reply = readJson(join(this.questions, `${id}.reply`));
|
|
231
|
+
if (reply) return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', updatedInput: { ...tool, answers: answersOf(reply) } } };
|
|
232
|
+
if (claim?.pid && !alive(Number(claim.pid))) return {};
|
|
233
|
+
await new Promise((done) => setTimeout(done, 250));
|
|
234
|
+
}
|
|
235
|
+
} finally { rmSync(waiting, { force: true }); }
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** One PreToolUse call of a relay: null allows it, a reason denies it (claude_relay.rs `gate_decision`). */
|
|
240
|
+
export function gateDecision(input, queued) {
|
|
241
|
+
const tool = typeof input.tool_name === 'string' ? input.tool_name : '';
|
|
242
|
+
if (tool === 'ListAgents') return null;
|
|
243
|
+
if (tool !== 'SendMessage') return `a relay may not use ${tool}`;
|
|
244
|
+
if (!queued) return 'nothing is queued to send; mail for the host is not answered by the relay';
|
|
245
|
+
const to = typeof input.tool_input?.to === 'string' ? input.tool_input.to : '';
|
|
246
|
+
const message = typeof input.tool_input?.message === 'string' ? input.tool_input.message : '';
|
|
247
|
+
if (to !== queued.to) return `not the queued outbound message: \`to\` is ${JSON.stringify(to)}, the queue says ${JSON.stringify(queued.to)}`;
|
|
248
|
+
if (message !== queued.message) {
|
|
249
|
+
// the first character that differs, at its byte in what was sent; each text from that byte (none from a byte inside
|
|
250
|
+
// one of its characters), as the native gate said it
|
|
251
|
+
const sent = Array.from(message), want = Array.from(queued.message);
|
|
252
|
+
let at = 0, index = 0;
|
|
253
|
+
while (index < sent.length && index < want.length && sent[index] === want[index]) { at += Buffer.byteLength(sent[index]); index++; }
|
|
254
|
+
if (index >= sent.length || index >= want.length) at = Math.min(Buffer.byteLength(message), Buffer.byteLength(queued.message));
|
|
255
|
+
const tail = (text) => { const bytes = Buffer.from(text); if (at > bytes.length || (at < bytes.length && (bytes[at] & 0xc0) === 0x80)) return ''; return Array.from(bytes.subarray(at).toString('utf8')).slice(0, 40).join(''); };
|
|
256
|
+
return `not the queued outbound message: \`message\` (${Buffer.byteLength(message)} bytes) differs from the queue (${Buffer.byteLength(queued.message)} bytes) at byte ${at}: sent ${JSON.stringify(tail(message))}, queued ${JSON.stringify(tail(queued.message))}`;
|
|
257
|
+
}
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** An inbound peer envelope or notice in the text Claude handed a relay (claude_relay.rs `parse_inbound_text`). */
|
|
262
|
+
export function parseInbound(text) {
|
|
263
|
+
const trimmed = text.trimStart();
|
|
264
|
+
if (trimmed.startsWith(NATIVE_IDLE_NOTICE) || trimmed.startsWith(NATIVE_DELIVERY_NOTICE)) return { kind: 'notice', text: trimmed };
|
|
265
|
+
if (!trimmed.startsWith(NATIVE_PEER_PREAMBLE) && !trimmed.startsWith(NATIVE_PEER_OPENING)) return null;
|
|
266
|
+
const start = text.indexOf(NATIVE_PEER_OPENING);
|
|
267
|
+
if (start < 0) return null;
|
|
268
|
+
const headerEnd = text.indexOf('>\n', start);
|
|
269
|
+
if (headerEnd < 0) return null;
|
|
270
|
+
const header = text.slice(start + NATIVE_PEER_OPENING.length, headerEnd);
|
|
271
|
+
const bodyStart = headerEnd + 2;
|
|
272
|
+
// the body is not escaped by Claude: the closing tag is the last one
|
|
273
|
+
const bodyEnd = text.lastIndexOf(`\n${NATIVE_PEER_CLOSING}`);
|
|
274
|
+
if (bodyEnd < bodyStart) return null;
|
|
275
|
+
const attributes = {};
|
|
276
|
+
// each key="value" in turn, a later one of the same key in its place (claude_relay.rs `parse_attributes`)
|
|
277
|
+
for (let rest = header, equals; (equals = rest.indexOf('="')) >= 0;) {
|
|
278
|
+
const close = rest.indexOf('"', equals + 2);
|
|
279
|
+
if (close < 0) break;
|
|
280
|
+
attributes[rest.slice(0, equals).trim()] = rest.slice(equals + 2, close);
|
|
281
|
+
rest = rest.slice(close + 1);
|
|
282
|
+
}
|
|
283
|
+
if (!attributes.from) return null;
|
|
284
|
+
return { kind: 'peer', from: attributes.from, fromName: attributes['from-name'] ?? null, body: text.slice(bodyStart, bodyEnd) };
|
|
285
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Native Codex questions as mail (step 4c; mail_question.rs `poll`, deleted): every POLL_MS the daemon asks each Codex
|
|
2
|
+
// pane it launched (its native-launches seed: a socket of its own, its owner alive) for the threads it runs and the
|
|
3
|
+
// questions waiting on them, records each thread's creator (`native-questions/binding-<blake3(address)>.json`) and files
|
|
4
|
+
// each question once (`<q-id>.json`) in the session's mailbox, read, and in its creator's, with a wake, so the carrier
|
|
5
|
+
// hands it over (or carries it to the creator's machine). A reply is answered by mail/send.mjs; the files are the native
|
|
6
|
+
// hook's (`message hook`) as well, so they are kept byte for byte as mail_question.rs writes them. Native request
|
|
7
|
+
// identities decide which question a reply answers, never screen text or terminal keys.
|
|
8
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, writeSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { blake3Hex } from './envelope.mjs';
|
|
11
|
+
import { envelope as makeEnvelope, makeAddress } from './compose.mjs';
|
|
12
|
+
|
|
13
|
+
const POLL_MS = 10_000;
|
|
14
|
+
|
|
15
|
+
const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
|
|
16
|
+
const alive = (pid) => { if (!Number.isInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
|
|
17
|
+
|
|
18
|
+
export class NativeQuestions {
|
|
19
|
+
/** `codexQuestions(request)` the in-process native question client (native-questions.mjs). */
|
|
20
|
+
constructor({ machine, mailRoot, store, codexQuestions, log = () => {} }) {
|
|
21
|
+
Object.assign(this, { machine, mailRoot, store, codexQuestions, log });
|
|
22
|
+
this.dir = join(mailRoot, 'native-questions');
|
|
23
|
+
this.running = false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
start() {
|
|
27
|
+
this.timer = setInterval(() => void this.poll(), POLL_MS);
|
|
28
|
+
this.timer.unref?.();
|
|
29
|
+
void this.poll();
|
|
30
|
+
return this;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
stop() { clearInterval(this.timer); }
|
|
34
|
+
|
|
35
|
+
/** One pass over the launched Codex panes; a pass still running is not started twice. Answers how many panes were
|
|
36
|
+
* asked and how many questions they hold, or null when a pass was already running. */
|
|
37
|
+
async poll() {
|
|
38
|
+
if (this.running) return null;
|
|
39
|
+
this.running = true;
|
|
40
|
+
const counted = { panes: 0, questions: 0 };
|
|
41
|
+
try {
|
|
42
|
+
let names = [];
|
|
43
|
+
try { names = readdirSync(join(this.mailRoot, 'native-launches')); } catch { return counted; }
|
|
44
|
+
for (const name of names) {
|
|
45
|
+
if (!name.endsWith('.json') || name.endsWith('.session.json')) continue;
|
|
46
|
+
const seedPath = join(this.mailRoot, 'native-launches', name);
|
|
47
|
+
const seed = readJson(seedPath);
|
|
48
|
+
// only a socket belonging to a launched pane whose owner lives: a retained socket file is not a live endpoint,
|
|
49
|
+
// and a global shared app-server or a cwd is never guessed at
|
|
50
|
+
if (seed?.kind !== 'codex' || typeof seed.socketPath !== 'string' || !alive(seed.ownerPid) || !existsSync(seed.socketPath)) continue;
|
|
51
|
+
let reply;
|
|
52
|
+
try { reply = await this.codexQuestions({ socketPath: seed.socketPath, discover: true }); } catch { continue; }
|
|
53
|
+
counted.panes++; counted.questions += reply?.questions?.length ?? 0;
|
|
54
|
+
for (const thread of reply?.threads ?? []) {
|
|
55
|
+
try { this.bind(seed, { session_id: thread.id, transcript_path: thread.path ?? null }); }
|
|
56
|
+
catch (error) { this.log(`native questions: the creator of ${thread.id} could not be recorded: ${error.message}`); }
|
|
57
|
+
}
|
|
58
|
+
for (const native of reply?.questions ?? []) {
|
|
59
|
+
try { this.file(this.bind(seed, { session_id: native.threadId }), native); }
|
|
60
|
+
catch (error) { this.log(`native questions: a question of ${native.threadId} could not be filed: ${error.message}`); }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return counted;
|
|
64
|
+
} finally { this.running = false; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---- the files, as mail_question.rs writes them ------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
#bindingPath(address) { return join(this.dir, `binding-${blake3Hex(address)}.json`); }
|
|
70
|
+
|
|
71
|
+
/** Written whole and synced, never over an existing file (mail_question.rs `create`). */
|
|
72
|
+
create(path, value) {
|
|
73
|
+
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
74
|
+
const fd = openSync(path, 'wx', 0o600);
|
|
75
|
+
try { writeSync(fd, JSON.stringify(value)); fsyncSync(fd); } finally { closeSync(fd); }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A session's binding: its creator recorded at its first launch, its pane, socket and transcript kept current. */
|
|
79
|
+
bind(seed, input, harness = 'codex') {
|
|
80
|
+
const session = makeAddress(this.machine, harness, String(input.session_id ?? ''));
|
|
81
|
+
if (!session) throw new Error('the thread has no session id');
|
|
82
|
+
const creator = typeof seed.by === 'string' ? seed.by : makeAddress(this.machine, 'operator', 'user');
|
|
83
|
+
const binding = { session, creator, pane: seed.pane ?? '', socket_path: seed.socketPath ?? null, transcript_path: input.transcript_path ?? null };
|
|
84
|
+
const path = this.#bindingPath(session);
|
|
85
|
+
try { this.create(path, binding); return binding; }
|
|
86
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
87
|
+
const existing = readJson(path);
|
|
88
|
+
if (!existing) throw new Error(`its binding ${path} could not be read`);
|
|
89
|
+
if (existing.pane !== binding.pane || existing.socket_path !== binding.socket_path || (binding.transcript_path != null && existing.transcript_path !== binding.transcript_path)) {
|
|
90
|
+
existing.pane = binding.pane; existing.socket_path = binding.socket_path;
|
|
91
|
+
if (binding.transcript_path != null) existing.transcript_path = binding.transcript_path;
|
|
92
|
+
const staged = `${path.slice(0, -'.json'.length)}.${process.pid}.tmp`;
|
|
93
|
+
this.create(staged, existing);
|
|
94
|
+
renameSync(staged, path);
|
|
95
|
+
}
|
|
96
|
+
return existing;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A question's id, from its native identity (mail_question.rs `question_id`): each poll and hook finds the same one. */
|
|
100
|
+
idOf(session, native) {
|
|
101
|
+
return `q-${blake3Hex(JSON.stringify([session, native.threadId ?? null, native.turnId ?? null, native.itemId ?? null])).slice(0, 24)}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One question filed once. */
|
|
105
|
+
file(binding, native) {
|
|
106
|
+
const id = this.idOf(binding.session, native);
|
|
107
|
+
let body = '';
|
|
108
|
+
for (const question of native.questions ?? []) {
|
|
109
|
+
body += `${question.id ?? ''}: ${question.question ?? ''}\n`;
|
|
110
|
+
for (const option of question.options ?? []) body += `- ${option.label ?? ''}\n`;
|
|
111
|
+
}
|
|
112
|
+
body += '\nReply with the answer text for one question, or a JSON object mapping each question id to its answer for several questions.';
|
|
113
|
+
let question = { binding, native, envelope: makeEnvelope({ id, from: binding.session, from_name: binding.session, kind: 'question', reply_via: { mode: 'command' }, body }) };
|
|
114
|
+
try { this.create(join(this.dir, `${id}.json`), question); }
|
|
115
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; question = readJson(join(this.dir, `${id}.json`)) ?? question; }
|
|
116
|
+
this.store.deliverRead(binding.session, question.envelope);
|
|
117
|
+
// its creator's copy, filed once with a wake: the carrier hands it over, here or on the creator's machine (whose
|
|
118
|
+
// copy kept here is the session's timeline's mirror, marked read once carried)
|
|
119
|
+
if (!this.store.findIn(binding.creator, id)) { this.store.deliver(binding.creator, question.envelope); this.store.requestWake(binding.creator, id); }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A reply the native harness used confirmed (mail_question.rs `confirm`): the reply read in the session's mailbox, and
|
|
123
|
+
* when it was used recorded once (`<q-id>.delivered`, which a listing reads as its delivery). */
|
|
124
|
+
confirm(id, reply) {
|
|
125
|
+
const question = readJson(join(this.dir, `${id}.json`));
|
|
126
|
+
if (!question) throw new Error(`question ${id} is not on file`);
|
|
127
|
+
const found = reply?.envelope?.id ? this.store.findIn(question.binding.session, reply.envelope.id) : null;
|
|
128
|
+
if (found && found.state === 'unread') { try { this.store.markRead(question.binding.session, found.path); } catch { /* read meanwhile */ } }
|
|
129
|
+
try { this.create(join(this.dir, `${id}.delivered`), Date.now()); } catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
130
|
+
}
|
|
131
|
+
}
|
package/mail/store.mjs
CHANGED
|
@@ -188,9 +188,6 @@ export class MailStore extends EventEmitter {
|
|
|
188
188
|
|
|
189
189
|
// ---- writes, as mailbox.rs makes them --------------------------------------------------------------------------
|
|
190
190
|
|
|
191
|
-
/** The unread mail of `address` (not the user's own turns) moved into `claimed/` under its reader's pid (`pid`, the
|
|
192
|
-
* reading process: a claim whose reader ends goes back to `new/`), each claim recorded; another reader that took one
|
|
193
|
-
* first keeps it (mailbox.rs `claim_unread`). Oldest first. */
|
|
194
191
|
/** Whether `claimUnread` would take anything now, taking nothing: abandoned claims given back first, as it does, then
|
|
195
192
|
* any envelope in new/ that is not a user's own turn (`message wait` wakes only for mail it can claim). From the
|
|
196
193
|
* mailbox's own folders, as claimUnread reads them. */
|
|
@@ -207,6 +204,9 @@ export class MailStore extends EventEmitter {
|
|
|
207
204
|
});
|
|
208
205
|
}
|
|
209
206
|
|
|
207
|
+
/** The unread mail of `address` (not the user's own turns) moved into `claimed/` under its reader's pid (`pid`, the
|
|
208
|
+
* reading process: a claim whose reader ends goes back to `new/`), each claim recorded; another reader that took one
|
|
209
|
+
* first keeps it (mailbox.rs `claim_unread`). Oldest first. */
|
|
210
210
|
claimUnread(address, via, caller, pid = process.pid) {
|
|
211
211
|
// the mailbox's own folders, read here (one named read): what decides a delivery never comes from the watch-fed
|
|
212
212
|
// index alone, whose events a platform can drop (an FSEvents drop, an inotify overflow)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.119",
|
|
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",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"@volter/supercode-health": "^0.1.0",
|
|
71
71
|
"@volter/supercode-terminal": "^0.2.54",
|
|
72
72
|
"@volter/supercode-trace": "^0.1.0",
|
|
73
|
-
"@volter/teams": "0.2.
|
|
73
|
+
"@volter/teams": "0.2.34",
|
|
74
74
|
"@xterm/addon-serialize": "^0.14.0",
|
|
75
75
|
"@xterm/headless": "^6.0.0",
|
|
76
76
|
"protobufjs": "8.8.0",
|