@ddtcorex/dsh-maestro-supervisor 0.7.0 → 0.7.1

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,15 @@
1
+ /**
2
+ * Durable self-restart intent sidecar written by `dsh_web_restart`
3
+ * (`~/.dsh/.supervisor/intents/<sessionId>.json`, mode 600). Consumed by
4
+ * auto-resume so a session that requested the restart is resumed with a
5
+ * contextual message instead of the generic "outcome unknown" recovery text.
6
+ */
7
+ export interface RestartIntent {
8
+ ts: number;
9
+ sessionId?: string;
10
+ reason?: string;
11
+ }
12
+ export declare function intentsDir(): string;
13
+ export declare function intentPath(sessionId: string): string;
14
+ export declare function readIntent(sessionId: string): RestartIntent | undefined;
15
+ export declare function consumeIntent(sessionId: string): void;
package/lib/intents.js ADDED
@@ -0,0 +1,27 @@
1
+ import { existsSync, readFileSync, unlinkSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ export function intentsDir() {
5
+ return join(homedir(), '.dsh', '.supervisor', 'intents');
6
+ }
7
+ export function intentPath(sessionId) {
8
+ const safe = sessionId.replace(/[^A-Za-z0-9._-]/g, '_');
9
+ return join(intentsDir(), `${safe}.json`);
10
+ }
11
+ export function readIntent(sessionId) {
12
+ try {
13
+ const p = intentPath(sessionId);
14
+ if (!existsSync(p))
15
+ return undefined;
16
+ return JSON.parse(readFileSync(p, 'utf8'));
17
+ }
18
+ catch {
19
+ return undefined;
20
+ }
21
+ }
22
+ export function consumeIntent(sessionId) {
23
+ try {
24
+ unlinkSync(intentPath(sessionId));
25
+ }
26
+ catch { }
27
+ }
package/lib/plugin.d.ts CHANGED
@@ -6,7 +6,8 @@
6
6
  * and web restart; this plugin handles the in-process resume.
7
7
  */
8
8
  import { findInterrupted as defaultFindInterrupted, findDanglingOpenTurns as defaultFindDanglingOpenTurns } from './resume.js';
9
- export declare const inject: readonly ["sessions", "agents", "connection", "skills"];
9
+ import type { RestartIntent } from './intents.js';
10
+ export declare const inject: readonly ["sessions", "agents", "connection", "tools", "skills"];
10
11
  export interface SupervisorPluginConfig {
11
12
  autoResumeWithin?: number | string;
12
13
  autoResumeEnabled?: boolean;
@@ -17,7 +18,10 @@ export declare function runAutoResume(ctx: any, opts?: {
17
18
  resumeInterrupted?: typeof resumeInterrupted;
18
19
  config?: SupervisorPluginConfig;
19
20
  }): Promise<void>;
20
- export declare function resumeInterrupted(ctx: any, ids: string[]): Promise<string[]>;
21
+ export declare function resumeInterrupted(ctx: any, ids: string[], deps?: {
22
+ readIntent?: (id: string) => RestartIntent | undefined;
23
+ consumeIntent?: (id: string) => void;
24
+ }): Promise<string[]>;
21
25
  export declare function createResumeRpcHandler(ctx: any, opts?: {
22
26
  resumeInterrupted?: typeof resumeInterrupted;
23
27
  config?: SupervisorPluginConfig;
package/lib/plugin.js CHANGED
@@ -10,11 +10,12 @@ import * as path from 'node:path';
10
10
  import * as os from 'node:os';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import { findInterrupted as defaultFindInterrupted, findDanglingOpenTurns as defaultFindDanglingOpenTurns } from './resume.js';
13
+ import { readIntent, consumeIntent } from './intents.js';
13
14
  import { makeSkillProvider } from './skill-provider.js';
14
15
  import { registerRestartTool } from './restart-tool.js';
15
16
  import { makePreExecuteGuard } from './self-kill-guard.js';
16
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
- export const inject = ['sessions', 'agents', 'connection', 'skills'];
18
+ export const inject = ['sessions', 'agents', 'connection', 'tools', 'skills'];
18
19
  function parseDuration(s) {
19
20
  if (!s)
20
21
  return undefined;
@@ -171,7 +172,9 @@ export async function runAutoResume(ctx, opts = {}) {
171
172
  catch { }
172
173
  }
173
174
  }
174
- export async function resumeInterrupted(ctx, ids) {
175
+ export async function resumeInterrupted(ctx, ids, deps = {}) {
176
+ const doReadIntent = deps.readIntent ?? readIntent;
177
+ const doConsumeIntent = deps.consumeIntent ?? consumeIntent;
175
178
  const resumed = [];
176
179
  for (const id of ids) {
177
180
  try {
@@ -246,15 +249,31 @@ export async function resumeInterrupted(ctx, ids) {
246
249
  // model to verify external state before retrying. A bare "continue" made
247
250
  // the model reply with text instead of re-issuing bash, leaving the
248
251
  // session stuck after every crash (36646045..., 31ae53a2...).
249
- const resumeMessage = 'The previous turn was interrupted by a crash and the harness has synthesized a tool result with TOOL_OUTCOME_UNKNOWN / TOOL_NOT_STARTED. ' +
252
+ const idleMessage = 'The previous turn was interrupted by a crash and the harness has synthesized a tool result with TOOL_OUTCOME_UNKNOWN / TOOL_NOT_STARTED. ' +
250
253
  'Outcome of the last tool call is unknown — it may or may not have had side effects. ' +
251
254
  'Verify external state with bash (e.g., ls, cat, git status) before retrying. ' +
252
255
  'Retry only if the operation is read-only or idempotent; if it may have side effects, verify first or ask the user. ' +
253
256
  'Then continue the original task from where it was interrupted — re-issue the next bash/tool call that the plan requires.';
257
+ // A session that requested the dsh web restart has a durable intent
258
+ // sidecar (written by dsh_web_restart): resume it with a contextual
259
+ // message instead of the generic "outcome unknown" recovery prompt, then
260
+ // consume the sidecar so it cannot re-trigger on a later resume.
261
+ let resumeMessage = idleMessage;
262
+ try {
263
+ const intent = doReadIntent(sessionId);
264
+ if (intent)
265
+ resumeMessage = `You requested a dsh web restart${intent.reason ? ` (reason: ${intent.reason})` : ''} and it completed. Do NOT call dsh_web_restart again. Verify current state if needed, then continue the original task.`;
266
+ }
267
+ catch { }
254
268
  agent.followup(createUserMessage({
255
269
  content: [{ type: 'text', text: resumeMessage }],
256
270
  source: { kind: 'user' },
257
271
  }));
272
+ try {
273
+ if (resumeMessage !== idleMessage)
274
+ doConsumeIntent(sessionId);
275
+ }
276
+ catch { }
258
277
  resumed.push(id);
259
278
  ctx.logger?.info?.(`[supervisor] auto-resume: sent recovery continue for ${id}`);
260
279
  }
@@ -30,16 +30,29 @@ export declare function dryBootVerify(harnessRoot: string, opts?: {
30
30
  ok: boolean;
31
31
  detail: string;
32
32
  }>;
33
+ /**
34
+ * Classify a failed dry-boot's log tail into a precise one-line detail. The
35
+ * most common operator-actionable failure is an EADDRINUSE — the candidate
36
+ * collided with the live dsh web tree on :3000/:3080 or with another process
37
+ * on the ephemeral 9000-9999 port — so name the colliding port instead of
38
+ * reporting a generic boot failure. Plugin-tree load errors keep their stable
39
+ * codes (the caller's refused message reads `dry-boot failed — restart
40
+ * refused. <detail>`).
41
+ */
42
+ export declare function dryBootFailureDetail(tail: string, exitCode: number): string;
33
43
  /** Minimal file metadata the drift check reads; injectable for deterministic tests. */
34
44
  export interface FileStat {
35
45
  mtimeMs: number;
36
46
  }
37
47
  /**
38
- * Whether the live plugin tree differs from the latest LKG snapshot. Two
48
+ * Whether the live plugin tree differs from the latest LKG snapshot. Three
39
49
  * signals are combined:
40
50
  *
41
51
  * 1. manifest drift — the live profile's web `package.json` text vs baseline;
42
- * 2. plugin-lib drift — any `@ddtcorex` plugin `lib/` file newer than the
52
+ * 2. cordis patch drift — the live profile's web `cordis.patch.yml` text vs
53
+ * baseline (a patch-only config edit changes the boot-time row wiring
54
+ * without touching the manifest — the manifest check alone misses it);
55
+ * 3. plugin-lib drift — any `@ddtcorex` plugin `lib/` file newer than the
43
56
  * snapshot moment. Link-installed plugins resolve to the same workspace
44
57
  * files in both live and LKG, so the stored copies cannot be compared
45
58
  * byte-wise; the snapshot itself is the meaningful baseline and a rebuilt
@@ -66,7 +66,9 @@ export async function dryBootVerify(harnessRoot, opts = {}) {
66
66
  }
67
67
  const tail = logs.join('').slice(-3000);
68
68
  const loadErr = /ERR_MODULE_NOT_FOUND|assertChannel|must declare output|failed to apply loader entry/.exec(tail);
69
- return { ok: code === 0 && !loadErr, detail: loadErr ? loadErr[0] : (code === 0 ? 'dry-boot ok' : `dry-boot failed (exit ${code})`) };
69
+ if (code === 0 && !loadErr)
70
+ return { ok: true, detail: 'dry-boot ok' };
71
+ return { ok: false, detail: dryBootFailureDetail(tail, code) };
70
72
  }
71
73
  catch (e) {
72
74
  return { ok: false, detail: `dry-boot error: ${e?.message ?? String(e)}` };
@@ -87,11 +89,34 @@ export async function dryBootVerify(harnessRoot, opts = {}) {
87
89
  }
88
90
  }
89
91
  /**
90
- * Whether the live plugin tree differs from the latest LKG snapshot. Two
92
+ * Classify a failed dry-boot's log tail into a precise one-line detail. The
93
+ * most common operator-actionable failure is an EADDRINUSE — the candidate
94
+ * collided with the live dsh web tree on :3000/:3080 or with another process
95
+ * on the ephemeral 9000-9999 port — so name the colliding port instead of
96
+ * reporting a generic boot failure. Plugin-tree load errors keep their stable
97
+ * codes (the caller's refused message reads `dry-boot failed — restart
98
+ * refused. <detail>`).
99
+ */
100
+ export function dryBootFailureDetail(tail, exitCode) {
101
+ const addrInUse = /EADDRINUSE[^]*?:(\d+)/.exec(tail);
102
+ if (addrInUse) {
103
+ const port = addrInUse[1];
104
+ return `dry-boot failed: port ${port} already in use (EADDRINUSE) — the live dsh web tree or another process holds it`;
105
+ }
106
+ const loadErr = /ERR_MODULE_NOT_FOUND|assertChannel|must declare output|failed to apply loader entry/.exec(tail);
107
+ if (loadErr)
108
+ return `dry-boot failed: ${loadErr[0]}`;
109
+ return `dry-boot failed (exit ${exitCode})`;
110
+ }
111
+ /**
112
+ * Whether the live plugin tree differs from the latest LKG snapshot. Three
91
113
  * signals are combined:
92
114
  *
93
115
  * 1. manifest drift — the live profile's web `package.json` text vs baseline;
94
- * 2. plugin-lib drift — any `@ddtcorex` plugin `lib/` file newer than the
116
+ * 2. cordis patch drift — the live profile's web `cordis.patch.yml` text vs
117
+ * baseline (a patch-only config edit changes the boot-time row wiring
118
+ * without touching the manifest — the manifest check alone misses it);
119
+ * 3. plugin-lib drift — any `@ddtcorex` plugin `lib/` file newer than the
95
120
  * snapshot moment. Link-installed plugins resolve to the same workspace
96
121
  * files in both live and LKG, so the stored copies cannot be compared
97
122
  * byte-wise; the snapshot itself is the meaningful baseline and a rebuilt
@@ -122,6 +147,17 @@ export function isPluginTreeChanged(harnessRoot, lkgDir = join(homedir(), '.dsh/
122
147
  return true;
123
148
  if (readFileSync(liveManifest, 'utf8') !== readFileSync(lkgManifest, 'utf8'))
124
149
  return true;
150
+ // cordis.patch.yml — compare only when at least one side has it (profiles
151
+ // without a patch are the baseline; a patch appearing on either side alone
152
+ // is drift). The text compare keeps the check cheap and hermetic.
153
+ const lkgPatch = join(lkgHome, 'cordis.patch.yml');
154
+ const livePatch = join(live, 'cordis.patch.yml');
155
+ if (existsSync(lkgPatch) || existsSync(livePatch)) {
156
+ if (!existsSync(lkgPatch) || !existsSync(livePatch))
157
+ return true;
158
+ if (readFileSync(livePatch, 'utf8') !== readFileSync(lkgPatch, 'utf8'))
159
+ return true;
160
+ }
125
161
  const snapshotManifest = join(lkgDir, latest, 'manifest.json');
126
162
  const baseline = existsSync(snapshotManifest)
127
163
  ? statFile(snapshotManifest).mtimeMs
@@ -5,21 +5,69 @@
5
5
  * by killing the host.
6
6
  */
7
7
  /**
8
- * Whether a shell command is a self-kill. `livePids` are the pids currently
9
- * holding listening sockets; a `kill <pid>` whose pid is one of ours is a
10
- * self-kill regardless of anything else in the command. The kill parser accepts
11
- * flag forms (`kill -9 <pid>`, `kill -TERM <pid>`). A command that is
12
- * essentially JUST a `kill <unrelated-pid>` is allowed (idempotent cleanups
13
- * are common), as are kill attempts whose output reports "not found"/"done" —
14
- * but any compound that chains a restart/kill after it (or before the end)
15
- * stays denied.
8
+ * The dsh web MainThread owns both ports (3000 = gitlab-webhook, 3080 = web).
9
+ * Only listeners on these ports can be dsh web; every other listening process
10
+ * on the host (mysql, sshd, nginx, redis, ...) is explicitly NOT protected.
11
+ */
12
+ export declare const DSH_WEB_PORTS: number[];
13
+ export type TreeBoundaryKind = 'none' | 'launcher' | 'service-manager';
14
+ /**
15
+ * Boundary classification for the ancestor walk — mirrors
16
+ * `skills/dsh-safe-restart/scripts/restart-dsh-web.sh`'s `resolve_tree()`:
17
+ * the walk stops at `pnpm` (the launcher — everything above it is the
18
+ * launching shell, not dsh web) and never walks into a `systemd --user`
19
+ * manager (it owns every user unit on the box). `launcher` pids stay in the
20
+ * forest; `service-manager` pids are never included.
21
+ */
22
+ export declare function treeBoundaryKind(commandLine: string): TreeBoundaryKind;
23
+ /** Convenience boolean form of {@link treeBoundaryKind}. */
24
+ export declare function isTreeBoundary(commandLine: string): boolean;
25
+ export interface ProcessRow {
26
+ pid: number;
27
+ ppid: number;
28
+ }
29
+ /**
30
+ * Resolve the pids that belong to the dsh web process forest. `listeners` are
31
+ * the pids owning the dsh-web ports (already narrowed by the caller). A pid is
32
+ * protected iff its upward ancestor chain reaches the forest before pid 1:
33
+ *
34
+ * - the forest roots are the listeners plus every ancestor up to the
35
+ * `launcher` boundary (pnpm stays inside the forest; systemd --user and
36
+ * the launching shell stay out);
37
+ * - the descendant closure then protects the whole owned subtree — the dsh
38
+ * web node processes AND their bash-tool/browser children — while never
39
+ * climbing into unrelated ancestors.
40
+ *
41
+ * `boundary(pid)` classifies the command line of a walked pid; it is only
42
+ * invoked for the handful of listener + ancestor pids, never for the full
43
+ * table.
44
+ */
45
+ export declare function resolveDshWebTreePids(listeners: number[], rows: ProcessRow[], boundary?: (pid: number) => TreeBoundaryKind): number[];
46
+ /**
47
+ * Whether a shell command is a self-kill. `livePids` are the pids of the dsh
48
+ * web process forest; a `kill <pid>` whose pid is one of ours is a self-kill
49
+ * regardless of anything else in the command. The kill parser accepts flag
50
+ * forms (`kill -9 <pid>`, `kill -TERM <pid>`). A command that is essentially
51
+ * JUST a `kill <unrelated-pid>` is allowed (idempotent cleanups are common),
52
+ * as are kill attempts whose output reports "not found"/"done" — but any
53
+ * compound that chains a restart/kill after it (or before the end) stays
54
+ * denied.
16
55
  */
17
56
  export declare function isSelfKillCommand(cmd: string, livePids: number[]): boolean;
57
+ /**
58
+ * Live pids of the dsh web OWN forest: pids owning the dsh-web ports
59
+ * (3000/3080) plus their ancestor chain up to the pnpm/systemd boundary and
60
+ * the owned subtree. A kill of an unrelated listener (mysql/sshd/nginx) is
61
+ * therefore allowed — before this scoping the guard denied `kill <pid>` of ANY
62
+ * pid holding a listening socket as a "restart dsh web".
63
+ */
64
+ export declare function dshWebTreeLivePids(): number[];
18
65
  /**
19
66
  * Build a `tools/pre-execute` waterfall listener: deny matching
20
67
  * bash/shell/exec commands, otherwise delegate to `next()`. Live pids default
21
- * to the pids holding listening sockets (`ss -tlnp`) so `kill <pid>` of a
22
- * live host process is caught even when the command names no tool.
68
+ * to the dsh web process forest (see `dshWebTreeLivePids`) so `kill <pid>` of
69
+ * a live host process is caught even when the command names no tool, while a
70
+ * kill of an unrelated service is not.
23
71
  */
24
72
  export declare function makePreExecuteGuard(opts?: {
25
73
  livePids?: () => number[];
@@ -12,14 +12,91 @@ const require = createRequire(import.meta.url);
12
12
  // narrowed below so a kill of an unrelated pid is not denied as a self-kill.
13
13
  const SELF_KILL_RE = /(systemctl\s+--?user\s+.*(restart|stop|start).*dsh-web|pkill\s+.*dsh|killall\s+.*dsh|ss\s+.*3080.*kill|restart-dsh-web|kill\s+)/i;
14
14
  /**
15
- * Whether a shell command is a self-kill. `livePids` are the pids currently
16
- * holding listening sockets; a `kill <pid>` whose pid is one of ours is a
17
- * self-kill regardless of anything else in the command. The kill parser accepts
18
- * flag forms (`kill -9 <pid>`, `kill -TERM <pid>`). A command that is
19
- * essentially JUST a `kill <unrelated-pid>` is allowed (idempotent cleanups
20
- * are common), as are kill attempts whose output reports "not found"/"done" —
21
- * but any compound that chains a restart/kill after it (or before the end)
22
- * stays denied.
15
+ * The dsh web MainThread owns both ports (3000 = gitlab-webhook, 3080 = web).
16
+ * Only listeners on these ports can be dsh web; every other listening process
17
+ * on the host (mysql, sshd, nginx, redis, ...) is explicitly NOT protected.
18
+ */
19
+ export const DSH_WEB_PORTS = [3000, 3080];
20
+ /**
21
+ * Boundary classification for the ancestor walk mirrors
22
+ * `skills/dsh-safe-restart/scripts/restart-dsh-web.sh`'s `resolve_tree()`:
23
+ * the walk stops at `pnpm` (the launcher — everything above it is the
24
+ * launching shell, not dsh web) and never walks into a `systemd --user`
25
+ * manager (it owns every user unit on the box). `launcher` pids stay in the
26
+ * forest; `service-manager` pids are never included.
27
+ */
28
+ export function treeBoundaryKind(commandLine) {
29
+ if (commandLine.includes('pnpm'))
30
+ return 'launcher';
31
+ if (commandLine.includes('systemd --user'))
32
+ return 'service-manager';
33
+ return 'none';
34
+ }
35
+ /** Convenience boolean form of {@link treeBoundaryKind}. */
36
+ export function isTreeBoundary(commandLine) {
37
+ return treeBoundaryKind(commandLine) !== 'none';
38
+ }
39
+ /**
40
+ * Resolve the pids that belong to the dsh web process forest. `listeners` are
41
+ * the pids owning the dsh-web ports (already narrowed by the caller). A pid is
42
+ * protected iff its upward ancestor chain reaches the forest before pid 1:
43
+ *
44
+ * - the forest roots are the listeners plus every ancestor up to the
45
+ * `launcher` boundary (pnpm stays inside the forest; systemd --user and
46
+ * the launching shell stay out);
47
+ * - the descendant closure then protects the whole owned subtree — the dsh
48
+ * web node processes AND their bash-tool/browser children — while never
49
+ * climbing into unrelated ancestors.
50
+ *
51
+ * `boundary(pid)` classifies the command line of a walked pid; it is only
52
+ * invoked for the handful of listener + ancestor pids, never for the full
53
+ * table.
54
+ */
55
+ export function resolveDshWebTreePids(listeners, rows, boundary = () => 'launcher') {
56
+ const byPid = new Map(rows.map(r => [r.pid, r]));
57
+ const roots = new Set();
58
+ for (const pid of listeners) {
59
+ let cur = pid;
60
+ for (let depth = 0; cur && cur !== 1 && depth < 100 && !roots.has(cur); depth++) {
61
+ const row = byPid.get(cur);
62
+ if (!row)
63
+ break;
64
+ const kind = boundary(cur);
65
+ if (kind === 'service-manager')
66
+ break; // never climb into systemd --user
67
+ roots.add(cur);
68
+ if (kind === 'launcher')
69
+ break; // pnpm is the ceiling of the forest
70
+ if (row.ppid === cur || row.ppid <= 0)
71
+ break;
72
+ cur = row.ppid;
73
+ }
74
+ }
75
+ const protectedSet = new Set(roots);
76
+ for (const row of rows) {
77
+ let cur = row.pid;
78
+ for (let depth = 0; cur && cur !== 1 && depth < 100; depth++) {
79
+ if (roots.has(cur)) {
80
+ protectedSet.add(row.pid);
81
+ break;
82
+ }
83
+ const next = byPid.get(cur);
84
+ if (!next || next.ppid === cur || next.ppid <= 0)
85
+ break;
86
+ cur = next.ppid;
87
+ }
88
+ }
89
+ return [...protectedSet].sort((a, b) => a - b);
90
+ }
91
+ /**
92
+ * Whether a shell command is a self-kill. `livePids` are the pids of the dsh
93
+ * web process forest; a `kill <pid>` whose pid is one of ours is a self-kill
94
+ * regardless of anything else in the command. The kill parser accepts flag
95
+ * forms (`kill -9 <pid>`, `kill -TERM <pid>`). A command that is essentially
96
+ * JUST a `kill <unrelated-pid>` is allowed (idempotent cleanups are common),
97
+ * as are kill attempts whose output reports "not found"/"done" — but any
98
+ * compound that chains a restart/kill after it (or before the end) stays
99
+ * denied.
23
100
  */
24
101
  export function isSelfKillCommand(cmd, livePids) {
25
102
  if (/kill\s+(?:-\S+\s+)?(\d+)/i.test(cmd)) {
@@ -34,23 +111,52 @@ export function isSelfKillCommand(cmd, livePids) {
34
111
  && !/^kill\s+(?:-\S+\s+)?\d+\s*$/i.test(cmd.trim())
35
112
  && !/kill\s+(?:-\S+\s+)?(\d+)\s+.*(not found|done)/i.test(cmd);
36
113
  }
114
+ /**
115
+ * Live pids of the dsh web OWN forest: pids owning the dsh-web ports
116
+ * (3000/3080) plus their ancestor chain up to the pnpm/systemd boundary and
117
+ * the owned subtree. A kill of an unrelated listener (mysql/sshd/nginx) is
118
+ * therefore allowed — before this scoping the guard denied `kill <pid>` of ANY
119
+ * pid holding a listening socket as a "restart dsh web".
120
+ */
121
+ export function dshWebTreeLivePids() {
122
+ try {
123
+ const { execSync } = require('node:child_process');
124
+ const filter = DSH_WEB_PORTS.map(p => `sport = :${p}`).join(' or ');
125
+ const out = execSync(`ss -tlnp '( ${filter} )' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | sort -u`, { encoding: 'utf8' });
126
+ const listeners = out.trim().split('\n').filter(Boolean).map(Number);
127
+ if (listeners.length === 0)
128
+ return [];
129
+ const psOut = execSync(`ps -eo pid=,ppid=`, { encoding: 'utf8' });
130
+ const rows = psOut.trim().split('\n')
131
+ .map(line => line.trim().split(/\s+/))
132
+ .filter(p => p.length >= 2 && /^\d+$/.test(p[0]) && /^\d+$/.test(p[1]))
133
+ .map(([pid, ppid]) => ({ pid: Number(pid), ppid: Number(ppid) }));
134
+ // Command lines are only fetched for the walked listener/ancestor pids
135
+ // (a handful of subprocess calls), never for the whole process table.
136
+ const boundary = (pid) => {
137
+ try {
138
+ const args = execSync(`ps -o args= -p ${pid}`, { encoding: 'utf8' });
139
+ return treeBoundaryKind(args);
140
+ }
141
+ catch {
142
+ return 'launcher'; // gone or unreadable → stop walking right here
143
+ }
144
+ };
145
+ return resolveDshWebTreePids(listeners, rows, boundary);
146
+ }
147
+ catch {
148
+ return [];
149
+ }
150
+ }
37
151
  /**
38
152
  * Build a `tools/pre-execute` waterfall listener: deny matching
39
153
  * bash/shell/exec commands, otherwise delegate to `next()`. Live pids default
40
- * to the pids holding listening sockets (`ss -tlnp`) so `kill <pid>` of a
41
- * live host process is caught even when the command names no tool.
154
+ * to the dsh web process forest (see `dshWebTreeLivePids`) so `kill <pid>` of
155
+ * a live host process is caught even when the command names no tool, while a
156
+ * kill of an unrelated service is not.
42
157
  */
43
158
  export function makePreExecuteGuard(opts = {}) {
44
- const livePids = opts.livePids ?? (() => {
45
- try {
46
- const { execSync } = require('node:child_process');
47
- const out = execSync(`ss -tlnp 2>/dev/null | grep -oP 'pid=\\K[0-9]+' | sort -u`, { encoding: 'utf8' });
48
- return out.trim().split('\n').filter(Boolean).map(Number);
49
- }
50
- catch {
51
- return [];
52
- }
53
- });
159
+ const livePids = opts.livePids ?? dshWebTreeLivePids;
54
160
  return async (exec, next) => {
55
161
  // dsh-tools hands the frozen ToolExecution (name + arguments); the guard
56
162
  // also accepts the `args` shape for tests/embedded hosts.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ddtcorex/dsh-maestro-supervisor",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Supervisor daemon for DSH Web resilience — auto-detect crashes, rollback to LKG, report",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",