@volter/supercode-teams 0.3.133 → 0.3.135
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/teams.mjs +12 -9
- package/live-sessions.mjs +60 -27
- package/machine.mjs +150 -20
- package/mail/relay.mjs +3 -2
- package/mail/send.mjs +1 -1
- package/package.json +1 -1
package/bin/teams.mjs
CHANGED
|
@@ -204,7 +204,10 @@ async function main() {
|
|
|
204
204
|
if (action === 'unregister' && root) return withNode(async (rpc) => out(await rpc.call('harness.v1.boards.unregister', { root: resolve(root) })));
|
|
205
205
|
die('machine boards [list] | machine boards unregister <root>');
|
|
206
206
|
}
|
|
207
|
-
|
|
207
|
+
// `machine pane-of <pid>`: the pane this daemon launched that the process runs in, and who launched it, from the
|
|
208
|
+
// daemon's own record (the native's acting caller asks it for its own pid; never what a command sets for itself)
|
|
209
|
+
if (sub === 'pane-of') return withNode(async (rpc) => out(await rpc.call('harness.v1.pane_of', { pid: Number(rest[0]) })));
|
|
210
|
+
die('machine start|status|restart|boards|pane-of');
|
|
208
211
|
}
|
|
209
212
|
case 'slow': {
|
|
210
213
|
// `slow [--since 1h|30m|<iso>]`: the slow log on this machine, grouped by what was slow (kind and name), the
|
|
@@ -252,7 +255,7 @@ async function main() {
|
|
|
252
255
|
// the flag parser takes `--foreign p_…` as the flag's value: that pane is the one named
|
|
253
256
|
const named = typeof flags.foreign === 'string' ? [flags.foreign, ...rest] : rest;
|
|
254
257
|
if (named.length !== 1) die('panes close <pane> [--foreign]: one pane per close');
|
|
255
|
-
return withNode(async (rpc) => out(await rpc.call('panes.close', { pane: named[0], foreign: flags.foreign !== undefined, ...(await acting()) })));
|
|
258
|
+
return withNode(async (rpc) => out(await rpc.call('panes.close', { pane: named[0], foreign: flags.foreign !== undefined, ...(await acting()), ...(flags.foreign ? { foreign: true } : {}) })));
|
|
256
259
|
}
|
|
257
260
|
// `window <pane> [machine]`: a terminal window on this machine's screen showing the pane, this machine's or, named,
|
|
258
261
|
// another machine's (attached through Teams, in that machine's Teams context `--attach-context`); `--observe` shows it
|
|
@@ -281,9 +284,9 @@ async function main() {
|
|
|
281
284
|
die('attention ls | rank | request <pane> [--urgency now|soon|whenever] [--reason text] | clear <pane>');
|
|
282
285
|
}
|
|
283
286
|
case 'suspend':
|
|
284
|
-
return withNode(async (rpc) => out(await rpc.call('panes.suspend', { pane: sub, interrupt: Boolean(flags.interrupt), ...(await acting()) })));
|
|
287
|
+
return withNode(async (rpc) => out(await rpc.call('panes.suspend', { pane: sub, interrupt: Boolean(flags.interrupt), ...(await acting()), ...(flags.foreign ? { foreign: true } : {}) })));
|
|
285
288
|
case 'resume':
|
|
286
|
-
return withNode(async (rpc) => out(await rpc.call('panes.resume', { pane: sub, input: flags.input ? String(flags.input) : null, ...(await acting()) })));
|
|
289
|
+
return withNode(async (rpc) => out(await rpc.call('panes.resume', { pane: sub, input: flags.input ? String(flags.input) : null, ...(await acting()), ...(flags.foreign ? { foreign: true } : {}) })));
|
|
287
290
|
case 'files': {
|
|
288
291
|
const path = rest[0];
|
|
289
292
|
if (sub === 'ls') return withNode(async (rpc) => out(await rpc.call('files.list', { path })));
|
|
@@ -331,14 +334,14 @@ async function main() {
|
|
|
331
334
|
// `--harness NAME` (with it): the addressed session's harness; the daemon refuses a pane running another program.
|
|
332
335
|
if (flags['clear-draft']) {
|
|
333
336
|
if (rest.length || flags.key || flags['no-enter'] || flags['when-composer-empty']) die('input <pane> --clear-draft only clears the current draft; send a new message separately');
|
|
334
|
-
return withNode(async rpc => out(await rpc.call('panes.clearDraft', { pane: sub, ...(await acting()) })));
|
|
337
|
+
return withNode(async rpc => out(await rpc.call('panes.clearDraft', { pane: sub, ...(await acting()), ...(flags.foreign ? { foreign: true } : {}) })));
|
|
335
338
|
}
|
|
336
339
|
if (flags.key !== undefined) {
|
|
337
340
|
if (flags.key === true || rest.length) die('input <pane> --key NAMES: keys by tmux name, space- or comma-separated (Escape, Enter, Tab, BTab, BSpace, Space, Up, Down, Left, Right, Home, End, PPage, NPage, DC, C-a … C-z)');
|
|
338
|
-
return withNode(async (rpc) => out(await rpc.call('panes.input', { pane: sub, text: keyBytes(String(flags.key)), submit: false, keys: true, ...(await acting()) })));
|
|
341
|
+
return withNode(async (rpc) => out(await rpc.call('panes.input', { pane: sub, text: keyBytes(String(flags.key)), submit: false, keys: true, ...(await acting()), ...(flags.foreign ? { foreign: true } : {}) })));
|
|
339
342
|
}
|
|
340
|
-
if (flags['when-composer-empty']) return withNode(async (rpc) => out(await rpc.call('panes.turn', { pane: sub, text: rest.join(' '), message_ids: flags['message-ids'] ? String(flags['message-ids']).split(',') : undefined, harness: typeof flags.harness === 'string' ? flags.harness : undefined, ...(await acting()) })));
|
|
341
|
-
return withNode(async (rpc) => out(await rpc.call('panes.input', { pane: sub, text: rest.join(' '), submit: !flags['no-enter'], message_ids: flags['message-ids'] ? String(flags['message-ids']).split(',') : undefined, ...(await acting()) })));
|
|
343
|
+
if (flags['when-composer-empty']) return withNode(async (rpc) => out(await rpc.call('panes.turn', { pane: sub, text: rest.join(' '), message_ids: flags['message-ids'] ? String(flags['message-ids']).split(',') : undefined, harness: typeof flags.harness === 'string' ? flags.harness : undefined, ...(await acting()), ...(flags.foreign ? { foreign: true } : {}) })));
|
|
344
|
+
return withNode(async (rpc) => out(await rpc.call('panes.input', { pane: sub, text: rest.join(' '), submit: !flags['no-enter'], message_ids: flags['message-ids'] ? String(flags['message-ids']).split(',') : undefined, ...(await acting()), ...(flags.foreign ? { foreign: true } : {}) })));
|
|
342
345
|
case 'port': {
|
|
343
346
|
// `port <pane>/<n> [--listen host:port]`: a machine port forwarded here. Stays up until ctrl-c.
|
|
344
347
|
const spec = String(sub ?? '');
|
|
@@ -449,7 +452,7 @@ async function main() {
|
|
|
449
452
|
return withNode(async (rpc) => out(await rpc.call('jobs.list', {})));
|
|
450
453
|
case 'launches': {
|
|
451
454
|
if (sub === 'get' && rest[0]) return withNode(async (rpc) => out(await rpc.call('launches.get', { key: rest[0] })));
|
|
452
|
-
if (sub === 'cancel' && rest[0]) return withNode(async (rpc) => out(await rpc.call('launches.cancel', { key: rest[0], ...(await acting()) })));
|
|
455
|
+
if (sub === 'cancel' && rest[0]) return withNode(async (rpc) => out(await rpc.call('launches.cancel', { key: rest[0], ...(await acting()), ...(flags.foreign ? { foreign: true } : {}) })));
|
|
453
456
|
if (sub === 'list') return withNode(async (rpc) => out(await rpc.call('launches.list', {})));
|
|
454
457
|
die('launches get <key> | cancel <key> | list [--machine NAME]');
|
|
455
458
|
return;
|
package/live-sessions.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// - Messaging tools: the marker a `message mcp` server writes for its session process, with the server alive.
|
|
15
15
|
import { EventEmitter } from 'node:events';
|
|
16
16
|
import { execFile } from 'node:child_process';
|
|
17
|
-
import { mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, unlinkSync, watch, writeFileSync } from 'node:fs';
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, unlinkSync, watch, writeFileSync } from 'node:fs';
|
|
18
18
|
import { homedir } from 'node:os';
|
|
19
19
|
import { dirname, join } from 'node:path';
|
|
20
20
|
import { FrontendClient } from '@volter/supercode-frontend';
|
|
@@ -731,31 +731,40 @@ export class LiveSessions extends EventEmitter {
|
|
|
731
731
|
return null;
|
|
732
732
|
}
|
|
733
733
|
|
|
734
|
-
/**
|
|
734
|
+
/**
|
|
735
|
+
* The session a client runs in, read from its process's ancestry, which this daemon reads itself, never from the
|
|
736
|
+
* environment the client's command set (#1245 review: `CLAUDE_CODE_SESSION_ID=<another session> supercode teams panes
|
|
737
|
+
* close` acted as that session). The nearest ancestor that is a live session's process names it: a Claude Code
|
|
738
|
+
* process its current session, a runtime supercode hosts by its receipt, a Codex process the thread the claim names
|
|
739
|
+
* only while that process holds the thread's rollout open (one Codex process serves several threads). On Windows,
|
|
740
|
+
* whose processes this reads no parents of, the claim's own session stands, as before.
|
|
741
|
+
*/
|
|
735
742
|
async #sessionOfClaim({ pid, ppid, session, runtime }) {
|
|
736
743
|
// the probes of one claim share one bound (CLAIM_PROBE_MS), however many of its processes are runtimes
|
|
737
744
|
const deadline = Date.now() + CLAIM_PROBE_MS;
|
|
738
|
-
// a runtime supercode hosts (the native agent) says so in its claim: it is named by its own process first, never by
|
|
739
|
-
// the session whose shell started it; one with no receipt is named by the rest of its claim, as any client is
|
|
740
745
|
if (runtime === true) {
|
|
741
|
-
// a pid several receipts name is no one runtime: the rest of the claim names it, never a refusal here
|
|
742
746
|
let own = null;
|
|
743
747
|
try { own = await this.#runtimeOfPid(Number(pid), deadline); } catch (error) { if (!(error instanceof CallerRefused) || error.unconfirmed) throw error; }
|
|
744
748
|
if (own) return own;
|
|
745
749
|
}
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
750
|
+
if (process.platform === 'win32') {
|
|
751
|
+
const parent = this.claude.get(Number(ppid));
|
|
752
|
+
if (parent && alive(parent.pid)) return this.#address('claude-code', parent.sessionId);
|
|
753
|
+
if (typeof session === 'string' && session) {
|
|
754
|
+
const found = this.#sessionOfId(session);
|
|
755
|
+
if (!found) throw new CallerRefused(`the session this command runs in (${session}) is not one this machine's live sessions have held, so it cannot act for it; nothing was done`);
|
|
756
|
+
return found;
|
|
757
|
+
}
|
|
758
|
+
for (const candidate of [Number(pid), Number(ppid)]) { const found = await this.#runtimeOfPid(candidate, deadline); if (found) return found; }
|
|
759
|
+
return null;
|
|
752
760
|
}
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
const
|
|
758
|
-
if (
|
|
761
|
+
const left = () => Math.max(250, deadline - Date.now());
|
|
762
|
+
for (const { pid: at, command } of await ancestryOf(Number(pid), left())) {
|
|
763
|
+
const claude = this.claude.get(at);
|
|
764
|
+
if (claude && alive(claude.pid)) return this.#address('claude-code', claude.sessionId);
|
|
765
|
+
const hosted = await this.#runtimeOfPid(at, deadline);
|
|
766
|
+
if (hosted) return hosted;
|
|
767
|
+
if (/(^|\/)codex$/.test(command) && typeof session === 'string' && /^[0-9a-f-]{8,64}$/.test(session) && await holdsRollout(at, session, left())) return this.#address('codex', session);
|
|
759
768
|
}
|
|
760
769
|
return null;
|
|
761
770
|
}
|
|
@@ -785,27 +794,46 @@ export class LiveSessions extends EventEmitter {
|
|
|
785
794
|
* a caller the map does not hold.
|
|
786
795
|
*/
|
|
787
796
|
async callerOf(claim) {
|
|
788
|
-
const { pid,
|
|
797
|
+
const { pid, named } = claim ?? {};
|
|
789
798
|
const board = typeof named === 'string' ? new RegExp(`^sc:${this.machine.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:board:([^:\\s]+)$`).exec(named) : null;
|
|
790
|
-
// a board's owner, by the pid it registered with: the client itself, or
|
|
791
|
-
//
|
|
799
|
+
// a board's owner, by the pid it registered with: the client itself, or an ancestor of it (the owner runs the
|
|
800
|
+
// commands it runs as its board), alive. Only the proven pid's own ancestry says so: the owner pid a client names
|
|
801
|
+
// (`SUPERCODE_BOARD_OWNER_PID`) is its environment's word, which any command can set (#1245 review round 3)
|
|
792
802
|
const registered = board ? this.boardOwner(board[1]) : null;
|
|
793
|
-
if (registered &&
|
|
803
|
+
if (registered && alive(registered) && Number.isSafeInteger(Number(pid)) && Number(pid) > 1
|
|
804
|
+
&& (Number(pid) === registered || (await ancestryOf(Number(pid), CLAIM_PROBE_MS)).some((row) => row.pid === registered))) return named;
|
|
794
805
|
// A board's name from anything but its owner is not taken (a card's session, a pane run for the board): the caller
|
|
795
806
|
// is the session the claim resolves to, or a person, as before (a board's name stood only under its own process).
|
|
796
807
|
const session = await this.#sessionOfClaim(claim ?? {});
|
|
797
808
|
if (session) return session;
|
|
798
809
|
if (board) return null;
|
|
799
|
-
// a
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
}
|
|
804
|
-
// no session and no name: a person
|
|
810
|
+
// a session named in the environment (SUPERCODE_CALLER) is not taken: any command can set it (#1245 review). A pane
|
|
811
|
+
// a session launched acts for it by the daemon's own record of the pane (machine.mjs #callerOf); else no caller,
|
|
812
|
+
// which is no authority (a close of what the caller did not start needs --foreign)
|
|
813
|
+
if (process.platform === 'win32' && typeof named === 'string' && named && this.sessions().some((live) => live.address === named)) return named;
|
|
805
814
|
return null;
|
|
806
815
|
}
|
|
807
816
|
}
|
|
808
817
|
|
|
818
|
+
/** Process `pid` and its ancestors, nearest first, with each one's command name: one `ps` of every process. */
|
|
819
|
+
async function ancestryOf(pid, timeout = 5000) {
|
|
820
|
+
if (!Number.isSafeInteger(pid) || pid <= 1) return [];
|
|
821
|
+
const table = await new Promise((resolve) => execFile(tool('ps'), ['-A', '-o', 'pid=,ppid=,comm='], { timeout, maxBuffer: 16 * 1024 * 1024 }, (_error, stdout) => resolve(String(stdout ?? ''))));
|
|
822
|
+
const rows = new Map();
|
|
823
|
+
for (const line of table.split('\n')) { const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line); if (m) rows.set(Number(m[1]), { ppid: Number(m[2]), command: m[3].trim() }); }
|
|
824
|
+
const chain = [];
|
|
825
|
+
for (let at = pid; at > 1 && chain.length < 64 && !chain.some((row) => row.pid === at) && rows.has(at); at = rows.get(at).ppid) chain.push({ pid: at, command: rows.get(at).command });
|
|
826
|
+
return chain;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** Whether Codex process `pid` holds thread `thread`'s rollout open (its file is named by the thread's id), and no
|
|
830
|
+
* other's: a process serving several threads (an app-server) names none of them, so a child cannot name a sibling. */
|
|
831
|
+
async function holdsRollout(pid, thread, timeout = 5000) {
|
|
832
|
+
const out = await new Promise((resolve) => execFile(tool('lsof'), ['-a', '-p', String(pid), '-Fn'], { timeout, maxBuffer: 16 * 1024 * 1024 }, (_error, stdout) => resolve(String(stdout ?? ''))));
|
|
833
|
+
const rollouts = new Set(out.split('\n').filter((line) => line.startsWith('n') && /rollout-[^/]*\.jsonl$/.test(line)).map((line) => line.slice(1)));
|
|
834
|
+
return rollouts.size === 1 && [...rollouts][0].includes(thread);
|
|
835
|
+
}
|
|
836
|
+
|
|
809
837
|
/** A live-runtime receipt file, when it is one a delivery may use: its schema, and a loopback HTTP address. */
|
|
810
838
|
function readReceiptFile(path) {
|
|
811
839
|
let receipt = null;
|
|
@@ -824,3 +852,8 @@ export function ownClaim(env = process.env) {
|
|
|
824
852
|
return { pid: process.pid, ppid: process.ppid, session: env.CLAUDE_CODE_SESSION_ID || env.CODEX_THREAD_ID || null, named: env.SUPERCODE_CALLER || null, owner: env.SUPERCODE_BOARD_OWNER_PID || null };
|
|
825
853
|
}
|
|
826
854
|
|
|
855
|
+
/** A system tool by its own path: a daemon's PATH (a service's) may hold no /usr/sbin, where macOS keeps lsof. */
|
|
856
|
+
function tool(name) {
|
|
857
|
+
const at = { lsof: ['/usr/sbin/lsof', '/usr/bin/lsof', '/sbin/lsof'], ps: ['/bin/ps', '/usr/bin/ps'], ss: ['/usr/bin/ss', '/bin/ss', '/usr/sbin/ss', '/sbin/ss'] }[name] ?? [];
|
|
858
|
+
return at.find((path) => existsSync(path)) ?? name;
|
|
859
|
+
}
|
package/machine.mjs
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
import { ProcessTree, harnessProcesses } from './process-tree.mjs';
|
|
29
29
|
import { spawn, spawnSync, execFile } from 'node:child_process';
|
|
30
30
|
import { EventEmitter } from 'node:events';
|
|
31
|
-
import { appendFileSync, readFileSync, writeFileSync, writeSync, statSync, readdirSync, mkdirSync, renameSync, rmSync, existsSync, openSync, readSync, closeSync, accessSync, constants } from 'node:fs';
|
|
31
|
+
import { appendFileSync, readFileSync, writeFileSync, writeSync, statSync, readdirSync, mkdirSync, renameSync, rmSync, existsSync, openSync, readSync, closeSync, accessSync, constants, readlinkSync } from 'node:fs';
|
|
32
32
|
import { basename, join, resolve, dirname, isAbsolute } from 'node:path';
|
|
33
33
|
import { open as openAsync, readdir as readdirAsync } from 'node:fs/promises';
|
|
34
34
|
import { askBoardOwner, BOARD_OPERATION_VERBS, boardHome } from '@volter/teams/board-owner';
|
|
@@ -1784,7 +1784,21 @@ export class TeamsMachine extends MachineCore {
|
|
|
1784
1784
|
// observe attachment stays.
|
|
1785
1785
|
let attachedBy = null;
|
|
1786
1786
|
if (cap === 'control') {
|
|
1787
|
-
|
|
1787
|
+
// control types into the pane (C-c, /exit): only a process the kernel shows at this connection's other end attaches
|
|
1788
|
+
// with it, never one whose claim names another (#1245 review round 3; Windows' pipes show no peer)
|
|
1789
|
+
if (entry.principal === 'local' && !entry.appDoor && entry.socket && process.platform !== 'win32' && !(await peerIs(entry.socket, Number(stream.params?.claim?.pid)))) {
|
|
1790
|
+
const detail = 'the process attaching could not be confirmed as this connection\'s other end; nothing was done';
|
|
1791
|
+
this.log.append({ principal, cap, target: `pane:${pane}`, outcome: 'refused', detail });
|
|
1792
|
+
return refuse(detail);
|
|
1793
|
+
}
|
|
1794
|
+
try { attachedBy = await this.#actorOf(entry, stream.params); } catch (error) { this.log.append({ principal, cap, target: `pane:${pane}`, outcome: 'refused', detail: error.message }); return refuse(error.message); }
|
|
1795
|
+
// no caller is no session proven, not a person proven: a person attaches from their terminal, so a process with
|
|
1796
|
+
// none (one a session detached with setsid) gets no control by having no caller (#1245 review round 3)
|
|
1797
|
+
if (attachedBy == null && entry.principal === 'local' && !entry.appDoor && entry.socket && process.platform !== 'win32' && !(await holdsTerminal(Number(stream.params?.claim?.pid)))) {
|
|
1798
|
+
const detail = 'a command with no session behind it attaches with control only from a terminal; nothing was done';
|
|
1799
|
+
this.log.append({ principal, cap, target: `pane:${pane}`, outcome: 'refused', detail });
|
|
1800
|
+
return refuse(detail);
|
|
1801
|
+
}
|
|
1788
1802
|
const sessionTurn = this.#sessionTurn({}, attachedBy)?.refused;
|
|
1789
1803
|
if (sessionTurn) { this.log.append({ principal, cap, target: `pane:${pane}`, outcome: 'refused', detail: sessionTurn.reason }); return refuse(`${sessionTurn.reason}; a session may attach to observe`); }
|
|
1790
1804
|
}
|
|
@@ -2049,14 +2063,71 @@ export class TeamsMachine extends MachineCore {
|
|
|
2049
2063
|
return { code: 0, address: caller, rendered: [...note, ...kept.map((index) => read.rendered[index])], plain: [...note, ...kept.map((index) => plain[index])], rows: kept.map((index) => read.rows[index]), omitted: left, token };
|
|
2050
2064
|
}
|
|
2051
2065
|
|
|
2066
|
+
/**
|
|
2067
|
+
* The pane this daemon launched that process `pid` runs in: `{ pane, by }` (`by` null for a person's), or null for a
|
|
2068
|
+
* process in none. Its ancestry is read here (one `ps`), the panes from this daemon's tmux server, and a pane counts only
|
|
2069
|
+
* as the one its launch made: its tmux session created within 30 s of the launch's first record, so a session made
|
|
2070
|
+
* later under a pane's name (a command in a pane can make one) is not that pane.
|
|
2071
|
+
*/
|
|
2072
|
+
async #paneOfPid(pid) {
|
|
2073
|
+
if (process.platform === 'win32') return null;
|
|
2074
|
+
const read = (program, args) => new Promise((done) => execFile(program, args, { encoding: 'utf8', timeout: 5000, env: this.env }, (error, out) => done(error ? '' : String(out))));
|
|
2075
|
+
const parents = new Map((await read('ps', ['-A', '-o', 'pid=,ppid='])).trim().split('\n').map((line) => line.trim().split(/\s+/).map(Number)).filter(([child, parent]) => child && parent));
|
|
2076
|
+
const ancestry = [];
|
|
2077
|
+
for (let at = pid; at > 1 && ancestry.length < 64 && !ancestry.includes(at); at = parents.get(at)) ancestry.push(at);
|
|
2078
|
+
const panes = (await read('tmux', [...(this.tmuxSocket ? ['-L', this.tmuxSocket] : []), 'list-panes', '-a', '-F', '#{pane_pid} #{session_name} #{session_created}'])).trim().split('\n').map((line) => line.split(' ')).filter((fields) => fields.length === 3);
|
|
2079
|
+
const hit = panes.find(([panePid]) => ancestry.includes(Number(panePid)));
|
|
2080
|
+
if (!hit) return null;
|
|
2081
|
+
const [, name, created] = hit;
|
|
2082
|
+
let first = null, last = null;
|
|
2083
|
+
try {
|
|
2084
|
+
for (const line of readFileSync(this.p.launches, 'utf8').split('\n')) {
|
|
2085
|
+
let row; try { row = JSON.parse(line); } catch { continue; }
|
|
2086
|
+
if (row.pane !== name) continue;
|
|
2087
|
+
first ??= row; if (row.key === first.key) last = row;
|
|
2088
|
+
}
|
|
2089
|
+
} catch { /* no launches */ }
|
|
2090
|
+
const createdMs = Number(created) * 1000;
|
|
2091
|
+
if (!first || !(createdMs >= first.t - 2000 && createdMs <= first.t + 30_000)) return { pane: name, by: null, launched: false };
|
|
2092
|
+
return { pane: name, by: last?.by ?? null, launched: true };
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
/** Who acts on a pane through its doors (launch, cancel, close, input, suspend, resume, turn, a draft cleared, control
|
|
2096
|
+
* attached, and the caller handed to another machine for its remote forms): the
|
|
2097
|
+
* caller, except on Windows, where a local claim is only the client's own word (a named pipe shows no peer process),
|
|
2098
|
+
* so it is no caller there and acting on another's pane takes --foreign (#1245 review round 3). Mail keeps the claim's
|
|
2099
|
+
* sender, labelled as the machine sees it. */
|
|
2100
|
+
async #actorOf(entry, params) {
|
|
2101
|
+
const caller = await this.#callerOf(entry, params);
|
|
2102
|
+
return process.platform === 'win32' && entry.principal === 'local' && !entry.appDoor ? null : caller;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2052
2105
|
#callerOf(entry, params) {
|
|
2053
2106
|
const named = typeof params.caller === 'string' && params.caller ? params.caller : null;
|
|
2054
|
-
|
|
2107
|
+
// a caller another machine names for its own process: never a board (a board acts on its own machine) nor a session
|
|
2108
|
+
// of this machine (this machine's sessions do not reach it through the link) (#1245 review)
|
|
2109
|
+
if (entry.principal !== 'local') {
|
|
2110
|
+
const parsed = named ? /^sc:([^:]+):([^:]+):/.exec(named) : null;
|
|
2111
|
+
return Promise.resolve(parsed && parsed[2] !== 'board' && normalMachineName(parsed[1]) !== normalMachineName(machineName(this.env)) ? named : null);
|
|
2112
|
+
}
|
|
2055
2113
|
// A page the app door relays is an origin and a token, not this OS user: whatever it says of itself, it is a person.
|
|
2056
2114
|
if (entry.appDoor) return Promise.resolve(null);
|
|
2057
2115
|
const claim = params.claim && typeof params.claim === 'object' ? params.claim : null;
|
|
2058
2116
|
if (!claim) return Promise.reject(Object.assign(new Error('this client did not say which session it runs in (a supercode older than this daemon); update it. Nothing was done'), { code: 403 }));
|
|
2059
|
-
|
|
2117
|
+
// the session the claim's process runs under, as its ancestry says; else, in a pane this daemon launched, who
|
|
2118
|
+
// launched it (this daemon's own record, never what the command set for itself: #1245 review)
|
|
2119
|
+
return (async () => {
|
|
2120
|
+
// the claim's pid stands only when it is this connection's other end, as the kernel says: a client that names
|
|
2121
|
+
// another process (a session's) is no one (#1245 review). A connection made inside this daemon has no socket.
|
|
2122
|
+
const pid = Number(claim.pid);
|
|
2123
|
+
const proven = !entry.socket || await peerIs(entry.socket, pid);
|
|
2124
|
+
const caller = await this.live.callerOf(proven ? claim : { ...claim, pid: null, ppid: null });
|
|
2125
|
+
if (caller) return caller;
|
|
2126
|
+
const pane = proven && Number.isSafeInteger(pid) && pid > 1 ? await this.#paneOfPid(pid) : null;
|
|
2127
|
+
// a pane's launcher, but never a board's name: a board acts only under its own process, and a session on its card
|
|
2128
|
+
// that is no session (its id unset) is no one, not its board (#1245 review P0)
|
|
2129
|
+
return pane?.launched && pane.by && !/^sc:[^:]+:board:/.test(pane.by) ? pane.by : null;
|
|
2130
|
+
})();
|
|
2060
2131
|
}
|
|
2061
2132
|
|
|
2062
2133
|
/** Whether `pid` holds the board lock of the home at `root` (`board.lock`, held by its owner's lock holder child): the
|
|
@@ -2100,7 +2171,10 @@ export class TeamsMachine extends MachineCore {
|
|
|
2100
2171
|
});
|
|
2101
2172
|
}
|
|
2102
2173
|
|
|
2103
|
-
#refuseForeign(pane, caller) {
|
|
2174
|
+
#refuseForeign(pane, caller, foreign = false) {
|
|
2175
|
+
if (foreign) return;
|
|
2176
|
+
// no caller is no authority: it types into, suspends or turns another's pane only with --foreign (#1245 review)
|
|
2177
|
+
if (caller == null) throw Object.assign(new Error(`pane ${pane}: no session or pane is behind this command, so nothing says it may act on that pane. Nothing was done; say --foreign to act on another's pane`), { code: 403 });
|
|
2104
2178
|
const starter = this.#foreignStarter(pane, caller);
|
|
2105
2179
|
if (starter) throw Object.assign(new Error(`pane ${pane} ${starter}, not by ${caller}: a session acts only on the panes it started. Nothing was done; reach that session through its own doors (supercode message send)`), { code: 403 });
|
|
2106
2180
|
}
|
|
@@ -3313,7 +3387,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
3313
3387
|
if (programName === 'codex') params = { ...params, kind: 'codex' };
|
|
3314
3388
|
const key = params.key ?? `lk_${randomBytes(9).toString('base64url')}`;
|
|
3315
3389
|
// who starts it: a claim the map refuses refuses the launch, never records it as a person's
|
|
3316
|
-
const by = await this.#
|
|
3390
|
+
const by = await this.#actorOf(entry, params);
|
|
3317
3391
|
// A pane that reopens an existing session (`supercode open <id>`) is that session's resume, by `by`: kept as
|
|
3318
3392
|
// `resumes`, with the session as its native session, so the row joins to its session without the pane and the
|
|
3319
3393
|
// session's first starter stays the `by` of its own launch. `by` stays who started this pane (#refuseForeign).
|
|
@@ -3364,8 +3438,11 @@ export class TeamsMachine extends MachineCore {
|
|
|
3364
3438
|
command: this.#nativeQuestionCommand(params, contextKey, by, (value) => { nativeInput = value; }),
|
|
3365
3439
|
contextKey,
|
|
3366
3440
|
cwd: params.cwd,
|
|
3367
|
-
// A pane that runs one verb for its launcher (`close`/`stop` on another machine)
|
|
3368
|
-
|
|
3441
|
+
// A pane that runs one verb for its launcher (`close`/`stop` on another machine) is told who that is; what a
|
|
3442
|
+
// process in any pane acts for is read from this daemon's own record (harness.v1.pane_of), never its
|
|
3443
|
+
// environment. Neither variable is taken from the request's own env, where a session could name another.
|
|
3444
|
+
env: this.#paneEnv(contextKey, (({ SUPERCODE_CALLER: _caller, SUPERCODE_LAUNCHED_BY: _launched, ...asked }) =>
|
|
3445
|
+
params.actForCaller && by ? { ...asked, SUPERCODE_CALLER: by } : asked)(params.env ?? {})),
|
|
3369
3446
|
// The pane's own tmux session is always its key: the exit hooks record and clean up p_* sessions only, and
|
|
3370
3447
|
// a pane named otherwise lingered dead with its exit status lost. A launch's name is its title.
|
|
3371
3448
|
name: contextKey,
|
|
@@ -3426,7 +3503,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
3426
3503
|
case 'launches.cancel': {
|
|
3427
3504
|
if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
|
|
3428
3505
|
if (typeof params.key !== 'string' || !params.key.trim()) throw Object.assign(new Error('launch cancellation requires a nonempty key'), { code: 400 });
|
|
3429
|
-
const by = await this.#
|
|
3506
|
+
const by = await this.#actorOf(entry, params);
|
|
3430
3507
|
// Retire only an absent key. An issued, live or failed launch keeps its actual
|
|
3431
3508
|
// receipt; cancellation never closes a pane or guesses that issuance did not happen.
|
|
3432
3509
|
let found = this.launches.find(params.key);
|
|
@@ -3496,12 +3573,14 @@ export class TeamsMachine extends MachineCore {
|
|
|
3496
3573
|
// only a pane here (open, or still starting) is anyone's: one this machine lacks is answered as absent, before
|
|
3497
3574
|
// the caller is asked for
|
|
3498
3575
|
const here = starting.length > 0 || Boolean(await this.#paneByContext(params.pane));
|
|
3499
|
-
const closeBy = here ? await this.#
|
|
3500
|
-
|
|
3576
|
+
const closeBy = here ? await this.#actorOf(entry, params) : null;
|
|
3577
|
+
// no caller (a command with no session behind it, in no pane a session launched) is no authority: a session that
|
|
3578
|
+
// left its own ancestry looks the same, so closing what nobody shown started needs --foreign too (#1245 review)
|
|
3579
|
+
const starter = params.foreign === true || !here ? null : closeBy == null ? 'was not started by this command (no session or pane is behind it)' : this.#foreignStarter(params.pane, closeBy);
|
|
3501
3580
|
// the pane the caller runs in: a Claude row names it as its tmux locator (`p_…:@window.%pane`), the others as the pane
|
|
3502
3581
|
if (starter && !this.live.sessions().some((live) => live.address === closeBy && String(live.pane ?? '').split(':')[0] === params.pane)) {
|
|
3503
3582
|
act('close', `pane:${params.pane}`, 'refused', `foreign: ${starter}`);
|
|
3504
|
-
throw Object.assign(new Error(`pane ${params.pane} ${starter}
|
|
3583
|
+
throw Object.assign(new Error(`pane ${params.pane} ${starter}${closeBy ? `, not by ${closeBy}` : ""}. Nothing was closed. A session closes the panes it started; closing another's is deliberate: read what the pane holds, settle it with whoever started it, then close that one pane with --foreign`), { code: 403 });
|
|
3505
3584
|
}
|
|
3506
3585
|
for (const pending of starting) pending.abort.abort();
|
|
3507
3586
|
const session = await this.#paneByContext(params.pane);
|
|
@@ -3520,8 +3599,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
3520
3599
|
// working, only `{input}` is refused — until `resume`, which may carry
|
|
3521
3600
|
// the input that hands the pane back. Local tmux input is never touched.
|
|
3522
3601
|
need('control', `pane:${params.pane}`);
|
|
3523
|
-
const resumeBy = await this.#
|
|
3524
|
-
this.#refuseForeign(params.pane, resumeBy);
|
|
3602
|
+
const resumeBy = await this.#actorOf(entry, params);
|
|
3603
|
+
this.#refuseForeign(params.pane, resumeBy, params.foreign === true);
|
|
3525
3604
|
const session = await this.#paneByContext(params.pane);
|
|
3526
3605
|
if (!session) throw new Error(`no pane with context ${params.pane}`);
|
|
3527
3606
|
// the input a resume carries is a turn like any other: a session's is mail, and the door's is recorded first
|
|
@@ -3550,8 +3629,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
3550
3629
|
// mechanics) for callers that are services rather than terminals. Same gate as an
|
|
3551
3630
|
// attachment in control mode, same suspend rule as remote input everywhere.
|
|
3552
3631
|
need('control', `pane:${params.pane}`);
|
|
3553
|
-
const inputBy = await this.#
|
|
3554
|
-
this.#refuseForeign(params.pane, inputBy);
|
|
3632
|
+
const inputBy = await this.#actorOf(entry, params);
|
|
3633
|
+
this.#refuseForeign(params.pane, inputBy, params.foreign === true);
|
|
3555
3634
|
const session = await this.#paneByContext(params.pane);
|
|
3556
3635
|
if (!session) throw Object.assign(new Error(`no pane with context ${params.pane}`), { code: 404 });
|
|
3557
3636
|
if (this.suspended.has(params.pane)) throw Object.assign(new Error(`pane ${params.pane} is suspended: remote input is refused until resume`), { code: 423 });
|
|
@@ -3580,7 +3659,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
3580
3659
|
}
|
|
3581
3660
|
case 'panes.clearDraft': {
|
|
3582
3661
|
need('control', `pane:${params.pane}`);
|
|
3583
|
-
this.#refuseForeign(params.pane, await this.#
|
|
3662
|
+
this.#refuseForeign(params.pane, await this.#actorOf(entry, params), params.foreign === true);
|
|
3584
3663
|
const session = await this.#paneByContext(params.pane);
|
|
3585
3664
|
if (!session) throw new Error(`no pane with context ${params.pane}`);
|
|
3586
3665
|
if (this.suspended.has(params.pane)) throw new Error('pane is suspended; resume remote input first');
|
|
@@ -3640,8 +3719,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
3640
3719
|
// answer, not an error: the caller keeps the turn and asks again. Its own
|
|
3641
3720
|
// method, so a daemon without it refuses instead of typing into a draft.
|
|
3642
3721
|
need('control', `pane:${params.pane}`);
|
|
3643
|
-
const turnBy = await this.#
|
|
3644
|
-
this.#refuseForeign(params.pane, turnBy);
|
|
3722
|
+
const turnBy = await this.#actorOf(entry, params);
|
|
3723
|
+
this.#refuseForeign(params.pane, turnBy, params.foreign === true);
|
|
3645
3724
|
// a refused turn acts on nothing: the refusal comes before any capture, wait or key in the pane
|
|
3646
3725
|
const sessionTurn = this.#sessionTurn(params, turnBy);
|
|
3647
3726
|
if (sessionTurn?.refused) { act('control', `pane:${params.pane}`, 'refused', JSON.stringify(sessionTurn.refused)); return sessionTurn.refused; }
|
|
@@ -4030,9 +4109,18 @@ export class TeamsMachine extends MachineCore {
|
|
|
4030
4109
|
}
|
|
4031
4110
|
// Who a local client is, from its claim (live-sessions.mjs callerOf): what a command acting on another machine sends
|
|
4032
4111
|
// there as its caller, resolved here, where the map that holds it is. Local only; never the app door's.
|
|
4112
|
+
// The pane this daemon launched that a local process runs in, and who started that pane: what a process with no
|
|
4113
|
+
// session above it acts for (main.rs acting_caller), read here from the daemon's own launches and its tmux server,
|
|
4114
|
+
// never from the asking process's environment, which its command sets (#1245 review: `env SUPERCODE_CALLER=…`).
|
|
4115
|
+
case 'harness.v1.pane_of': {
|
|
4116
|
+
if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('a pane is resolved only for a process on this machine'), { code: 403 });
|
|
4117
|
+
const pid = Number(params.pid);
|
|
4118
|
+
if (!Number.isSafeInteger(pid) || pid <= 1) throw Object.assign(new Error('name the process (pid)'), { code: 400 });
|
|
4119
|
+
return { pane: await this.#paneOfPid(pid) };
|
|
4120
|
+
}
|
|
4033
4121
|
case 'harness.v1.caller': {
|
|
4034
4122
|
if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('a caller is resolved only for a process on this machine'), { code: 403 });
|
|
4035
|
-
return { caller: await this.#
|
|
4123
|
+
return { caller: await this.#actorOf(entry, params) };
|
|
4036
4124
|
}
|
|
4037
4125
|
case 'harness.v1.boards.register': case 'harness.v1.boards.list': case 'harness.v1.boards.unregister': {
|
|
4038
4126
|
// A board home this machine keeps, published to its team by its connector (board-events.mjs).
|
|
@@ -4354,6 +4442,48 @@ function healthEntry(env) {
|
|
|
4354
4442
|
return existsSync(sibling) ? sibling : null;
|
|
4355
4443
|
}
|
|
4356
4444
|
|
|
4445
|
+
/**
|
|
4446
|
+
* Whether process `pid` is the other end of local connection `socket`, as the kernel says: the daemon's socket for it
|
|
4447
|
+
* and a socket of `pid` paired (macOS: lsof's own address and its peer's; Linux: the socket inodes ss pairs). Unknown
|
|
4448
|
+
* (no fd, no tool) is no.
|
|
4449
|
+
*/
|
|
4450
|
+
async function peerIs(socket, pid) {
|
|
4451
|
+
if (!Number.isSafeInteger(pid) || pid <= 1) return false;
|
|
4452
|
+
const fd = socket?._handle?.fd;
|
|
4453
|
+
if (!Number.isInteger(fd) || fd < 0) return false;
|
|
4454
|
+
const read = (program, args) => new Promise((done) => execFile(program, args, { encoding: 'utf8', timeout: 5000, maxBuffer: 16 * 1024 * 1024 }, (error, out) => done(error && !out ? '' : String(out ?? ''))));
|
|
4455
|
+
if (process.platform === 'darwin') {
|
|
4456
|
+
const own = /\nd(0x[0-9a-f]+)/.exec(await read(tool('lsof'), ['-a', '-p', String(process.pid), '-d', String(fd), '-U', '-F', 'd']))?.[1];
|
|
4457
|
+
if (!own) return false;
|
|
4458
|
+
return (await read(tool('lsof'), ['-a', '-p', String(pid), '-U', '-F', 'n'])).split('\n').includes(`n->${own}`);
|
|
4459
|
+
}
|
|
4460
|
+
if (process.platform === 'linux') {
|
|
4461
|
+
let inode = null;
|
|
4462
|
+
try { inode = /socket:\[(\d+)\]/.exec(readlinkSync(`/proc/self/fd/${fd}`))?.[1] ?? null; } catch { return false; }
|
|
4463
|
+
if (!inode) return false;
|
|
4464
|
+
// ss -xp: a row per socket, its own inode and its peer's; the peer's row names its process
|
|
4465
|
+
const rows = (await read(tool('ss'), ['-xpn'])).split('\n');
|
|
4466
|
+
const mine = rows.find((row) => new RegExp(`\\s${inode}\\s+\\S+\\s+(\\d+)`).test(row));
|
|
4467
|
+
const peer = mine ? new RegExp(`\\s${inode}\\s+\\S+\\s+(\\d+)`).exec(mine)?.[1] : null;
|
|
4468
|
+
if (!peer) return false;
|
|
4469
|
+
return rows.some((row) => new RegExp(`\\s${peer}\\s+\\S+\\s+${inode}\\b`).test(row) && row.includes(`pid=${pid},`));
|
|
4470
|
+
}
|
|
4471
|
+
return false;
|
|
4472
|
+
}
|
|
4473
|
+
|
|
4474
|
+
/** Whether process `pid` has a controlling terminal (a person's command has; one detached with setsid has none). */
|
|
4475
|
+
async function holdsTerminal(pid) {
|
|
4476
|
+
if (!Number.isSafeInteger(pid) || pid <= 1) return false;
|
|
4477
|
+
const tty = await new Promise((done) => execFile(tool('ps'), ['-o', 'tty=', '-p', String(pid)], { encoding: 'utf8', timeout: 5000 }, (_error, out) => done(String(out ?? '').trim())));
|
|
4478
|
+
return Boolean(tty) && !/^[?-]+$/.test(tty);
|
|
4479
|
+
}
|
|
4480
|
+
|
|
4481
|
+
/** A system tool by its own path: a daemon's PATH (a service's) may hold no /usr/sbin, where macOS keeps lsof. */
|
|
4482
|
+
function tool(name) {
|
|
4483
|
+
const at = { lsof: ['/usr/sbin/lsof', '/usr/bin/lsof', '/sbin/lsof'], ps: ['/bin/ps', '/usr/bin/ps'], ss: ['/usr/bin/ss', '/bin/ss', '/usr/sbin/ss', '/sbin/ss'] }[name] ?? [];
|
|
4484
|
+
return at.find((path) => existsSync(path)) ?? name;
|
|
4485
|
+
}
|
|
4486
|
+
|
|
4357
4487
|
/** What supercode's Windows shim runs, from its one line (`@"<node>" "<front door>" %*`, or `@"<native>" %*`;
|
|
4358
4488
|
* claude_relay.rs writes it): `[program, ...args]`, or null for a shim of another form. */
|
|
4359
4489
|
function shimTarget(path) {
|
package/mail/relay.mjs
CHANGED
|
@@ -372,8 +372,9 @@ export class Relays {
|
|
|
372
372
|
* by its name. One send at a time per relay; a relay whose process is proven dead is started again once.
|
|
373
373
|
*/
|
|
374
374
|
/** Whether `sender`'s relay still holds a send queued (its queue file, written within SEND_TIMEOUT_MS twice over): a
|
|
375
|
-
* relay runtime outlives a daemon that ended mid-send and may still make it.
|
|
376
|
-
|
|
375
|
+
* relay runtime outlives a daemon that ended mid-send and may still make it. (Not `sending`: that is the field of
|
|
376
|
+
* sends in progress, which would shadow it.) */
|
|
377
|
+
queued(sender, senderName) {
|
|
377
378
|
try { return Date.now() - statSync(this.spec(sender, senderName).queue).mtimeMs < 2 * SEND_TIMEOUT_MS; } catch { return false; }
|
|
378
379
|
}
|
|
379
380
|
|
package/mail/send.mjs
CHANGED
|
@@ -337,7 +337,7 @@ export class MailSend {
|
|
|
337
337
|
if (this.relaying.has(key)) throw unanswered('its relay send is still running here');
|
|
338
338
|
const earlier = this.store.hasWake(to, handed.id) ? this.store.wakeState(to, handed.id).handover_at_ms : null;
|
|
339
339
|
if (earlier != null) {
|
|
340
|
-
if (this.relays.
|
|
340
|
+
if (this.relays.queued(handed.from, handed.from_name)) throw unanswered('its relay may still be making it');
|
|
341
341
|
let landed = false;
|
|
342
342
|
try { landed = await this.relays.landed(door.session.address.split(':').slice(3).join(':'), handed.id, handed.from); } catch { /* unread: hand it over */ }
|
|
343
343
|
if (landed) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.135",
|
|
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",
|