@volter/supercode-teams 0.3.149 → 0.3.151
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 +28 -0
- package/machine.mjs +82 -3
- package/mail/continuation.mjs +101 -0
- package/mail/send.mjs +77 -24
- package/package.json +1 -1
- package/remote-close.mjs +81 -0
package/README.md
CHANGED
|
@@ -22,6 +22,15 @@ the matching harness SDK and native core; source capabilities and fidelity remai
|
|
|
22
22
|
Fresh-session materialization supports Claude Code and Codex with requested `value_lossless`;
|
|
23
23
|
telemetry alone does not establish resumable native history.
|
|
24
24
|
|
|
25
|
+
The node door's `harness.v1.close.route` reads a remote close route and the
|
|
26
|
+
verified caller at its local origin. `harness.v1.sessions.close_remote` accepts
|
|
27
|
+
that forwarded caller at the target's Teams door only, under pane control. It
|
|
28
|
+
retains native live-work checks, then calls the existing pane-close door to
|
|
29
|
+
recheck its grant, recorded starter and host entry. It adds no caller identity
|
|
30
|
+
or origin-attestation protocol. A nonstarter needs explicit
|
|
31
|
+
`foreign: true` for that one pane. These are node-door methods, not additions to
|
|
32
|
+
the native harness service. [Scope and qualification](../../docs/evidence/remote-pane-close-2026-10-08.md).
|
|
33
|
+
|
|
25
34
|
See the [public client declarations](workspace-client.d.ts), [contracts](contracts/index.d.ts),
|
|
26
35
|
and [implementation specification](../../docs/plans/teams-server-build/README.md).
|
|
27
36
|
|
|
@@ -169,6 +178,25 @@ its token or removing an origin closes the channels already admitted; a door lef
|
|
|
169
178
|
off. While its port is taken the daemon retries every ten seconds, and `status` shows why it is not
|
|
170
179
|
listening (app-door.state.json).
|
|
171
180
|
|
|
181
|
+
## Native mail continuation
|
|
182
|
+
|
|
183
|
+
Mail to an old Claude Code address follows only the harness's explicit terminal
|
|
184
|
+
`continued-in` record to a live UUID on the same machine. It never infers a route
|
|
185
|
+
from a name or working directory. The send result and receipt retain
|
|
186
|
+
`continuation: { addressed, recipient, chain }`; send logs retain that metadata.
|
|
187
|
+
The existing plain-text sent markers and native relay behavior are unchanged.
|
|
188
|
+
If the receiver rolls over between an attempt and a same-`--id` repeat, delivery
|
|
189
|
+
to its successor can duplicate the message (separate task t_65153cfb). This
|
|
190
|
+
routing correction does not promise exactly-once delivery across rollover.
|
|
191
|
+
The target daemon rechecks mail grants on all routed addresses. A grant on only
|
|
192
|
+
the old address does not cover its continuation. Caller identity, pane ownership,
|
|
193
|
+
history access and old mailbox contents are unchanged.
|
|
194
|
+
|
|
195
|
+
The named lookup reads at most 64 KiB per transcript tail and follows at most
|
|
196
|
+
16 links within a shared 4,096-project-entry / 750 ms work budget. Incomplete,
|
|
197
|
+
changed, conflicting or cyclic records are refused. The filesystem reads are
|
|
198
|
+
asynchronous; an in-progress OS read is not cancelled by the elapsed budget.
|
|
199
|
+
|
|
172
200
|
## Files (under `$SUPERCODE_HOME/teams`)
|
|
173
201
|
|
|
174
202
|
`machine.key|pub` (the host key; it signs `describe` and nothing else) · `machine.sock` ·
|
package/machine.mjs
CHANGED
|
@@ -35,6 +35,7 @@ import { askBoardOwner, BOARD_OPERATION_VERBS, boardHome } from '@volter/teams/b
|
|
|
35
35
|
import { openMachine, normalMachineName } from '@volter/teams/machine-door';
|
|
36
36
|
import { RecordHolder, RecordProducer, RECENT_PER_MAILBOX, RECORDS_NOT_GRANTED } from './machine-records.mjs';
|
|
37
37
|
import { LiveSessions, ACTING_VARIABLES } from './live-sessions.mjs';
|
|
38
|
+
import { checkRemoteClose } from './remote-close.mjs';
|
|
38
39
|
import { MailStore } from './mail/store.mjs';
|
|
39
40
|
import { MailReads, panePrompt } from './mail/reads.mjs';
|
|
40
41
|
import { render } from './mail/envelope.mjs';
|
|
@@ -796,6 +797,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
796
797
|
return result.waits ?? result;
|
|
797
798
|
};
|
|
798
799
|
this.mailSend = new MailSend({ machine, mailRoot, live, store: this.mailStore, agents: this.agents, relays: this.relays,
|
|
800
|
+
claudeProjects: () => [...live.claudeDirs.keys()].map((dir) => join(dirname(dir), 'projects')),
|
|
799
801
|
// a send waits on its receiver's door there (a relay's receipt takes up to 90 s), never past MAIL_SEND_MS
|
|
800
802
|
askMachine: (to, request) => this.#askMailDoor(to, request, MAIL_SEND_MS),
|
|
801
803
|
deliverRuntime: deliverToRuntime, codexAnswer: (request) => codexQuestions(request),
|
|
@@ -1597,7 +1599,18 @@ export class TeamsMachine extends MachineCore {
|
|
|
1597
1599
|
const link = linkOf(principal, entry.principalName ?? null);
|
|
1598
1600
|
void (async () => {
|
|
1599
1601
|
let answer;
|
|
1600
|
-
try { answer = request.op === 'file' ? this.#fileHere(request, link) : await this.#sendHere(request, link)
|
|
1602
|
+
try { answer = request.op === 'file' ? this.#fileHere(request, link) : await this.#sendHere(request, link, async (addresses) => {
|
|
1603
|
+
// Native continuation changes a recipient, not a grant. Re-derive this stream's admission after the named
|
|
1604
|
+
// transcript read, and require mail on every address in its route before the send can file anything.
|
|
1605
|
+
if (stream.closed) return 'the mail stream closed before delivery';
|
|
1606
|
+
const fresh = entry.verify ? await entry.verify() : entry.admitted;
|
|
1607
|
+
if (stream.closed) return 'the mail stream closed before delivery';
|
|
1608
|
+
if (fresh) entry.admitted = fresh;
|
|
1609
|
+
const missing = addresses.find(address => !holds(fresh, 'mail', `mail:${address}`));
|
|
1610
|
+
if (!missing) return null;
|
|
1611
|
+
this.log.append({ principal, cap: 'mail', target: `mail:${missing}`, outcome: 'refused', detail: 'receiver grant' });
|
|
1612
|
+
return `no live mail grant for mail:${missing}`;
|
|
1613
|
+
}); }
|
|
1601
1614
|
catch (error) { answer = { outcome: 'failed', detail: error.message }; }
|
|
1602
1615
|
this.log.append({ principal, cap: 'mail', target, outcome: answer.outcome === 'failed' || (answer.code ?? 0) !== 0 ? 'failed' : request.op, detail: answer.outcome === 'failed' ? String(answer.detail).slice(0, 200) : undefined });
|
|
1603
1616
|
if (stream.closed) return;
|
|
@@ -1609,14 +1622,14 @@ export class TeamsMachine extends MachineCore {
|
|
|
1609
1622
|
/** A send through the mail door, for a session here: one for another machine is
|
|
1610
1623
|
* refused when it came through a link (never forwarded on this machine's own credential), and this machine's own
|
|
1611
1624
|
* daemon sends on through the one way out. */
|
|
1612
|
-
async #sendHere(request, link) {
|
|
1625
|
+
async #sendHere(request, link, authorizeRecipients) {
|
|
1613
1626
|
const from = parseAddress(request.from)?.address;
|
|
1614
1627
|
if (!from) return { code: 2, text: `\`${request.from}\` is not a session address; addresses look like sc:<machine>:<harness>:<session-id>`, receipt: null };
|
|
1615
1628
|
const text = (key) => (typeof request[key] === 'string' ? request[key] : null);
|
|
1616
1629
|
return this.mailSend.send({ address: from, name: text('from_name') ?? '' }, text('to') ?? '', text('body') ?? '', {
|
|
1617
1630
|
subject: text('subject'), in_reply_to: text('in_reply_to'), notify_when_idle: request.notify_when_idle === true, queue: request.queue === true,
|
|
1618
1631
|
id: text('id'), notice: request.notice === true, thread: text('thread'),
|
|
1619
|
-
}, { link });
|
|
1632
|
+
}, { link, authorizeRecipients });
|
|
1620
1633
|
}
|
|
1621
1634
|
|
|
1622
1635
|
/** A message another machine's mailbox carried here, filed in a mailbox here: the
|
|
@@ -3732,6 +3745,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
3732
3745
|
// says so (`foreign`), one pane at a time, by name: a listing piped into close is refused pane by pane rather
|
|
3733
3746
|
// than ending every session on the machine (docs/adr/0004-pane-close-authority.md).
|
|
3734
3747
|
const starting = [...this.pendingStartups.values()].filter((pending) => pending.pane === params.pane);
|
|
3748
|
+
if (params.expected && starting.length) throw Object.assign(new Error(`pane ${params.pane} began starting during its close check; nothing was closed`), { code: 409 });
|
|
3735
3749
|
// only a pane here (open, or still starting) is anyone's: one this machine lacks is answered as absent, before
|
|
3736
3750
|
// the caller is asked for
|
|
3737
3751
|
const here = starting.length > 0 || Boolean(await this.#paneByContext(params.pane));
|
|
@@ -3750,6 +3764,10 @@ export class TeamsMachine extends MachineCore {
|
|
|
3750
3764
|
act('close', `pane:${params.pane}`, 'ok', 'already absent');
|
|
3751
3765
|
return { closed: params.pane, alreadyAbsent: true };
|
|
3752
3766
|
}
|
|
3767
|
+
// The remote session-close wrapper checked this exact host entry before reaching this existing door. A
|
|
3768
|
+
// changed pane must not inherit that check; ordinary pane-close callers keep their existing behavior.
|
|
3769
|
+
if (params.expected && (session.id !== params.expected.id || session.rootPid !== params.expected.rootPid
|
|
3770
|
+
|| session.dead !== params.expected.dead)) throw Object.assign(new Error(`pane ${params.pane} changed during its close check; nothing was closed`), { code: 409 });
|
|
3753
3771
|
await this.host.closeSession(session.id);
|
|
3754
3772
|
act('close', `pane:${params.pane}`, 'ok', params.foreign === true ? `foreign, by ${closeBy ?? 'a person'}` : null);
|
|
3755
3773
|
this.#panesChanged();
|
|
@@ -4284,6 +4302,67 @@ export class TeamsMachine extends MachineCore {
|
|
|
4284
4302
|
if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('a caller is resolved only for a process on this machine'), { code: 403 });
|
|
4285
4303
|
return { caller: await this.#actorOf(entry, params) };
|
|
4286
4304
|
}
|
|
4305
|
+
case 'harness.v1.close.route': {
|
|
4306
|
+
if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('a close route is read only at its origin machine'), { code: 403 });
|
|
4307
|
+
const target = String(params.target ?? '');
|
|
4308
|
+
if (!target || target.length > 500) throw Object.assign(new Error('close names one target'), { code: 400 });
|
|
4309
|
+
const self = normalMachineName(machineName(this.env));
|
|
4310
|
+
let machine, address = null;
|
|
4311
|
+
const exact = parseAddress(target);
|
|
4312
|
+
if (exact) { machine = exact.machine; address = target; }
|
|
4313
|
+
else if (target.includes('@')) machine = target.slice(target.lastIndexOf('@') + 1);
|
|
4314
|
+
else if (/^p_[A-Za-z0-9_-]+$/.test(target)) {
|
|
4315
|
+
if (await this.#paneByContext(target)) return { remote: false };
|
|
4316
|
+
const found = Object.entries(this.records?.holder.read() ?? {}).filter(([, held]) => held.record?.panes?.panes?.some(row => row.pane === target && !row.dead));
|
|
4317
|
+
if (found.length > 1) throw Object.assign(new Error(`pane ${target} is recorded on more than one machine; nothing was closed`), { code: 409 });
|
|
4318
|
+
machine = found[0]?.[0];
|
|
4319
|
+
} else {
|
|
4320
|
+
const resolved = this.mail.teamAddress(target, name => this.mailSend.remembered(name));
|
|
4321
|
+
if (resolved.error) throw Object.assign(new Error(resolved.error), { code: 409 });
|
|
4322
|
+
if (resolved.to) { address = resolved.to; machine = parseAddress(address)?.machine; }
|
|
4323
|
+
}
|
|
4324
|
+
if (!machine || normalMachineName(machine) === self) return { remote: false };
|
|
4325
|
+
let caller;
|
|
4326
|
+
try { caller = await this.#actorOf(entry, params); }
|
|
4327
|
+
catch (error) { if (params.foreign !== true) throw error; caller = null; }
|
|
4328
|
+
let identity = null;
|
|
4329
|
+
if (caller) identity = readJson(join(this.home, 'agent-session-contexts.json'), { sessions: {} }).sessions[caller.split(':').slice(2).join(':')] ?? null;
|
|
4330
|
+
return { remote: true, machine, target: address ?? target, caller, agent: identity?.agent ?? null, context: identity?.context ?? null };
|
|
4331
|
+
}
|
|
4332
|
+
case 'harness.v1.sessions.close_remote': {
|
|
4333
|
+
// The same origin-resolved caller and Teams admission as remote panes.close. This wrapper adds the native
|
|
4334
|
+
// session's close safeguards, then calls that existing pane door; it creates no new caller identity.
|
|
4335
|
+
if (entry.principal === 'local' || entry.appDoor) throw Object.assign(new Error('remote close is called through the target machine\'s Teams door'), { code: 403 });
|
|
4336
|
+
const target = String(params.target ?? '');
|
|
4337
|
+
if (!target || target.length > 500) throw Object.assign(new Error('remote close names one target'), { code: 400 });
|
|
4338
|
+
let pane = /^p_[A-Za-z0-9_-]+$/.test(target) ? target : null;
|
|
4339
|
+
if (!pane) {
|
|
4340
|
+
const resolved = this.mail.resolve(target);
|
|
4341
|
+
if (resolved.error) throw Object.assign(new Error(resolved.error.replace(/Nothing was sent/g, 'Nothing was closed')), { code: 404 });
|
|
4342
|
+
const live = this.live.sessions().find(row => row.address === resolved.address);
|
|
4343
|
+
pane = live ? this.mail.paneOf(live) : null;
|
|
4344
|
+
if (!pane) return { native: true };
|
|
4345
|
+
}
|
|
4346
|
+
need('control', `pane:${pane}`);
|
|
4347
|
+
const caller = await this.#actorOf(entry, params);
|
|
4348
|
+
const foreign = params.foreign === true;
|
|
4349
|
+
const session = await this.#paneByContext(pane);
|
|
4350
|
+
if (!session) return { closed: pane, alreadyAbsent: true };
|
|
4351
|
+
if (this.pendingStartups && [...this.pendingStartups.values()].some(row => row.pane === pane)) throw Object.assign(new Error(`pane ${pane} is still starting; nothing was closed`), { code: 409 });
|
|
4352
|
+
this.#refuseForeign(pane, caller, foreign);
|
|
4353
|
+
const recheck = !foreign ? await checkRemoteClose({ pane, session, kind: this.#paneKind(pane, session),
|
|
4354
|
+
live: this.live.sessions().filter(row => this.mail.paneOf(row) === pane), native: this.supercodeBin, env: this.env,
|
|
4355
|
+
activity: pid => this.#serveRequest('harness.v1.sessions.activity_under', { pids: [pid] }, 4000) }) : null;
|
|
4356
|
+
// Every await above can admit a replacement pane, a changed grant or a conflicting launch attempt.
|
|
4357
|
+
const current = await this.#paneByContext(pane);
|
|
4358
|
+
if (!current) return { closed: pane, alreadyAbsent: true };
|
|
4359
|
+
if (current.id !== session.id || current.rootPid !== session.rootPid || current.dead !== session.dead) throw Object.assign(new Error(`pane ${pane} changed during its close check; nothing was closed`), { code: 409 });
|
|
4360
|
+
if (recheck) await recheck();
|
|
4361
|
+
// Re-enter the ordinary close door: re-admit its current grant, re-read its starter and current host entry,
|
|
4362
|
+
// and close through the same host operation that already works for the starter's remote panes.close.
|
|
4363
|
+
return this.#dispatch(entry, 'panes.close', { pane, caller, foreign,
|
|
4364
|
+
expected: { id: session.id, rootPid: session.rootPid, dead: session.dead } });
|
|
4365
|
+
}
|
|
4287
4366
|
case 'harness.v1.boards.register': case 'harness.v1.boards.list': case 'harness.v1.boards.unregister': {
|
|
4288
4367
|
// A board home this machine keeps, published to its team by its connector (board-events.mjs).
|
|
4289
4368
|
if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
|
|
@@ -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/send.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { blake3Hex, render, shortId } from './envelope.mjs';
|
|
|
10
10
|
import { AGENT_HARNESS, RESUME_WAIT_MS } from './agents.mjs';
|
|
11
11
|
import { MAX_RELAYED_BYTES, RELAY_NAME_PREFIX } from './relay.mjs';
|
|
12
12
|
import { FULL_ID_LEN, cameThroughLink, envelope as makeEnvelope, makeAddress, messageIdFor, newMessageId, normalMachineName, parseAddress, senderIdentity, subjectLine, threadOf } from './compose.mjs';
|
|
13
|
+
import { nativeMailContinuation } from './continuation.mjs';
|
|
13
14
|
|
|
14
15
|
export const EXIT_REFUSED = 2;
|
|
15
16
|
export const EXIT_UNKNOWN = 3;
|
|
@@ -56,8 +57,8 @@ export class MailSend {
|
|
|
56
57
|
* question's answer through its app-server; `typeTurn(pane, harness, text, ids)` a user's own turn typed into a daemon
|
|
57
58
|
* pane's empty composer (machine.mjs `#paneTurn`: `{ delivered }`, `{ queued }`, or neither when it waits).
|
|
58
59
|
*/
|
|
59
|
-
constructor({ machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, typeTurn, log = () => {} }) {
|
|
60
|
-
Object.assign(this, { machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, typeTurn, log });
|
|
60
|
+
constructor({ machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, typeTurn, claudeProjects = () => [], log = () => {} }) {
|
|
61
|
+
Object.assign(this, { machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, typeTurn, claudeProjects, log });
|
|
61
62
|
this.handing = new Set(); // `<address>\n<id>` of each hand-over running here (handOver)
|
|
62
63
|
this.relaying = new Set(); // `<address>\n<id>` of each relay send running here (route): its wake waits for it
|
|
63
64
|
}
|
|
@@ -70,6 +71,16 @@ export class MailSend {
|
|
|
70
71
|
* came through (`{ principal, name }`), or null for this machine's own callers. Answers `{ code, text, receipt }`.
|
|
71
72
|
*/
|
|
72
73
|
async send(caller, to, body, options = {}, context = {}) {
|
|
74
|
+
if (context.link && this.#remoteMachine(to)) return outcome(EXIT_UNKNOWN, `${to} is not on this machine; a send through its mail door is for its own sessions.`);
|
|
75
|
+
// Every stream earns its current route's grants before it can join the existing same-ID send in flight.
|
|
76
|
+
let receiver = null;
|
|
77
|
+
if (!this.#remoteMachine(to)) {
|
|
78
|
+
receiver = await this.#receiver(to, this.live.sessions());
|
|
79
|
+
if (receiver.outcome && !this.live.codexKnown) { await this.#codexKnown(); receiver = await this.#receiver(to, this.live.sessions()); }
|
|
80
|
+
if (receiver.outcome) return receiver.outcome;
|
|
81
|
+
const refused = await this.#authorizeReceiver(context, receiver.continuation?.chain ?? [receiver.address]);
|
|
82
|
+
if (refused) return refused;
|
|
83
|
+
}
|
|
73
84
|
// A send under an --id is held while it runs: the sent marker is written only once it is done, so a second send
|
|
74
85
|
// under the same key meanwhile waits for the first and takes its outcome (sent once), never delivering it again; one
|
|
75
86
|
// after a first that was not sent sends.
|
|
@@ -78,17 +89,25 @@ export class MailSend {
|
|
|
78
89
|
const running = (this.inFlight ??= new Map()).get(key);
|
|
79
90
|
if (running) {
|
|
80
91
|
const first = await running.catch(() => null);
|
|
81
|
-
if (first?.code === 0)
|
|
92
|
+
if (first?.code === 0) {
|
|
93
|
+
const addresses = [...(first.continuation?.chain ?? []), ...(first.receipt?.recipients ?? []), first.receipt?.recipient].filter(Boolean);
|
|
94
|
+
if (addresses.length) {
|
|
95
|
+
const refused = await this.#authorizeReceiver(context, addresses);
|
|
96
|
+
if (refused) return refused;
|
|
97
|
+
} else if (context.authorizeRecipients) return outcome(EXIT_REFUSED, 'Not sent: the earlier send has no recipient receipt to authorize on this stream. Nothing was sent again.');
|
|
98
|
+
return { ...outcome(0, `Already sent with --id ${options.id}: ${first.text} Nothing was sent again.`, first.receipt ?? null), ...(first.continuation ? { continuation: first.continuation } : {}) };
|
|
99
|
+
}
|
|
82
100
|
return this.send(caller, to, body, options, context);
|
|
83
101
|
}
|
|
84
102
|
}
|
|
85
|
-
const sending = this.#send(caller, to, body, options, context);
|
|
103
|
+
const sending = this.#send(caller, to, body, options, context, receiver);
|
|
86
104
|
if (!key) return sending;
|
|
87
105
|
this.inFlight.set(key, sending);
|
|
88
106
|
try { return await sending; } finally { if (this.inFlight.get(key) === sending) this.inFlight.delete(key); }
|
|
89
107
|
}
|
|
90
108
|
|
|
91
|
-
async #send(caller, to, body, { subject = null, in_reply_to = null, notify_when_idle = false, queue = false, id = null, notice = false, thread = null, message_id = null } = {},
|
|
109
|
+
async #send(caller, to, body, { subject = null, in_reply_to = null, notify_when_idle = false, queue = false, id = null, notice = false, thread = null, message_id = null } = {}, context = {}, receiver = null) {
|
|
110
|
+
const { link = null } = context;
|
|
92
111
|
// an id its client chose (so a lost answer can name it), never an idempotency key: it marks nothing sent
|
|
93
112
|
const chosen = typeof message_id === 'string' && /^m-[0-9a-f]{24}$/.test(message_id) ? message_id : null;
|
|
94
113
|
const machine = this.#remoteMachine(to);
|
|
@@ -103,10 +122,25 @@ export class MailSend {
|
|
|
103
122
|
const request = { op: 'send', from: caller.address, from_name: caller.name, to, body, subject, in_reply_to: answered.id, thread: carried, notify_when_idle, queue, id: messageId, notice };
|
|
104
123
|
const sent = await this.#remoteSend(machine, request);
|
|
105
124
|
// unanswered, it may have been delivered: kept in the send log, so a later reply to it resolves here
|
|
106
|
-
if (sent.code === 0 || sent.code === EXIT_STORED || sent.receipt?.unanswered === true) this.#recordSend(caller.address, to, messageId, carried ?? answered.id ?? messageId);
|
|
125
|
+
if (sent.code === 0 || sent.code === EXIT_STORED || sent.receipt?.unanswered === true) this.#recordSend(caller.address, to, messageId, carried ?? answered.id ?? messageId, sent.continuation);
|
|
107
126
|
return sent;
|
|
108
127
|
}
|
|
109
|
-
|
|
128
|
+
const answer = await this.#deliver(caller, receiver, body, { subject, in_reply_to, notify_when_idle, queue, id, notice, thread, chosen }, link, context);
|
|
129
|
+
return this.#annotateReceiver(answer, receiver);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#annotateReceiver(answer, receiver) {
|
|
133
|
+
const continuation = receiver.continuation;
|
|
134
|
+
const alias = continuation && !answer.text.includes(receiver.alias) ? receiver.alias : '';
|
|
135
|
+
return { ...answer, text: answer.text + alias, ...(continuation ? { continuation } : {}),
|
|
136
|
+
receipt: answer.receipt ? { ...answer.receipt, recipient: receiver.address,
|
|
137
|
+
recipients: answer.receipt.recipients ?? [receiver.address], ...(continuation ? { continuation } : {}) } : null };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async #authorizeReceiver(context, addresses) {
|
|
141
|
+
if (!context.authorizeRecipients) return null;
|
|
142
|
+
const reason = await context.authorizeRecipients(addresses);
|
|
143
|
+
return reason ? outcome(EXIT_REFUSED, `Not sent: ${reason}. Nothing was filed or delivered by this request.`) : null;
|
|
110
144
|
}
|
|
111
145
|
|
|
112
146
|
/** The machine `to` names when it is not this one, in its normal form. */
|
|
@@ -123,7 +157,7 @@ export class MailSend {
|
|
|
123
157
|
const answer = await this.askMachine(machine, request);
|
|
124
158
|
const code = Number.isInteger(answer?.code) ? answer.code : EXIT_FAILED;
|
|
125
159
|
const text = typeof answer?.text === 'string' ? answer.text : typeof answer?.detail === 'string' ? answer.detail : 'the other machine answered nothing readable';
|
|
126
|
-
return outcome(code, text, answer?.receipt ?? null);
|
|
160
|
+
return { ...outcome(code, text, answer?.receipt ?? null), ...(answer?.continuation ? { continuation: answer.continuation } : {}) };
|
|
127
161
|
} catch (error) {
|
|
128
162
|
const detail = String(error?.message ?? error);
|
|
129
163
|
// the door took it and its answer was lost: unanswered, never refused or failed (what it did there is unknown)
|
|
@@ -147,7 +181,7 @@ export class MailSend {
|
|
|
147
181
|
}
|
|
148
182
|
|
|
149
183
|
/** The receiver `to` names here: `{ address, name, session (its live row, when it runs) }`, or an outcome. */
|
|
150
|
-
#receiver(to, sessions) {
|
|
184
|
+
async #receiver(to, sessions) {
|
|
151
185
|
const operator = String(to).startsWith('operator:') ? makeAddress(this.machine, 'operator', String(to).slice('operator:'.length)) : null;
|
|
152
186
|
const parsed = parseAddress(operator ?? to);
|
|
153
187
|
// an operator (a board, a script) is not a listed session: its address is taken as given
|
|
@@ -158,7 +192,9 @@ export class MailSend {
|
|
|
158
192
|
return { address: parsed.address, name: `agent ${parsed.session}@${parsed.machine}` };
|
|
159
193
|
}
|
|
160
194
|
if (parsed) {
|
|
161
|
-
const
|
|
195
|
+
const resolved = await this.#nativeReceiver(parsed.address, sessions);
|
|
196
|
+
if (resolved) return resolved;
|
|
197
|
+
const session = this.live.sessions().find((row) => row.address === parsed.address);
|
|
162
198
|
if (!session) return { outcome: outcome(EXIT_UNKNOWN, this.#notYetKnown(to) ?? `${to} is no longer running. Nothing was sent. Run supercode message list for the live sessions.`) };
|
|
163
199
|
return { address: session.address, name: session.name, session };
|
|
164
200
|
}
|
|
@@ -172,7 +208,9 @@ export class MailSend {
|
|
|
172
208
|
if (!matching.length) {
|
|
173
209
|
const remembered = this.#rememberedAddress(wanted);
|
|
174
210
|
if (remembered) {
|
|
175
|
-
const
|
|
211
|
+
const resolved = await this.#nativeReceiver(remembered, sessions);
|
|
212
|
+
if (resolved) return resolved;
|
|
213
|
+
const session = this.live.sessions().find((row) => row.address === remembered);
|
|
176
214
|
if (!session) return { outcome: outcome(EXIT_UNKNOWN, `${wanted} was ${remembered}, which is not running. Nothing was sent. Run supercode message list for the live sessions.`) };
|
|
177
215
|
return { address: session.address, name: session.name, session, alias: ` "${wanted}" is a name it ran under before; it runs as ${session.name} now.` };
|
|
178
216
|
}
|
|
@@ -190,6 +228,18 @@ export class MailSend {
|
|
|
190
228
|
return { outcome: outcome(EXIT_UNKNOWN, this.#notYetKnown(wanted) ?? `No session named "${wanted}" is reachable.${hint} Run supercode message list. Nothing was sent.`) };
|
|
191
229
|
}
|
|
192
230
|
|
|
231
|
+
async #nativeReceiver(address, sessions) {
|
|
232
|
+
const live = sessions.find((row) => row.address === address);
|
|
233
|
+
if (live) return { address: live.address, name: live.name, session: live };
|
|
234
|
+
const continued = await nativeMailContinuation(address, { machine: this.machine, claudeProjects: this.claudeProjects, sessions: () => this.live.sessions() });
|
|
235
|
+
if (continued?.error) return { outcome: outcome(EXIT_UNKNOWN, `Not sent: ${address}'s continuation is unresolved: ${continued.error}. Nothing was sent.`) };
|
|
236
|
+
if (!continued) return null;
|
|
237
|
+
const { session, addressed, chain } = continued;
|
|
238
|
+
return { address: session.address, name: session.name, session,
|
|
239
|
+
continuation: { addressed, recipient: session.address, chain },
|
|
240
|
+
alias: ` Addressed to ${address}; its native continued-in record routes it to ${session.address}.` };
|
|
241
|
+
}
|
|
242
|
+
|
|
193
243
|
/** In a daemon's first moments the Codex sessions are not known yet (its session index and their activity still being
|
|
194
244
|
* read): a send waits for them, at most CODEX_KNOWN_WAIT_MS. */
|
|
195
245
|
async #codexKnown() { if (!this.live.codexKnown) await this.live.whenCodexKnown?.(CODEX_KNOWN_WAIT_MS); }
|
|
@@ -219,12 +269,8 @@ export class MailSend {
|
|
|
219
269
|
}
|
|
220
270
|
|
|
221
271
|
/** Deliver from `caller` to `to` on this machine (mail_send.rs `deliver`). */
|
|
222
|
-
async #deliver(caller,
|
|
223
|
-
|
|
224
|
-
let receiver = this.#receiver(to, sessions);
|
|
225
|
-
// only a receiver not found waits for the Codex sessions to be known (one the map holds is reached at once)
|
|
226
|
-
if (receiver.outcome && !this.live.codexKnown) { await this.#codexKnown(); sessions = this.live.sessions(); receiver = this.#receiver(to, sessions); }
|
|
227
|
-
if (receiver.outcome) return receiver.outcome;
|
|
272
|
+
async #deliver(caller, receiver, body, { subject, in_reply_to, notify_when_idle, queue, id, notice, thread: carried, chosen }, link, context) {
|
|
273
|
+
const sessions = this.live.sessions();
|
|
228
274
|
const { address, name } = receiver;
|
|
229
275
|
if (address === caller.address) return outcome(EXIT_REFUSED, 'Not sent: that address is your own session.');
|
|
230
276
|
const door = parseAddress(address).harness === AGENT_HARNESS ? null : this.#door(address, sessions);
|
|
@@ -250,7 +296,14 @@ export class MailSend {
|
|
|
250
296
|
});
|
|
251
297
|
const plan = await this.#plan(sent, address, {});
|
|
252
298
|
if (plan.outcome) return plan.outcome;
|
|
253
|
-
|
|
299
|
+
const recipients = plan.plan ? [...new Set([address, ...(plan.plan.agent ? [plan.plan.agent] : []),
|
|
300
|
+
...plan.plan.recipients.filter(item => item.address !== caller.address).map(item => item.address)])] : [address];
|
|
301
|
+
const refused = await this.#authorizeReceiver(context, [...(receiver.continuation?.chain ?? []), ...recipients]);
|
|
302
|
+
if (refused) return refused;
|
|
303
|
+
if (plan.plan) {
|
|
304
|
+
const answer = await this.#deliverPlanned(caller, sent, plan.plan, { queue, notify_when_idle, continuation: receiver.continuation });
|
|
305
|
+
return { ...answer, receipt: answer.receipt ? { ...answer.receipt, recipients } : null };
|
|
306
|
+
}
|
|
254
307
|
// the thread this message is in, kept in the send log: a later reply to it continues that thread
|
|
255
308
|
const sentIn = threadOf(sent);
|
|
256
309
|
if (!door || door.kind === 'other_machine') return outcome(EXIT_UNKNOWN, this.#notYetKnown(name) ?? `${name} is no longer running. Nothing was sent. Run supercode message list for the live sessions.`);
|
|
@@ -263,7 +316,7 @@ export class MailSend {
|
|
|
263
316
|
if (error instanceof Refused && error.kind === 'queue') return outcome(EXIT_REFUSED, `Not sent: ${name} is idle, and a Claude session always starts a turn when a message arrives, so --queue cannot hold it. Send without --queue to wake it.`);
|
|
264
317
|
if (error instanceof Refused) return outcome(EXIT_REFUSED, `Not sent: the message is ${error.bytes} bytes; the limit is ${MAX_RELAYED_BYTES}. Write it to a file and send its path instead.`);
|
|
265
318
|
// may have arrived: kept filed, and said unanswered with its id (sent again with the same id, nothing is sent twice)
|
|
266
|
-
if (error.unanswered) { this.#recordSend(caller.address, address, messageId, sentIn); return outcome(EXIT_UNANSWERED, unansweredText(`${name}'s Claude relay`, error.message, messageId)); }
|
|
319
|
+
if (error.unanswered) { this.#recordSend(caller.address, address, messageId, sentIn, receiver.continuation); return outcome(EXIT_UNANSWERED, unansweredText(`${name}'s Claude relay`, error.message, messageId)); }
|
|
267
320
|
return outcome(EXIT_FAILED, `Not sent to ${name}: ${error.message}`);
|
|
268
321
|
}
|
|
269
322
|
const [code, what] = {
|
|
@@ -281,7 +334,7 @@ export class MailSend {
|
|
|
281
334
|
pane_waits: [EXIT_STORED, 'Stored (not failed): its pane\'s composer was not empty, so the message waits in its mailbox; it sees it with supercode message inbox. Don\'t resend'],
|
|
282
335
|
stored: [EXIT_STORED, `Stored (not failed): it has no delivery door, so it sees this only if it runs supercode message inbox${parseAddress(address).harness === 'codex' ? ' (a Codex session gets one with: supercode message setup codex)' : ''}. Don't resend, and don't wait for a reply`],
|
|
283
336
|
}[delivered];
|
|
284
|
-
this.#recordSend(caller.address, address, messageId, sentIn);
|
|
337
|
+
this.#recordSend(caller.address, address, messageId, sentIn, receiver.continuation);
|
|
285
338
|
const harness = parseAddress(address).harness;
|
|
286
339
|
const text = code === 0
|
|
287
340
|
? `sent to ${name} (${harness}, ${door.kind}): ${what}. Message id ${shortId(messageId)}.${receiver.alias ?? ''}${idleNote} Don't poll; carry on.`
|
|
@@ -408,7 +461,7 @@ export class MailSend {
|
|
|
408
461
|
*/
|
|
409
462
|
async deliverPlanned(caller, sent, plan, { queue = false, notify_when_idle = false } = {}) { return this.#deliverPlanned(caller, sent, plan, { queue, notify_when_idle }); }
|
|
410
463
|
|
|
411
|
-
async #deliverPlanned(caller, sent, plan, { queue, notify_when_idle }) {
|
|
464
|
+
async #deliverPlanned(caller, sent, plan, { queue, notify_when_idle, continuation = null }) {
|
|
412
465
|
const reached = [], copied = [], failed = [];
|
|
413
466
|
let recorded = false;
|
|
414
467
|
const held = []; // recipients whose own copy is held unread with its wake (an unanswered relay)
|
|
@@ -428,7 +481,7 @@ export class MailSend {
|
|
|
428
481
|
const sessions = this.live.sessions();
|
|
429
482
|
for (const recipient of plan.recipients.filter((item) => item.address !== caller.address)) {
|
|
430
483
|
const label = this.#label(sessions, recipient.address);
|
|
431
|
-
this.#recordSend(caller.address, recipient.address, sent.id, threadOf(sent));
|
|
484
|
+
this.#recordSend(caller.address, recipient.address, sent.id, threadOf(sent), continuation);
|
|
432
485
|
if (!recipient.wake) {
|
|
433
486
|
try { this.#fileUnread(recipient.address, sent); copied.push(label); } catch (error) { failed.push(`${label} (${error.message})`); }
|
|
434
487
|
continue;
|
|
@@ -914,10 +967,10 @@ export class MailSend {
|
|
|
914
967
|
return depth;
|
|
915
968
|
}
|
|
916
969
|
|
|
917
|
-
#recordSend(sender, to, id, thread) {
|
|
970
|
+
#recordSend(sender, to, id, thread, continuation = null) {
|
|
918
971
|
try {
|
|
919
972
|
mkdirSync(this.#sentDir(sender), { recursive: true });
|
|
920
|
-
appendFileSync(join(this.#sentDir(sender), 'log.jsonl'), `${JSON.stringify({ to, at_ms: Date.now(), id, thread })}\n`);
|
|
973
|
+
appendFileSync(join(this.#sentDir(sender), 'log.jsonl'), `${JSON.stringify({ to, at_ms: Date.now(), id, thread, ...(continuation ? { continuation } : {}) })}\n`);
|
|
921
974
|
} catch { /* the log is best kept */ }
|
|
922
975
|
}
|
|
923
976
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.151",
|
|
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",
|
package/remote-close.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// A remote session closes its recorded pane here, without launching a second command that has to rediscover the
|
|
2
|
+
// original caller. Caller admission and pane ownership stay in machine.mjs. This module only reads the target-side
|
|
3
|
+
// close safeguards; its native invocation is --check, never an action or a caller-identity bridge.
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
const refusal = (message) => Object.assign(new Error(`${message}. Nothing was closed`), { code: 409 });
|
|
7
|
+
const HARNESSES = new Set(['claude-code', 'claude', 'codex', 'gemini', 'gemini-cli', 'goose', 'opencode', 'pi', 'grok', 'hermes', 'openclaw']);
|
|
8
|
+
|
|
9
|
+
function read(program, args, env, timeout = 4000) {
|
|
10
|
+
return new Promise((resolve, reject) => execFile(program, args, {
|
|
11
|
+
env, encoding: 'utf8', timeout, maxBuffer: 1024 * 1024, windowsHide: true,
|
|
12
|
+
}, (error, stdout, stderr) => {
|
|
13
|
+
if (error) reject(refusal(`target close check failed: ${String(stderr || error.message).trim()}`));
|
|
14
|
+
else resolve(String(stdout));
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The native reader's self-close exemption must never bypass the work check because the daemon happened to start
|
|
19
|
+
// below the target session. Check that boundary from OS parents, not from an environment variable or caller claim.
|
|
20
|
+
async function outsideTarget(pid, env) {
|
|
21
|
+
let rows;
|
|
22
|
+
if (process.platform === 'win32') {
|
|
23
|
+
const script = 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId)" }';
|
|
24
|
+
rows = await read('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], env);
|
|
25
|
+
} else if (['darwin', 'linux'].includes(process.platform)) {
|
|
26
|
+
rows = await read('ps', ['-A', '-o', 'pid=,ppid='], env);
|
|
27
|
+
} else throw refusal('this platform cannot read the close checker\'s ancestry');
|
|
28
|
+
const parents = new Map(rows.trim().split('\n').map(line => line.trim().split(/\s+/).map(Number))
|
|
29
|
+
.filter(([child, parent]) => Number.isSafeInteger(child) && child > 0 && Number.isSafeInteger(parent) && parent >= 0));
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
let at = process.pid;
|
|
32
|
+
while (at > 1 && seen.size < 128) {
|
|
33
|
+
if (at === pid) throw refusal('the target contains the machine daemon; its work must be closed from outside that session');
|
|
34
|
+
if (!parents.has(at) || seen.has(at)) throw refusal('the close checker\'s ancestry could not be confirmed');
|
|
35
|
+
seen.add(at);
|
|
36
|
+
at = parents.get(at);
|
|
37
|
+
}
|
|
38
|
+
if (at !== 0 && at !== 1) throw refusal('the close checker\'s ancestry did not reach its root');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A known pane's native session must be idle and pass the existing native live-work check. A plain command has
|
|
42
|
+
* no session exemption to check. `activity` always reads this exact root, and a missing harness observation refuses. */
|
|
43
|
+
export async function checkRemoteClose({ pane, session, kind, activity, live, native, env }) {
|
|
44
|
+
if (session.dead) return async () => {};
|
|
45
|
+
if (!Number.isSafeInteger(session.rootPid) || session.rootPid <= 1) throw refusal(`pane ${pane} names no root process to check`);
|
|
46
|
+
const page = await activity(session.rootPid);
|
|
47
|
+
if (!Array.isArray(page?.activities)) throw refusal(`pane ${pane}'s session activity could not be read`);
|
|
48
|
+
const found = page.activities.filter(row => row.pid === session.rootPid);
|
|
49
|
+
if (found.length > 1) throw refusal(`pane ${pane} has ambiguous session activity`);
|
|
50
|
+
const observed = found[0]?.activity;
|
|
51
|
+
if (!observed) {
|
|
52
|
+
if (HARNESSES.has(kind)) throw refusal(`pane ${pane} runs ${kind}, whose native session could not be read`);
|
|
53
|
+
// A live-map session missed by activity_under is unknown, never a plain command.
|
|
54
|
+
if (live.length) throw refusal(`pane ${pane}'s known native session has no activity reading`);
|
|
55
|
+
return async () => {
|
|
56
|
+
const again = await activity(session.rootPid);
|
|
57
|
+
if (!Array.isArray(again?.activities) || again.activities.some(row => row.pid === session.rootPid && row.activity)) {
|
|
58
|
+
throw refusal(`pane ${pane}'s session changed during its close check`);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (observed.presence !== 'running' || observed.turn !== 'idle') {
|
|
63
|
+
throw refusal(`pane ${pane}'s session is ${observed.presence ?? 'unknown'}, turn ${observed.turn ?? 'unknown'}; finish or stop its turn first`);
|
|
64
|
+
}
|
|
65
|
+
const address = live.find(row => row.address.split(':').slice(2).join(':') === `${observed.harness}:${observed.session_id}`)?.address;
|
|
66
|
+
if (!address || live.length !== 1) throw refusal(`pane ${pane}'s native session binding could not be confirmed`);
|
|
67
|
+
// No ambient identity reaches the native read. It decides neither the forwarded caller nor pane authority.
|
|
68
|
+
const cleanEnv = { ...env };
|
|
69
|
+
for (const key of ['CLAUDE_CODE_SESSION_ID', 'CODEX_THREAD_ID', 'SUPERCODE_CALLER', 'SUPERCODE_BOARD_OWNER_PID']) delete cleanEnv[key];
|
|
70
|
+
await outsideTarget(live[0].pid ?? session.rootPid, cleanEnv);
|
|
71
|
+
const answer = await read(native, ['close', address, '--check'], cleanEnv);
|
|
72
|
+
// --check may instead resolve a process, another pane or an ended session. Only this exact pane's check stands.
|
|
73
|
+
if (!answer.trim().endsWith(`(pane ${pane}, through the machine daemon)`)) throw refusal(`the native close check did not name pane ${pane}`);
|
|
74
|
+
return async () => {
|
|
75
|
+
const again = await activity(session.rootPid);
|
|
76
|
+
const rows = again?.activities?.filter(row => row.pid === session.rootPid);
|
|
77
|
+
const current = rows?.length === 1 ? rows[0].activity : null;
|
|
78
|
+
if (!current || current.harness !== observed.harness || current.session_id !== observed.session_id
|
|
79
|
+
|| current.presence !== 'running' || current.turn !== 'idle') throw refusal(`pane ${pane}'s session or turn changed during its close check`);
|
|
80
|
+
};
|
|
81
|
+
}
|