@volter/supercode-teams 0.3.133 → 0.3.134

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 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
- die('machine start|status|restart|boards');
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
- /** The session a client runs in, from its claim: its parent's current session, else its environment's id through the map. */
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
- const parent = this.claude.get(Number(ppid));
747
- if (parent && alive(parent.pid)) return this.#address('claude-code', parent.sessionId);
748
- if (typeof session === 'string' && session) {
749
- const found = this.#sessionOfId(session);
750
- 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`);
751
- return found;
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
- // a runtime supercode hosts, calling from its own process or a child of it (the native agent's mail tool): taken
754
- // only when exactly one live runtime's receipt names that process. A process several receipts name (harness serve
755
- // records its own pid on each runtime it hosts with no child of its own) is no one runtime, and is refused.
756
- for (const candidate of [Number(pid), Number(ppid)]) {
757
- const found = await this.#runtimeOfPid(candidate, deadline);
758
- if (found) return found;
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, owner, named } = claim ?? {};
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 the owner that started it as the board
791
- // (`SUPERCODE_BOARD_OWNER_PID`, set by the owner for the commands it runs as its board), alive
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 && [pid, Number(owner)].includes(registered) && alive(registered)) return named;
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 verb a pane runs for the session that launched it names that session: accepted while it is live
800
- if (typeof named === 'string' && named) {
801
- if (this.sessions().some((live) => live.address === named)) return named;
802
- throw new CallerRefused(`${named} is not a live session on this machine, so nothing can act for it; nothing was done`);
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
- try { attachedBy = await this.#callerOf(entry, stream.params); } catch (error) { this.log.append({ principal, cap, target: `pane:${pane}`, outcome: 'refused', detail: error.message }); return refuse(error.message); }
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
- if (entry.principal !== 'local') return Promise.resolve(named);
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
- try { return Promise.resolve(this.live.callerOf(claim)); } catch (error) { return Promise.reject(error); }
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.#callerOf(entry, params);
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) acts as that launcher.
3368
- env: this.#paneEnv(contextKey, params.actForCaller && by ? { ...params.env, SUPERCODE_CALLER: by } : params.env),
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.#callerOf(entry, params);
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.#callerOf(entry, params) : null;
3500
- const starter = params.foreign === true || !here ? null : this.#foreignStarter(params.pane, closeBy);
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}, 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 });
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.#callerOf(entry, params);
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.#callerOf(entry, params);
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.#callerOf(entry, params));
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.#callerOf(entry, params);
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.#callerOf(entry, params) };
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.3.133",
3
+ "version": "0.3.134",
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",