@mnemom/mnemom 0.17.32-next.2 → 0.17.32-next.3

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.
@@ -0,0 +1,277 @@
1
+ /**
2
+ * The one launch-spec builder, buildDurableLaunchSpec (BACKBONE C4; C14-R01..R05, R12, R13; S1
3
+ * spec.ts). Every path that commits a spec (the lock verb, /mnemom lock, the launcher's
4
+ * durable=always path, the API lock route's validation and `sessions adopt`) builds it here.
5
+ *
6
+ * Pure: no I/O. The callers read the argv (the running source reads the original claude's argv
7
+ * exactly, the idle source passes the launcher's resolved continue argv) and resolve the cwd.
8
+ *
9
+ * Choices (each from S1 or the C14 spec, none invented here):
10
+ * - Only --model <v>, --effort <v> and --trim-mcp reach a committed spec (S1 SEAT_USER_FLAGS).
11
+ * - Names follow ruling 15 (S1 droppedArgvNames): left to right, flags verbatim, deduplicated,
12
+ * then one `positional arguments (<n>)` entry; tokens after claude's own `--` are positionals.
13
+ * - An allowlisted flag whose value the closed S1 schema cannot hold (MODEL_RE, EFFORT_VALUES:
14
+ * newlines, shell metacharacters, unknown effort) is dropped and named instead (C14-R03, R05,
15
+ * R23); the last valid occurrence of a repeated flag wins, as claude's parser does.
16
+ * - The yolo-default bypass flag the launcher injects counts as dropped and is named (S1
17
+ * spec.ts freeze; C14-R12), and a dropped --remote-control (explicit or auto-upgraded) is
18
+ * always named (M1 is terminal-only).
19
+ * - The spec holds no goal, prompt, positional, remote-control field, plugin path or hash: the
20
+ * plugin is the token `bundled-mnemom` (C14-R05, R26).
21
+ */
22
+ import { canonicalArgvPairs, droppedArgvNames, EFFORT_VALUES, hasRemoteControl, MODEL_RE, parseLaunchSpec, PLUGIN_TOKEN, renderAdoptNote, renderFixLine, SEAT_USER_FLAGS, SPEC_VERSION, specHash, } from "../vendor/durable-contract/index.js";
23
+ /** The bypass flag the launcher's yolo default injects (cli/src/commands/agent.ts buildCliArgs). */
24
+ export const YOLO_FLAG = "--dangerously-skip-permissions";
25
+ /** The launcher option for Remote Control; never committed in M1, always named when dropped. */
26
+ export const REMOTE_CONTROL_FLAG = "--remote-control";
27
+ /** One token as claude's parser reads it: '-'-leading (not '-') is a flag; `--f=v` is split. */
28
+ function readToken(tok) {
29
+ if (tok === "-" || !tok.startsWith("-"))
30
+ return { flag: null };
31
+ const eq = tok.startsWith("--") ? tok.indexOf("=") : -1;
32
+ return eq > 0 ? { flag: tok.slice(0, eq), inline: tok.slice(eq + 1) } : { flag: tok };
33
+ }
34
+ function validValue(flag, value) {
35
+ if (flag === "--model")
36
+ return MODEL_RE.test(value);
37
+ return EFFORT_VALUES.includes(value);
38
+ }
39
+ /**
40
+ * The C14-R01..R03 filter over a claude-level argv (after argv[0]): the flags a seat keeps, and
41
+ * the allowlisted flags dropped because their value is missing or not schema-valid. Every other
42
+ * name comes from droppedArgvNames (S1), so the two never disagree.
43
+ */
44
+ export function filterSeatArgv(argv) {
45
+ const kept = { trim_mcp: false };
46
+ const invalid = [];
47
+ for (let i = 0; i < argv.length; i++) {
48
+ const tok = argv[i];
49
+ if (tok === "--")
50
+ break;
51
+ const { flag, inline } = readToken(tok);
52
+ if (flag === null)
53
+ continue;
54
+ const arity = Object.prototype.hasOwnProperty.call(SEAT_USER_FLAGS, flag)
55
+ ? SEAT_USER_FLAGS[flag]
56
+ : undefined;
57
+ if (arity === undefined)
58
+ continue;
59
+ if (arity === 0) {
60
+ kept.trim_mcp = true;
61
+ continue;
62
+ }
63
+ let value = inline;
64
+ if (value === undefined) {
65
+ const next = argv[i + 1];
66
+ if (next !== undefined && readToken(next).flag === null) {
67
+ value = next;
68
+ i++;
69
+ }
70
+ }
71
+ // A value-less flag is named by droppedArgvNames; only a present, invalid value is ours.
72
+ if (value === undefined)
73
+ continue;
74
+ // The last valued occurrence decides (claude reads the last one): a later valid value
75
+ // clears an earlier invalid one, and a later invalid one is named though an earlier is kept.
76
+ const at = invalid.indexOf(flag);
77
+ if (!validValue(flag, value)) {
78
+ if (at < 0)
79
+ invalid.push(flag);
80
+ continue;
81
+ }
82
+ if (at >= 0)
83
+ invalid.splice(at, 1);
84
+ if (flag === "--model")
85
+ kept.model = value;
86
+ else
87
+ kept.effort = value;
88
+ }
89
+ return { kept, invalid };
90
+ }
91
+ /** The dropped-flag names (ruling 15) plus the invalid-value names, positional entry kept last. */
92
+ export function droppedNames(argv) {
93
+ const names = droppedArgvNames(argv);
94
+ const { invalid } = filterSeatArgv(argv);
95
+ const extra = invalid.filter((f) => !names.includes(f));
96
+ if (extra.length === 0)
97
+ return names;
98
+ const last = names[names.length - 1];
99
+ const hasPositionals = last !== undefined && last.startsWith("positional arguments (");
100
+ return hasPositionals ? [...names.slice(0, -1), ...extra, last] : [...names, ...extra];
101
+ }
102
+ /**
103
+ * buildDurableLaunchSpec: the committed, goal-elided spec and the names of what it dropped.
104
+ * Throws a RangeError when the result would not validate against the closed S1 schema (a cwd
105
+ * that is not absolute, or holds a newline): a caller bug, never a user input path.
106
+ */
107
+ export async function buildDurableLaunchSpec(input) {
108
+ const { kept } = filterSeatArgv(input.argv);
109
+ const spec = {
110
+ v: SPEC_VERSION,
111
+ cwd: input.cwd,
112
+ plugin: PLUGIN_TOKEN,
113
+ trim_mcp: kept.trim_mcp,
114
+ };
115
+ if (kept.model !== undefined)
116
+ spec.model = kept.model;
117
+ if (kept.effort !== undefined)
118
+ spec.effort = kept.effort;
119
+ if (parseLaunchSpec(spec) === null || hasRemoteControl(spec))
120
+ throw new RangeError("buildDurableLaunchSpec: the spec does not fit the closed S1 schema");
121
+ const named = [];
122
+ if (input.yoloInjected)
123
+ named.push(YOLO_FLAG);
124
+ if (input.remoteControl)
125
+ named.push(REMOTE_CONTROL_FLAG);
126
+ const dropped = droppedNames([...named, ...(input.userArgv ?? input.argv)]);
127
+ return { spec, dropped, hash: await specHash(spec) };
128
+ }
129
+ /** The seat's user-flag argv for a spec (the daemon adds its own flags around it), C14-R01. */
130
+ export function specUserArgv(spec) {
131
+ const out = [];
132
+ if (spec.model !== undefined)
133
+ out.push("--model", spec.model);
134
+ if (spec.effort !== undefined)
135
+ out.push("--effort", spec.effort);
136
+ if (spec.trim_mcp)
137
+ out.push("--trim-mcp");
138
+ return out;
139
+ }
140
+ /** The canonical multiset of a spec's user flags, for comparing with an argv (S1 canonicalArgvPairs). */
141
+ export function specUserPairs(spec) {
142
+ return canonicalArgvPairs(specUserArgv(spec));
143
+ }
144
+ // ---- the original's argv: launcher layout ---------------------------------------------------
145
+ /** The flags the launcher puts before the passthrough, in buildCliArgs order, with arity. */
146
+ const LAUNCHER_PREFIX = [
147
+ ["--model", 1],
148
+ ["--effort", 1],
149
+ [YOLO_FLAG, 0],
150
+ ["--session-id", 1],
151
+ ["--plugin-dir", 1],
152
+ ];
153
+ // No bounds check: the one caller's loop keeps 0 <= at and at + needle.length <= hay.length.
154
+ function isSubsequenceAt(hay, needle, at) {
155
+ for (let k = 0; k < needle.length; k++)
156
+ if (hay[at + k] !== needle[k])
157
+ return false;
158
+ return true;
159
+ }
160
+ /**
161
+ * Split an original claude argv (after argv[0]) the way `mnemom agent` built it (buildCliArgs):
162
+ * [--model v] [--effort v] [yolo] [--session-id id] [--plugin-dir dir] <passthrough>
163
+ * [--strict-mcp-config] [--mcp-config <v>]. `passthroughHint` (the tokens after the parent
164
+ * launcher's `--`, read from its own argv) pins the split exactly when it occurs where the
165
+ * layout allows; an empty hint (the parent was read and had no `--`) makes the shape read exact;
166
+ * without a hint (null or absent) the prefix and suffix are read by shape and reported ambiguous.
167
+ */
168
+ export function splitLauncherArgv(argv, passthroughHint) {
169
+ // Prefix: launcher-injected flags in their fixed order.
170
+ let i = 0;
171
+ const prefix = {};
172
+ for (const [flag, arity] of LAUNCHER_PREFIX) {
173
+ if (argv[i] !== flag)
174
+ continue;
175
+ if (arity === 0) {
176
+ prefix[flag] = true;
177
+ i += 1;
178
+ continue;
179
+ }
180
+ const v = argv[i + 1];
181
+ if (v === undefined || readToken(v).flag !== null)
182
+ break;
183
+ prefix[flag] = v;
184
+ i += 2;
185
+ }
186
+ // Suffix: [--strict-mcp-config] --mcp-config <v> at the very end.
187
+ let end = argv.length;
188
+ let trim = false;
189
+ let mcpSuffix = false;
190
+ if (end - 2 >= i && argv[end - 2] === "--mcp-config") {
191
+ mcpSuffix = true;
192
+ end -= 2;
193
+ if (end - 1 >= i && argv[end - 1] === "--strict-mcp-config") {
194
+ end -= 1;
195
+ trim = true;
196
+ }
197
+ }
198
+ let passthrough = argv.slice(i, end);
199
+ let exact = Array.isArray(passthroughHint) && passthroughHint.length === 0;
200
+ if (passthroughHint && passthroughHint.length > 0) {
201
+ // The hint wins when it sits inside the shape-read window (prefix and suffix may have been
202
+ // over-read into user tokens that look like launcher ones).
203
+ for (let at = 0; at + passthroughHint.length <= argv.length; at++) {
204
+ if (!isSubsequenceAt(argv, passthroughHint, at))
205
+ continue;
206
+ if (at > i || at + passthroughHint.length < end)
207
+ continue;
208
+ passthrough = [...passthroughHint];
209
+ exact = true;
210
+ break;
211
+ }
212
+ }
213
+ const launcherArgv = [];
214
+ if (typeof prefix["--model"] === "string")
215
+ launcherArgv.push("--model", prefix["--model"]);
216
+ if (typeof prefix["--effort"] === "string")
217
+ launcherArgv.push("--effort", prefix["--effort"]);
218
+ if (trim)
219
+ launcherArgv.push("--trim-mcp");
220
+ const ambiguous = [];
221
+ if (!exact) {
222
+ if (prefix["--session-id"] !== undefined)
223
+ ambiguous.push("--session-id");
224
+ if (prefix["--plugin-dir"] !== undefined)
225
+ ambiguous.push("--plugin-dir");
226
+ if (mcpSuffix)
227
+ ambiguous.push("--mcp-config");
228
+ }
229
+ return {
230
+ launcherArgv,
231
+ passthrough,
232
+ yoloInjected: prefix[YOLO_FLAG] === true,
233
+ sessionId: typeof prefix["--session-id"] === "string" ? prefix["--session-id"] : null,
234
+ exact,
235
+ ambiguous,
236
+ };
237
+ }
238
+ /** The running-source builder input for an original claude's exact argv and its checked cwd. */
239
+ export function runningSourceInput(originalArgv, cwd, opts = {}) {
240
+ const split = splitLauncherArgv(originalArgv, opts.passthroughHint);
241
+ return {
242
+ source: "running",
243
+ argv: [...split.launcherArgv, ...split.passthrough],
244
+ // A shape-read split names the launcher-shaped flags it could not attribute (conservative).
245
+ userArgv: [...split.ambiguous, ...split.passthrough],
246
+ yoloInjected: split.yoloInjected,
247
+ // Names are deduplicated, so a --remote-control the user also passed is named once.
248
+ remoteControl: opts.remoteControl === true,
249
+ cwd,
250
+ };
251
+ }
252
+ export async function compareWithCommitted(built, committed) {
253
+ const committedHash = typeof committed === "string" ? committed : await specHash(committed);
254
+ return (await specHash(built)) === committedHash
255
+ ? { changed: false }
256
+ : { changed: true, status: 422, code: "durable_unsupported", feature: "launch_spec_change" };
257
+ }
258
+ /**
259
+ * The fix lines printed with a launch_spec_change refusal: the S1 `launch_spec_change` entry
260
+ * under the fix-line rule (the locked row holds the name, so the done clause applies), plus the
261
+ * sole-unlock entry only when this is the agent's sole locked session (C14-R11).
262
+ */
263
+ export function launchSpecChangeFixLines(ctx) {
264
+ const render = {
265
+ launcherKey: ctx.launcherKey,
266
+ nameHeld: true,
267
+ params: { name: ctx.name, flags: ctx.flags },
268
+ };
269
+ const lines = [renderFixLine("launch_spec_change", render)];
270
+ if (ctx.soleLockedSession)
271
+ lines.push(renderFixLine("launch_spec_change_sole_unlock", { ...render, params: { name: ctx.name } }));
272
+ return lines;
273
+ }
274
+ /** The adopt Note for an adopted row (C14-R12, R21): S1 grammar, unchanged builder names. */
275
+ export function adoptNote(dropped) {
276
+ return renderAdoptNote(dropped);
277
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Exact process facts for the running-session builder source (S1 BUILDER_INPUT_TABLE `running`:
3
+ * "the original's argv, read exactly (KERN_PROCARGS2 or the shim log, never ps)").
4
+ *
5
+ * - argv and environment: macOS KERN_PROCARGS2 (sysctl through perl's syscall, no native addon),
6
+ * Linux /proc/<pid>/cmdline and /proc/<pid>/environ. Never ps, which loses argv boundaries.
7
+ * - cwd: lsof (macOS) or /proc/<pid>/cwd (Linux).
8
+ * - the process table (pid, ppid, state) for discovery and process trees: ps, which is exact for
9
+ * these columns; argv is never taken from it.
10
+ *
11
+ * parseProcArgs2 is pure; the rest is the thin driver.
12
+ */
13
+ export interface ProcArgs {
14
+ /** argv[0..argc-1] exactly as exec'd. */
15
+ argv: string[];
16
+ /** The environment as exec'd, `KEY=value` entries. */
17
+ env: string[];
18
+ }
19
+ /**
20
+ * Parse a KERN_PROCARGS2 buffer: int32 argc, the exec path, NUL padding, then argc NUL-terminated
21
+ * argv strings, then NUL-terminated environment strings up to an empty one. Null when malformed.
22
+ */
23
+ export declare function parseProcArgs2(buf: Buffer): ProcArgs | null;
24
+ /** Split a NUL-separated /proc file (cmdline, environ). */
25
+ export declare function splitNul(buf: Buffer): string[];
26
+ /** The perl one-shot that copies a pid's KERN_PROCARGS2 to stdout (CTL_KERN=1, KERN_PROCARGS2=49, KERN_ARGMAX=8; SYS_sysctl=202). */
27
+ export declare const PROCARGS2_PERL: string;
28
+ /**
29
+ * The same sysctl over many pids in one perl process: per pid a header line "<pid> <n>\n" then n
30
+ * raw bytes (n = 0 when the pid cannot be read).
31
+ */
32
+ export declare const PROCARGS2_MANY_PERL: string;
33
+ /** Parse PROCARGS2_MANY_PERL output into pid -> parsed args (unreadable pids are left out). */
34
+ export declare function parseManyProcArgs2(buf: Buffer): Map<number, ProcArgs>;
35
+ export interface ProcDeps {
36
+ platform: NodeJS.Platform;
37
+ run(cmd: string, args: string[]): {
38
+ status: number | null;
39
+ stdout: Buffer;
40
+ };
41
+ readFile(p: string): Buffer;
42
+ readlink(p: string): string;
43
+ }
44
+ export declare const liveProcDeps: ProcDeps;
45
+ /** A pid's exact argv and environment, or null when it cannot be read (gone, or not ours). */
46
+ export declare function readProcArgs(pid: number, deps?: ProcDeps): ProcArgs | null;
47
+ /**
48
+ * What a pid named when it was read: its start time as the OS reports it, and argv[0]. A pid can be
49
+ * reused once its process exits; the start time cannot, so equal identities are the same process.
50
+ */
51
+ export interface ProcIdentity {
52
+ start: string;
53
+ argv0: string;
54
+ }
55
+ /**
56
+ * A pid's start time, or null when it is gone or unreadable. Linux: /proc/<pid>/stat field 22
57
+ * (starttime, clock ticks since boot); elsewhere `ps -o lstart= -p <pid>`.
58
+ */
59
+ export declare function readProcStart(pid: number, deps?: ProcDeps): string | null;
60
+ /** A pid's identity (start time and argv[0]), or null when either cannot be read. */
61
+ export declare function readProcIdentity(pid: number, deps?: ProcDeps): ProcIdentity | null;
62
+ /** Many pids' exact argv and environment in one pass; unreadable pids are left out. */
63
+ export declare function readManyProcArgs(pids: readonly number[], deps?: ProcDeps): Map<number, ProcArgs>;
64
+ /** A pid's working directory, or null. */
65
+ export declare function readProcCwd(pid: number, deps?: ProcDeps): string | null;
66
+ export interface ProcRow {
67
+ pid: number;
68
+ ppid: number;
69
+ uid: number;
70
+ /** ps STAT, e.g. "S+", "T" (stopped). */
71
+ stat: string;
72
+ /** The start time as readProcStart reads it, when the table was read with starts. */
73
+ start?: string;
74
+ }
75
+ /** Parse `ps -axo pid=,ppid=,uid=,stat=[,lstart=]` output (a trailing lstart becomes start). */
76
+ export declare function parsePsTable(text: string): ProcRow[];
77
+ /** The process table (pid, ppid, state). Empty when ps fails. */
78
+ export declare function processTable(deps?: ProcDeps): ProcRow[];
79
+ /**
80
+ * The process table with each row's start time, in the form readProcStart reads it, so a pid
81
+ * from this snapshot can be re-checked before it is signalled. Darwin: ps lstart in the same pass.
82
+ * Linux: /proc/<pid>/stat per row. A row whose start cannot be read has none.
83
+ */
84
+ export declare function processTableWithStarts(deps?: ProcDeps): ProcRow[];
85
+ /** `root` and every descendant, root first, minus `exclude` and the excluded pids' own subtrees. */
86
+ export declare function processTree(root: number, table: readonly ProcRow[], exclude?: ReadonlySet<number>): number[];
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Exact process facts for the running-session builder source (S1 BUILDER_INPUT_TABLE `running`:
3
+ * "the original's argv, read exactly (KERN_PROCARGS2 or the shim log, never ps)").
4
+ *
5
+ * - argv and environment: macOS KERN_PROCARGS2 (sysctl through perl's syscall, no native addon),
6
+ * Linux /proc/<pid>/cmdline and /proc/<pid>/environ. Never ps, which loses argv boundaries.
7
+ * - cwd: lsof (macOS) or /proc/<pid>/cwd (Linux).
8
+ * - the process table (pid, ppid, state) for discovery and process trees: ps, which is exact for
9
+ * these columns; argv is never taken from it.
10
+ *
11
+ * parseProcArgs2 is pure; the rest is the thin driver.
12
+ */
13
+ import { spawnSync } from "node:child_process";
14
+ import { readFileSync, readlinkSync } from "node:fs";
15
+ /**
16
+ * Parse a KERN_PROCARGS2 buffer: int32 argc, the exec path, NUL padding, then argc NUL-terminated
17
+ * argv strings, then NUL-terminated environment strings up to an empty one. Null when malformed.
18
+ */
19
+ export function parseProcArgs2(buf) {
20
+ if (buf.length < 4)
21
+ return null;
22
+ const argc = buf.readInt32LE(0);
23
+ if (argc < 0 || argc > 65536)
24
+ return null;
25
+ let i = 4;
26
+ const end = buf.length;
27
+ while (i < end && buf[i] !== 0)
28
+ i++; // exec path
29
+ while (i < end && buf[i] === 0)
30
+ i++; // padding
31
+ const strings = [];
32
+ while (i < end) {
33
+ let j = i;
34
+ while (j < end && buf[j] !== 0)
35
+ j++;
36
+ if (j >= end)
37
+ break; // an unterminated tail
38
+ const s = buf.subarray(i, j).toString("utf8");
39
+ if (strings.length >= argc && s === "")
40
+ break;
41
+ strings.push(s);
42
+ i = j + 1;
43
+ }
44
+ if (strings.length < argc)
45
+ return null;
46
+ return { argv: strings.slice(0, argc), env: strings.slice(argc) };
47
+ }
48
+ /** Split a NUL-separated /proc file (cmdline, environ). */
49
+ export function splitNul(buf) {
50
+ const s = buf.toString("utf8");
51
+ const parts = s.split("\0");
52
+ if (parts.length > 0 && parts[parts.length - 1] === "")
53
+ parts.pop();
54
+ return parts;
55
+ }
56
+ /** The perl one-shot that copies a pid's KERN_PROCARGS2 to stdout (CTL_KERN=1, KERN_PROCARGS2=49, KERN_ARGMAX=8; SYS_sysctl=202). */
57
+ export const PROCARGS2_PERL = [
58
+ "use strict;",
59
+ "my $pid = int($ARGV[0]);",
60
+ 'my $mib = pack("i3", 1, 49, $pid);',
61
+ // The buffer must be exactly kern.argmax (KERN_ARGMAX=8): a larger one fails with EINVAL.
62
+ 'my $ammib = pack("i2", 1, 8); my $am = "\\0" x 4; my $amsz = pack("Q", 4);',
63
+ "exit 4 if syscall(202, $ammib, 2, $am, $amsz, 0, 0) != 0;",
64
+ 'my $buf = "\\0" x unpack("i", $am);',
65
+ 'my $size = pack("Q", length($buf));',
66
+ "my $r = syscall(202, $mib, 3, $buf, $size, 0, 0);",
67
+ "exit 3 if $r != 0;",
68
+ "binmode STDOUT;",
69
+ 'print substr($buf, 0, unpack("Q", $size));',
70
+ ].join("\n");
71
+ /**
72
+ * The same sysctl over many pids in one perl process: per pid a header line "<pid> <n>\n" then n
73
+ * raw bytes (n = 0 when the pid cannot be read).
74
+ */
75
+ export const PROCARGS2_MANY_PERL = [
76
+ "use strict;",
77
+ 'my $ammib = pack("i2", 1, 8); my $am = "\\0" x 4; my $amsz = pack("Q", 4);',
78
+ "exit 4 if syscall(202, $ammib, 2, $am, $amsz, 0, 0) != 0;",
79
+ 'my $max = unpack("i", $am);',
80
+ "binmode STDOUT;",
81
+ "for my $a (@ARGV) {",
82
+ " my $pid = int($a);",
83
+ ' my $mib = pack("i3", 1, 49, $pid);',
84
+ ' my $buf = "\\0" x $max;',
85
+ ' my $size = pack("Q", $max);',
86
+ ' my $out = "";',
87
+ ' $out = substr($buf, 0, unpack("Q", $size)) if syscall(202, $mib, 3, $buf, $size, 0, 0) == 0;',
88
+ ' print "$pid " . length($out) . "\\n" . $out;',
89
+ "}",
90
+ ].join("\n");
91
+ /** Parse PROCARGS2_MANY_PERL output into pid -> parsed args (unreadable pids are left out). */
92
+ export function parseManyProcArgs2(buf) {
93
+ const out = new Map();
94
+ let i = 0;
95
+ while (i < buf.length) {
96
+ const nl = buf.indexOf(10, i);
97
+ if (nl < 0)
98
+ break;
99
+ const m = /^(\d+) (\d+)$/.exec(buf.subarray(i, nl).toString("latin1"));
100
+ if (!m)
101
+ break;
102
+ const n = Number(m[2]);
103
+ const body = buf.subarray(nl + 1, nl + 1 + n);
104
+ if (body.length < n)
105
+ break;
106
+ const parsed = n > 0 ? parseProcArgs2(body) : null;
107
+ if (parsed)
108
+ out.set(Number(m[1]), parsed);
109
+ i = nl + 1 + n;
110
+ }
111
+ return out;
112
+ }
113
+ export const liveProcDeps = {
114
+ platform: process.platform,
115
+ run(cmd, args) {
116
+ const r = spawnSync(cmd, args, { timeout: 10_000, maxBuffer: 16 * 1024 * 1024 });
117
+ return { status: r.status, stdout: r.stdout ?? Buffer.alloc(0) };
118
+ },
119
+ readFile: (p) => readFileSync(p),
120
+ readlink: (p) => readlinkSync(p),
121
+ };
122
+ /** A pid's exact argv and environment, or null when it cannot be read (gone, or not ours). */
123
+ export function readProcArgs(pid, deps = liveProcDeps) {
124
+ if (!Number.isSafeInteger(pid) || pid <= 0)
125
+ return null;
126
+ try {
127
+ if (deps.platform === "linux") {
128
+ return {
129
+ argv: splitNul(deps.readFile(`/proc/${pid}/cmdline`)),
130
+ env: splitNul(deps.readFile(`/proc/${pid}/environ`)),
131
+ };
132
+ }
133
+ const r = deps.run("perl", ["-e", PROCARGS2_PERL, String(pid)]);
134
+ if (r.status !== 0)
135
+ return null;
136
+ return parseProcArgs2(r.stdout);
137
+ }
138
+ catch {
139
+ return null;
140
+ }
141
+ }
142
+ /**
143
+ * A pid's start time, or null when it is gone or unreadable. Linux: /proc/<pid>/stat field 22
144
+ * (starttime, clock ticks since boot); elsewhere `ps -o lstart= -p <pid>`.
145
+ */
146
+ export function readProcStart(pid, deps = liveProcDeps) {
147
+ if (!Number.isSafeInteger(pid) || pid <= 0)
148
+ return null;
149
+ try {
150
+ if (deps.platform === "linux") {
151
+ const stat = deps.readFile(`/proc/${pid}/stat`).toString("utf8");
152
+ // comm (field 2) is parenthesised and may hold spaces: field 3 on follow the last ")".
153
+ const close = stat.lastIndexOf(")");
154
+ if (close < 0)
155
+ return null;
156
+ const fields = stat.slice(close + 2).split(" ");
157
+ return fields[22 - 3] || null;
158
+ }
159
+ const r = deps.run("ps", ["-o", "lstart=", "-p", String(pid)]);
160
+ const out = r.stdout.toString("utf8").trim();
161
+ return r.status === 0 && out !== "" ? out : null;
162
+ }
163
+ catch {
164
+ return null;
165
+ }
166
+ }
167
+ /** A pid's identity (start time and argv[0]), or null when either cannot be read. */
168
+ export function readProcIdentity(pid, deps = liveProcDeps) {
169
+ const start = readProcStart(pid, deps);
170
+ if (start === null)
171
+ return null;
172
+ const argv0 = readProcArgs(pid, deps)?.argv[0];
173
+ return argv0 === undefined ? null : { start, argv0 };
174
+ }
175
+ /** Many pids' exact argv and environment in one pass; unreadable pids are left out. */
176
+ export function readManyProcArgs(pids, deps = liveProcDeps) {
177
+ const valid = pids.filter((p) => Number.isSafeInteger(p) && p > 0);
178
+ if (valid.length === 0)
179
+ return new Map();
180
+ if (deps.platform === "linux") {
181
+ const out = new Map();
182
+ for (const p of valid) {
183
+ const a = readProcArgs(p, deps);
184
+ if (a)
185
+ out.set(p, a);
186
+ }
187
+ return out;
188
+ }
189
+ try {
190
+ const r = deps.run("perl", ["-e", PROCARGS2_MANY_PERL, ...valid.map(String)]);
191
+ return r.status === 0 ? parseManyProcArgs2(r.stdout) : new Map();
192
+ }
193
+ catch {
194
+ return new Map();
195
+ }
196
+ }
197
+ /** A pid's working directory, or null. */
198
+ export function readProcCwd(pid, deps = liveProcDeps) {
199
+ if (!Number.isSafeInteger(pid) || pid <= 0)
200
+ return null;
201
+ try {
202
+ if (deps.platform === "linux")
203
+ return deps.readlink(`/proc/${pid}/cwd`);
204
+ const r = deps.run("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"]);
205
+ if (r.status !== 0)
206
+ return null;
207
+ const line = r.stdout
208
+ .toString("utf8")
209
+ .split("\n")
210
+ .find((l) => l.startsWith("n"));
211
+ return line ? line.slice(1) : null;
212
+ }
213
+ catch {
214
+ return null;
215
+ }
216
+ }
217
+ /** Parse `ps -axo pid=,ppid=,uid=,stat=[,lstart=]` output (a trailing lstart becomes start). */
218
+ export function parsePsTable(text) {
219
+ const out = [];
220
+ for (const line of text.split("\n")) {
221
+ const m = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)(?:\s+(\S.*?))?\s*$/.exec(line);
222
+ if (!m)
223
+ continue;
224
+ const row = {
225
+ pid: Number(m[1]),
226
+ ppid: Number(m[2]),
227
+ uid: Number(m[3]),
228
+ stat: m[4],
229
+ };
230
+ if (m[5] !== undefined)
231
+ row.start = m[5];
232
+ out.push(row);
233
+ }
234
+ return out;
235
+ }
236
+ /** The process table (pid, ppid, state). Empty when ps fails. */
237
+ export function processTable(deps = liveProcDeps) {
238
+ const r = deps.run("ps", ["-axo", "pid=,ppid=,uid=,stat="]);
239
+ return r.status === 0 ? parsePsTable(r.stdout.toString("utf8")) : [];
240
+ }
241
+ /**
242
+ * The process table with each row's start time, in the form readProcStart reads it, so a pid
243
+ * from this snapshot can be re-checked before it is signalled. Darwin: ps lstart in the same pass.
244
+ * Linux: /proc/<pid>/stat per row. A row whose start cannot be read has none.
245
+ */
246
+ export function processTableWithStarts(deps = liveProcDeps) {
247
+ if (deps.platform !== "linux") {
248
+ const r = deps.run("ps", ["-axo", "pid=,ppid=,uid=,stat=,lstart="]);
249
+ return r.status === 0 ? parsePsTable(r.stdout.toString("utf8")) : [];
250
+ }
251
+ return processTable(deps).map((row) => {
252
+ const start = readProcStart(row.pid, deps);
253
+ return start === null ? row : { ...row, start };
254
+ });
255
+ }
256
+ /** `root` and every descendant, root first, minus `exclude` and the excluded pids' own subtrees. */
257
+ export function processTree(root, table, exclude = new Set()) {
258
+ if (exclude.has(root))
259
+ return [];
260
+ const children = new Map();
261
+ for (const r of table) {
262
+ const list = children.get(r.ppid) ?? [];
263
+ list.push(r.pid);
264
+ children.set(r.ppid, list);
265
+ }
266
+ const out = [];
267
+ const queue = [root];
268
+ const seen = new Set();
269
+ while (queue.length > 0) {
270
+ const p = queue.shift();
271
+ if (seen.has(p) || exclude.has(p))
272
+ continue;
273
+ seen.add(p);
274
+ out.push(p);
275
+ for (const c of children.get(p) ?? [])
276
+ queue.push(c);
277
+ }
278
+ return out;
279
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import "./lib/retired-env-guard.js";
3
- import { program } from "commander";
3
+ import { program, type Command } from "commander";
4
+ /**
5
+ * The durable-gated commands (BACKBONE Flags/CLI: off lists no durable verb). The committed
6
+ * command-tree.json leaves them out whatever the generating machine's experimental state
7
+ * (scripts/gen-command-tree.mjs); a piece that registers another durable verb adds it here.
8
+ */
9
+ export declare const DURABLE_GATED_COMMANDS: ReadonlySet<Command>;
4
10
  export { program };
5
11
  /**
6
12
  * The entrypoint dispatch. `mnemom agent slack …` owns its own flags (--thread,