antechamber 1.0.36 → 1.0.37

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/CHANGELOG.md CHANGED
@@ -7,6 +7,10 @@ and this project uses [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.37] - 2026-11-03
11
+
12
+ - feat(session-hosts): tear down private OmO hosts on macOS (#85)
13
+
10
14
  ## [1.0.36] - 2026-11-03
11
15
 
12
16
  ### Added
@@ -16,6 +20,12 @@ and this project uses [Semantic Versioning](https://semver.org/).
16
20
  keeps reading `/proc`; macOS reads `/bin/ps`, because Node has neither `/proc` nor pidfd there. A process that has
17
21
  already exited (zombie) never counts as a survivor.
18
22
 
23
+ - Tear down OmO's private task hosts on macOS. The connector finds the supervisors it started by the `--socket` session
24
+ shard, entry point and working directory, records every process in their groups, and signals each one by PID only
25
+ after re-checking its group and start time, escalating to `SIGKILL` after a grace window. A process that outlives its
26
+ supervisor is still stopped. `antechamber doctor` reports the check as PASS when `/bin/ps` and `/usr/sbin/lsof`
27
+ exist. Linux keeps the pidfd path.
28
+
19
29
  ## [1.0.35] - 2026-11-03
20
30
 
21
31
  - test: resolve fixture paths with fileURLToPath (#86)
package/README.md CHANGED
@@ -354,10 +354,13 @@ Hardening controls (full reference in [docs/configuration.md](docs/configuration
354
354
  ## Platform notes
355
355
 
356
356
  Linux is the primary supported platform. OmO's private task-host teardown
357
- (killing detached supervisor processes antechamber spawned) needs Linux
358
- pidfds (`os.pidfd_open`, Linux 5.3+) and `python3` on PATH; Node has no pidfd
359
- API. On other platforms that teardown step is skipped with one warning instead
360
- of crashing; antechamber never falls back to signalling numeric PIDs. macOS is untested.
357
+ (killing detached supervisor processes antechamber spawned) uses Linux
358
+ pidfds (`os.pidfd_open`, Linux 5.3+) and `python3` on PATH, because Node has no pidfd
359
+ API; on Linux it never signals a numeric PID. macOS has no pidfd, so there it
360
+ reads `/bin/ps` and `/usr/sbin/lsof` and signals each owned process by PID after
361
+ re-checking its process group and start time (see
362
+ [the OmO backend notes](docs/backends/omo.md#private-task-host-teardown)). CI runs the suite on
363
+ macOS. On other platforms that teardown step is skipped with one warning instead of crashing.
361
364
 
362
365
  ## Limitations / non-goals
363
366
 
@@ -84,9 +84,15 @@ export function doctor(options = {}, ctx = createContext(), latestVersion) {
84
84
  }
85
85
  })
86
86
  : undefined;
87
- add("private-host-teardown", python === undefined ? "WARN" : "PASS", python === undefined
88
- ? "requires Linux and python3 with working pidfd support"
89
- : `Linux pidfd support via ${python}`);
87
+ if (ctx.platform === "darwin") {
88
+ const tools = ["/bin/ps", "/usr/sbin/lsof"];
89
+ const missing = tools.filter((tool) => !ctx.fs.existsSync(tool));
90
+ add("private-host-teardown", missing.length === 0 ? "PASS" : "WARN", missing.length === 0 ? `macOS teardown via ${tools.join(" and ")}` : `requires ${missing.join(" and ")}`);
91
+ }
92
+ else
93
+ add("private-host-teardown", python === undefined ? "WARN" : "PASS", python === undefined
94
+ ? "requires Linux and python3 with working pidfd support"
95
+ : `Linux pidfd support via ${python}`);
90
96
  const openchamber = resolveBinary("openchamber", ctx);
91
97
  if (openchamber === undefined)
92
98
  add("openchamber", "WARN", "openchamber is not on PATH");
@@ -1,4 +1,4 @@
1
- import type { ProcessTable } from "./proc-table.js";
1
+ import type { ProcessIdentity, ProcessTable } from "./proc-table.js";
2
2
  /**
3
3
  * macOS process table, backed by `/bin/ps`. Used by disposal to identify owned survivors, so it must
4
4
  * report the same three facts Linux reads from `/proc/<pid>/stat`: PID, process group, and a start
@@ -13,4 +13,11 @@ export type PsRunner = (args: readonly string[]) => string;
13
13
  * calls must agree with each other even if the user changes the system language mid-session.
14
14
  */
15
15
  export declare function runPs(args: readonly string[]): string;
16
+ /** One `ps -A` row with its state and argv, for callers that match processes by command line. */
17
+ export interface PsProcess extends ProcessIdentity {
18
+ readonly state: string;
19
+ readonly args: string;
20
+ }
21
+ /** Every live (non-zombie) process with its argv. Throws when `ps` fails, so the caller decides. */
22
+ export declare function listDarwinProcesses(run?: PsRunner): PsProcess[];
16
23
  export declare function createDarwinProcTable(run?: PsRunner): ProcessTable;
@@ -40,6 +40,21 @@ function parseLine(line) {
40
40
  function alive(entry) {
41
41
  return !entry.state.startsWith("Z");
42
42
  }
43
+ // Under LC_ALL=C, lstart is a fixed 24-character field, so argv starts right after it even when it
44
+ // contains spaces.
45
+ const PS_ARGS_LINE = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.{24})\s?(.*)$/;
46
+ /** Every live (non-zombie) process with its argv. Throws when `ps` fails, so the caller decides. */
47
+ export function listDarwinProcesses(run = runPs) {
48
+ return run(["-ww", "-o", `${PS_COLUMNS},args=`, "-A"])
49
+ .split("\n")
50
+ .flatMap((line) => {
51
+ const match = PS_ARGS_LINE.exec(line);
52
+ if (match === null)
53
+ return [];
54
+ const entry = { pid: Number(match[1]), group: Number(match[2]), state: match[3] ?? "", start: match[4] ?? "" };
55
+ return alive(entry) ? [{ ...entry, args: match[5] ?? "" }] : [];
56
+ });
57
+ }
43
58
  function toIdentity(entry) {
44
59
  return { pid: entry.pid, group: entry.group, start: entry.start };
45
60
  }
@@ -1,3 +1,24 @@
1
+ import { type PsRunner } from "./proc-table-darwin.js";
1
2
  /** Capability and warning state are captured once, before any shutdown work. */
2
3
  export declare function createSessionHostStopper(platform?: NodeJS.Platform, probe?: () => string | undefined): (created: ReadonlyMap<string, string>) => Promise<void>;
3
4
  export declare const stopSessionHosts: (created: ReadonlyMap<string, string>) => Promise<void>;
5
+ /** Injected so the darwin path can be tested without running ps, lsof or a signal. */
6
+ export interface DarwinHostDependencies {
7
+ readonly run: PsRunner;
8
+ readonly cwdOf: (pid: number) => string | undefined;
9
+ readonly realpath: (path: string) => string;
10
+ readonly signal: (pid: number, signal: NodeJS.Signals) => void;
11
+ readonly delay: (ms: number) => Promise<void>;
12
+ readonly graceMs: number;
13
+ readonly pollMs: number;
14
+ }
15
+ /**
16
+ * macOS teardown without pidfd. Ownership is the Linux rule: a live group leader whose argv names the
17
+ * supervisor entry point and, as its `--socket` value, the shard of a session this connector created,
18
+ * and whose working directory is that session's directory. Every member of an owned group is recorded
19
+ * with its start time in the same scan, and each later signal goes to one PID only after a fresh scan
20
+ * still shows the same group and start time. Members are tracked individually, so a child that outlives
21
+ * its supervisor is still stopped. A PID can be recycled between that scan and the signal; the window
22
+ * is the same one `PiRpcClient.dispose()` accepts.
23
+ */
24
+ export declare function createDarwinHostStopper(overrides?: Partial<DarwinHostDependencies>): (created: ReadonlyMap<string, string>) => Promise<void>;
@@ -1,11 +1,17 @@
1
- import { spawn } from "node:child_process";
1
+ import { execFileSync, spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { once } from "node:events";
4
4
  import { closeSync, constants, openSync, readFileSync, readdirSync, readlinkSync, realpathSync } from "node:fs";
5
5
  import { basename } from "node:path";
6
6
  import { findPidfdPython } from "./pidfd.js";
7
+ import { listDarwinProcesses, runPs } from "./proc-table-darwin.js";
8
+ /** How long a darwin teardown waits after SIGTERM before escalating, and how often it re-reads the table. */
9
+ const HOST_TERM_GRACE_MS = 5_000;
10
+ const HOST_POLL_MS = 100;
7
11
  /** Capability and warning state are captured once, before any shutdown work. */
8
12
  export function createSessionHostStopper(platform = process.platform, probe = findPidfdPython) {
13
+ if (platform === "darwin")
14
+ return createDarwinHostStopper();
9
15
  const python = platform === "linux" ? probe() : undefined;
10
16
  let warned = false;
11
17
  return async (created) => {
@@ -22,6 +28,121 @@ export function createSessionHostStopper(platform = process.platform, probe = fi
22
28
  };
23
29
  }
24
30
  export const stopSessionHosts = createSessionHostStopper();
31
+ /** macOS has no `/proc/<pid>/cwd`; lsof prints `p<pid>`, `fcwd`, `n<path>`. */
32
+ function cwdOfHost(pid) {
33
+ try {
34
+ const output = execFileSync("/usr/sbin/lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], {
35
+ encoding: "utf8",
36
+ timeout: 2_000,
37
+ maxBuffer: 1024 * 1024,
38
+ });
39
+ for (const line of output.split("\n"))
40
+ if (line.startsWith("n"))
41
+ return line.slice(1);
42
+ return undefined;
43
+ }
44
+ catch {
45
+ // No permission, no process, or no lsof: the directory is unconfirmed and nothing is signalled.
46
+ return undefined;
47
+ }
48
+ }
49
+ function signalHost(pid, signal) {
50
+ try {
51
+ process.kill(pid, signal);
52
+ }
53
+ catch (error) {
54
+ const code = error instanceof Error && "code" in error ? error.code : undefined;
55
+ // Gone since the last scan, or not ours to signal: either way nothing was sent.
56
+ if (code === "ESRCH" || code === "EPERM")
57
+ return;
58
+ throw error;
59
+ }
60
+ }
61
+ function socketName(sessionId) {
62
+ return `p-${createHash("sha256").update(`p:${sessionId}`).digest("hex").slice(0, 16)}.sock`;
63
+ }
64
+ /**
65
+ * The value of `--socket`, as Linux reads it from argv. `ps` joins argv with spaces, so the value runs
66
+ * to the next ` --` flag (a socket directory may contain spaces). Anything else after it makes the
67
+ * basename miss every shard, which fails closed.
68
+ */
69
+ function socketArgument(args) {
70
+ const match = /(?:^|\s)--socket\s+(.+?)(?=\s--|$)/.exec(args);
71
+ return match?.[1] === undefined ? undefined : basename(match[1].trimEnd());
72
+ }
73
+ function isSupervisor(args) {
74
+ const tokens = args.split(/\s+/);
75
+ return (tokens.includes("--internal-rpc-host-supervisor") || tokens.some((token) => token.endsWith("/host-lifecycle.js")));
76
+ }
77
+ /**
78
+ * macOS teardown without pidfd. Ownership is the Linux rule: a live group leader whose argv names the
79
+ * supervisor entry point and, as its `--socket` value, the shard of a session this connector created,
80
+ * and whose working directory is that session's directory. Every member of an owned group is recorded
81
+ * with its start time in the same scan, and each later signal goes to one PID only after a fresh scan
82
+ * still shows the same group and start time. Members are tracked individually, so a child that outlives
83
+ * its supervisor is still stopped. A PID can be recycled between that scan and the signal; the window
84
+ * is the same one `PiRpcClient.dispose()` accepts.
85
+ */
86
+ export function createDarwinHostStopper(overrides = {}) {
87
+ const deps = {
88
+ run: overrides.run ?? runPs,
89
+ cwdOf: overrides.cwdOf ?? cwdOfHost,
90
+ realpath: overrides.realpath ?? realpathSync,
91
+ signal: overrides.signal ?? signalHost,
92
+ delay: overrides.delay ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
93
+ graceMs: overrides.graceMs ?? HOST_TERM_GRACE_MS,
94
+ pollMs: overrides.pollMs ?? HOST_POLL_MS,
95
+ };
96
+ return async (created) => {
97
+ if (created.size === 0)
98
+ return;
99
+ const sockets = new Map([...created].map(([id, directory]) => [socketName(id), directory]));
100
+ const processes = listDarwinProcesses(deps.run);
101
+ const groups = new Set();
102
+ for (const row of processes) {
103
+ if (row.group !== row.pid || !isSupervisor(row.args))
104
+ continue;
105
+ const socket = socketArgument(row.args);
106
+ const directory = socket === undefined ? undefined : sockets.get(socket);
107
+ if (directory === undefined)
108
+ continue;
109
+ const cwd = deps.cwdOf(row.pid);
110
+ if (cwd === undefined)
111
+ continue;
112
+ let expected;
113
+ try {
114
+ expected = deps.realpath(directory);
115
+ }
116
+ catch {
117
+ continue;
118
+ }
119
+ if (cwd === expected)
120
+ groups.add(row.group);
121
+ }
122
+ const members = processes.filter((row) => groups.has(row.group));
123
+ if (members.length === 0)
124
+ return;
125
+ const survivors = () => {
126
+ const now = new Map(listDarwinProcesses(deps.run).map((row) => [row.pid, row]));
127
+ return members
128
+ .filter((member) => {
129
+ const row = now.get(member.pid);
130
+ return row !== undefined && row.group === member.group && row.start === member.start;
131
+ })
132
+ .map((member) => member.pid);
133
+ };
134
+ for (const signal of ["SIGTERM", "SIGKILL"]) {
135
+ for (const pid of survivors())
136
+ deps.signal(pid, signal);
137
+ for (let waited = 0; waited < deps.graceMs; waited += deps.pollMs) {
138
+ await deps.delay(deps.pollMs);
139
+ if (survivors().length === 0)
140
+ return;
141
+ }
142
+ }
143
+ throw new Error(`private OmO hosts survived shutdown: ${survivors().join(", ")}`);
144
+ };
145
+ }
25
146
  function processInfo(pid) {
26
147
  let fd;
27
148
  try {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antechamber-omo-compat",
3
- "version": "1.0.36",
3
+ "version": "1.0.37",
4
4
  "description": "OpenChamber extension: displays OmO tool calls the way OmO means them when OpenChamber runs on antechamber",
5
5
  "private": true,
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antechamber-omo-graph",
3
- "version": "1.0.36",
3
+ "version": "1.0.37",
4
4
  "description": "OpenChamber extension: live, interactive graph of the OmO sub-agents behind the current session",
5
5
  "private": true,
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antechamber-omo-work",
3
- "version": "1.0.36",
3
+ "version": "1.0.37",
4
4
  "description": "OpenChamber 2.x Work Status section: OmO todo phases, monitors, eval cells and background work of the current session",
5
5
  "private": true,
6
6
  "type": "module",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antechamber",
3
- "version": "1.0.36",
3
+ "version": "1.0.37",
4
4
  "description": "OpenCode-compatible HTTP/SSE server that lets OpenChamber drive the pi coding agent (and OmO) over RPC",
5
5
  "type": "module",
6
6
  "license": "MIT",