@camerontaylor/paseo-server 0.13.0-fork.1 → 0.13.0-fork.2

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.
@@ -167,6 +167,14 @@ const ACP_PROBE_CLOSE_TIMEOUT_MS = 2000;
167
167
  // request when the provider never answers, so these calls must be bounded
168
168
  // or the provider process is never terminated.
169
169
  const ACP_CLOSE_REQUEST_TIMEOUT_MS = 2000;
170
+ // An ACP adapter can front a daemon shared by other sessions: every `jcode acp`
171
+ // attaches to one setsid'd `jcode serve`, parented by whichever adapter started
172
+ // it. Terminating that adapter must not take the shared daemon down with it.
173
+ const ACP_PROVIDER_TERMINATION = {
174
+ gracefulTimeoutMs: 2000,
175
+ forceTimeoutMs: 2000,
176
+ spareDetachedOnForce: true,
177
+ };
170
178
  const ACP_IMPORT_HISTORY_LOAD_TIMEOUT_MS = 30000;
171
179
  const ACP_IMPORT_HISTORY_BUDGET_MS = 60000;
172
180
  function summarizeMalformedACPStdoutError(error) {
@@ -1233,10 +1241,7 @@ export class ACPAgentSession {
1233
1241
  async releaseHistoryRuntime() {
1234
1242
  const child = this.child;
1235
1243
  if (child) {
1236
- const result = await this.terminateProcess(child, {
1237
- gracefulTimeoutMs: 2000,
1238
- forceTimeoutMs: 2000,
1239
- });
1244
+ const result = await this.terminateProcess(child, ACP_PROVIDER_TERMINATION);
1240
1245
  if (result === "kill-timeout") {
1241
1246
  // Keep the child registered so close() can retry the shutdown.
1242
1247
  this.logger.warn("ACP history resume could not release the provider process");
@@ -2250,7 +2255,7 @@ export class ACPAgentSession {
2250
2255
  await Promise.all(terminalTerminations);
2251
2256
  this.terminalEntries.clear();
2252
2257
  if (this.child) {
2253
- await this.terminateProcess(this.child, { gracefulTimeoutMs: 2000, forceTimeoutMs: 2000 });
2258
+ await this.terminateProcess(this.child, ACP_PROVIDER_TERMINATION);
2254
2259
  }
2255
2260
  this.subscribers.clear();
2256
2261
  this.connection = null;
@@ -3572,7 +3577,11 @@ function coerceSessionConfigMetadata(metadata) {
3572
3577
  }
3573
3578
  async function terminateChildProcess(child, timeoutMs, terminate) {
3574
3579
  try {
3575
- await terminate(child, { gracefulTimeoutMs: timeoutMs, forceTimeoutMs: timeoutMs });
3580
+ await terminate(child, {
3581
+ ...ACP_PROVIDER_TERMINATION,
3582
+ gracefulTimeoutMs: timeoutMs,
3583
+ forceTimeoutMs: timeoutMs,
3584
+ });
3576
3585
  }
3577
3586
  finally {
3578
3587
  child.stdin.destroy();
@@ -11,9 +11,26 @@ export interface TerminateWithTreeKillOptions {
11
11
  gracefulTimeoutMs: number;
12
12
  forceTimeoutMs?: number;
13
13
  onForceSignal?: () => void;
14
+ spareDetachedOnForce?: boolean;
14
15
  }
15
16
  export type TerminateWithTreeKillResult = "already-exited" | "terminated" | "killed" | "kill-timeout";
16
17
  export type ProcessTerminator = (child: TreeKillTarget, options: TerminateWithTreeKillOptions) => Promise<TerminateWithTreeKillResult>;
17
- export declare function terminateWithTreeKill(child: TreeKillTarget, options: TerminateWithTreeKillOptions): Promise<TerminateWithTreeKillResult>;
18
+ export interface ProcessTableEntry {
19
+ pid: number;
20
+ ppid: number;
21
+ sessionLeader: boolean;
22
+ }
23
+ export type ProcessTableReader = () => Promise<ProcessTableEntry[] | null>;
24
+ export interface TreeKillDependencies {
25
+ readProcessTable: ProcessTableReader;
26
+ signalPid: (pid: number, signal: NodeJS.Signals) => void;
27
+ signalTree: (child: TreeKillTarget, signal: NodeJS.Signals) => Promise<void>;
28
+ }
29
+ export declare function createTreeKillTerminator(dependencies?: Partial<TreeKillDependencies>): ProcessTerminator;
30
+ export declare const terminateWithTreeKill: ProcessTerminator;
18
31
  export declare function signalProcessTree(child: TreeKillTarget, signal: NodeJS.Signals): Promise<void>;
32
+ export declare function selectAttachedProcessTree(table: readonly ProcessTableEntry[], rootPid: number): number[];
33
+ export declare function readProcessTable(): Promise<ProcessTableEntry[] | null>;
34
+ export declare function parseProcStat(stat: string): ProcessTableEntry | null;
35
+ export declare function parsePsProcessTable(output: string): ProcessTableEntry[];
19
36
  //# sourceMappingURL=tree-kill.d.ts.map
@@ -1,22 +1,38 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readdir, readFile } from "node:fs/promises";
1
3
  import treeKill from "tree-kill";
2
- export async function terminateWithTreeKill(child, options) {
3
- if (isProcessExited(child)) {
4
- return "already-exited";
5
- }
6
- const exitPromise = waitForProcessExit(child);
7
- await signalProcessTree(child, options.gracefulSignal ?? "SIGTERM");
8
- if (await waitForExitOrTimeout(exitPromise, options.gracefulTimeoutMs)) {
9
- return "terminated";
10
- }
11
- options.onForceSignal?.();
12
- await signalProcessTree(child, options.forceSignal ?? "SIGKILL");
13
- if (options.forceTimeoutMs === undefined) {
14
- return "killed";
15
- }
16
- return (await waitForExitOrTimeout(exitPromise, options.forceTimeoutMs))
17
- ? "killed"
18
- : "kill-timeout";
4
+ export function createTreeKillTerminator(dependencies = {}) {
5
+ const deps = {
6
+ readProcessTable: dependencies.readProcessTable ?? readProcessTable,
7
+ signalPid: dependencies.signalPid ?? ((pid, signal) => process.kill(pid, signal)),
8
+ signalTree: dependencies.signalTree ?? signalProcessTree,
9
+ };
10
+ return async (child, options) => {
11
+ if (isProcessExited(child)) {
12
+ return "already-exited";
13
+ }
14
+ const exitPromise = waitForProcessExit(child);
15
+ await signalAttachedProcessTree(child, options.gracefulSignal ?? "SIGTERM", deps);
16
+ if (await waitForExitOrTimeout(exitPromise, options.gracefulTimeoutMs)) {
17
+ return "terminated";
18
+ }
19
+ options.onForceSignal?.();
20
+ const forceSignal = options.forceSignal ?? "SIGKILL";
21
+ if (options.spareDetachedOnForce) {
22
+ await signalAttachedProcessTree(child, forceSignal, deps);
23
+ }
24
+ else {
25
+ await deps.signalTree(child, forceSignal);
26
+ }
27
+ if (options.forceTimeoutMs === undefined) {
28
+ return "killed";
29
+ }
30
+ return (await waitForExitOrTimeout(exitPromise, options.forceTimeoutMs))
31
+ ? "killed"
32
+ : "kill-timeout";
33
+ };
19
34
  }
35
+ export const terminateWithTreeKill = createTreeKillTerminator();
20
36
  export function signalProcessTree(child, signal) {
21
37
  if (isProcessExited(child)) {
22
38
  return Promise.resolve();
@@ -35,6 +51,136 @@ export function signalProcessTree(child, signal) {
35
51
  });
36
52
  });
37
53
  }
54
+ // The root plus every descendant still in its session. A descendant that leads
55
+ // its own session called setsid() to detach (daemons, `spawn({ detached: true })`),
56
+ // so it and its subtree are left out. Process groups are not the boundary: shell
57
+ // job control gives each pipeline its own group inside the same session.
58
+ export function selectAttachedProcessTree(table, rootPid) {
59
+ const childrenByParent = new Map();
60
+ for (const entry of table) {
61
+ if (entry.pid === rootPid)
62
+ continue;
63
+ const siblings = childrenByParent.get(entry.ppid);
64
+ if (siblings) {
65
+ siblings.push(entry);
66
+ }
67
+ else {
68
+ childrenByParent.set(entry.ppid, [entry]);
69
+ }
70
+ }
71
+ const attached = [rootPid];
72
+ const visited = new Set(attached);
73
+ for (let index = 0; index < attached.length; index += 1) {
74
+ for (const child of childrenByParent.get(attached[index]) ?? []) {
75
+ if (child.sessionLeader || visited.has(child.pid))
76
+ continue;
77
+ visited.add(child.pid);
78
+ attached.push(child.pid);
79
+ }
80
+ }
81
+ return attached;
82
+ }
83
+ async function signalAttachedProcessTree(child, signal, deps) {
84
+ if (isProcessExited(child)) {
85
+ return;
86
+ }
87
+ const pid = child.pid;
88
+ if (typeof pid !== "number" || pid <= 0) {
89
+ signalDirectChild(child, signal);
90
+ return;
91
+ }
92
+ const table = await deps.readProcessTable().catch(() => null);
93
+ if (!table) {
94
+ await deps.signalTree(child, signal);
95
+ return;
96
+ }
97
+ for (const target of selectAttachedProcessTree(table, pid)) {
98
+ try {
99
+ deps.signalPid(target, signal);
100
+ }
101
+ catch {
102
+ if (target === pid) {
103
+ signalDirectChild(child, signal);
104
+ }
105
+ // Descendants that exited since the snapshot are already gone.
106
+ }
107
+ }
108
+ }
109
+ export function readProcessTable() {
110
+ if (process.platform === "win32") {
111
+ // tree-kill's taskkill /T owns Windows; there is no session to detach into.
112
+ return Promise.resolve(null);
113
+ }
114
+ if (process.platform === "linux") {
115
+ return readLinuxProcessTable();
116
+ }
117
+ return readPsProcessTable();
118
+ }
119
+ async function readLinuxProcessTable() {
120
+ let names;
121
+ try {
122
+ names = await readdir("/proc");
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ const entries = await Promise.all(names
128
+ .filter((name) => /^\d+$/.test(name))
129
+ .map(async (name) => {
130
+ try {
131
+ return parseProcStat(await readFile(`/proc/${name}/stat`, "utf8"));
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ }));
137
+ const table = entries.filter((entry) => entry !== null);
138
+ return table.length > 0 ? table : null;
139
+ }
140
+ // /proc/<pid>/stat is "pid (comm) state ppid pgrp session ...". comm may itself
141
+ // contain spaces and parentheses, so fields are read after the last ")".
142
+ export function parseProcStat(stat) {
143
+ const commEnd = stat.lastIndexOf(")");
144
+ if (commEnd === -1)
145
+ return null;
146
+ const pid = Number.parseInt(stat, 10);
147
+ const fields = stat
148
+ .slice(commEnd + 1)
149
+ .trim()
150
+ .split(/\s+/);
151
+ const ppid = Number.parseInt(fields[1] ?? "", 10);
152
+ const session = Number.parseInt(fields[3] ?? "", 10);
153
+ if (!Number.isInteger(pid) || !Number.isInteger(ppid) || !Number.isInteger(session)) {
154
+ return null;
155
+ }
156
+ return { pid, ppid, sessionLeader: session === pid };
157
+ }
158
+ // BSD ps (macOS) reports no usable session id, but both BSD and procps flag a
159
+ // session leader with "s" in STAT.
160
+ function readPsProcessTable() {
161
+ return new Promise((resolve) => {
162
+ execFile("ps", ["-A", "-o", "pid=,ppid=,stat="], { timeout: 2000, maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => {
163
+ if (error) {
164
+ resolve(null);
165
+ return;
166
+ }
167
+ const table = parsePsProcessTable(stdout);
168
+ resolve(table.length > 0 ? table : null);
169
+ });
170
+ });
171
+ }
172
+ export function parsePsProcessTable(output) {
173
+ const table = [];
174
+ for (const line of output.split("\n")) {
175
+ const [pidText, ppidText, stat = ""] = line.trim().split(/\s+/);
176
+ const pid = Number.parseInt(pidText ?? "", 10);
177
+ const ppid = Number.parseInt(ppidText ?? "", 10);
178
+ if (!Number.isInteger(pid) || !Number.isInteger(ppid))
179
+ continue;
180
+ table.push({ pid, ppid, sessionLeader: stat.includes("s") });
181
+ }
182
+ return table;
183
+ }
38
184
  function signalDirectChild(child, signal) {
39
185
  try {
40
186
  child.kill(signal);
@@ -1,22 +1,38 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readdir, readFile } from "node:fs/promises";
1
3
  import treeKill from "tree-kill";
2
- export async function terminateWithTreeKill(child, options) {
3
- if (isProcessExited(child)) {
4
- return "already-exited";
5
- }
6
- const exitPromise = waitForProcessExit(child);
7
- await signalProcessTree(child, options.gracefulSignal ?? "SIGTERM");
8
- if (await waitForExitOrTimeout(exitPromise, options.gracefulTimeoutMs)) {
9
- return "terminated";
10
- }
11
- options.onForceSignal?.();
12
- await signalProcessTree(child, options.forceSignal ?? "SIGKILL");
13
- if (options.forceTimeoutMs === undefined) {
14
- return "killed";
15
- }
16
- return (await waitForExitOrTimeout(exitPromise, options.forceTimeoutMs))
17
- ? "killed"
18
- : "kill-timeout";
4
+ export function createTreeKillTerminator(dependencies = {}) {
5
+ const deps = {
6
+ readProcessTable: dependencies.readProcessTable ?? readProcessTable,
7
+ signalPid: dependencies.signalPid ?? ((pid, signal) => process.kill(pid, signal)),
8
+ signalTree: dependencies.signalTree ?? signalProcessTree,
9
+ };
10
+ return async (child, options) => {
11
+ if (isProcessExited(child)) {
12
+ return "already-exited";
13
+ }
14
+ const exitPromise = waitForProcessExit(child);
15
+ await signalAttachedProcessTree(child, options.gracefulSignal ?? "SIGTERM", deps);
16
+ if (await waitForExitOrTimeout(exitPromise, options.gracefulTimeoutMs)) {
17
+ return "terminated";
18
+ }
19
+ options.onForceSignal?.();
20
+ const forceSignal = options.forceSignal ?? "SIGKILL";
21
+ if (options.spareDetachedOnForce) {
22
+ await signalAttachedProcessTree(child, forceSignal, deps);
23
+ }
24
+ else {
25
+ await deps.signalTree(child, forceSignal);
26
+ }
27
+ if (options.forceTimeoutMs === undefined) {
28
+ return "killed";
29
+ }
30
+ return (await waitForExitOrTimeout(exitPromise, options.forceTimeoutMs))
31
+ ? "killed"
32
+ : "kill-timeout";
33
+ };
19
34
  }
35
+ export const terminateWithTreeKill = createTreeKillTerminator();
20
36
  export function signalProcessTree(child, signal) {
21
37
  if (isProcessExited(child)) {
22
38
  return Promise.resolve();
@@ -35,6 +51,136 @@ export function signalProcessTree(child, signal) {
35
51
  });
36
52
  });
37
53
  }
54
+ // The root plus every descendant still in its session. A descendant that leads
55
+ // its own session called setsid() to detach (daemons, `spawn({ detached: true })`),
56
+ // so it and its subtree are left out. Process groups are not the boundary: shell
57
+ // job control gives each pipeline its own group inside the same session.
58
+ export function selectAttachedProcessTree(table, rootPid) {
59
+ const childrenByParent = new Map();
60
+ for (const entry of table) {
61
+ if (entry.pid === rootPid)
62
+ continue;
63
+ const siblings = childrenByParent.get(entry.ppid);
64
+ if (siblings) {
65
+ siblings.push(entry);
66
+ }
67
+ else {
68
+ childrenByParent.set(entry.ppid, [entry]);
69
+ }
70
+ }
71
+ const attached = [rootPid];
72
+ const visited = new Set(attached);
73
+ for (let index = 0; index < attached.length; index += 1) {
74
+ for (const child of childrenByParent.get(attached[index]) ?? []) {
75
+ if (child.sessionLeader || visited.has(child.pid))
76
+ continue;
77
+ visited.add(child.pid);
78
+ attached.push(child.pid);
79
+ }
80
+ }
81
+ return attached;
82
+ }
83
+ async function signalAttachedProcessTree(child, signal, deps) {
84
+ if (isProcessExited(child)) {
85
+ return;
86
+ }
87
+ const pid = child.pid;
88
+ if (typeof pid !== "number" || pid <= 0) {
89
+ signalDirectChild(child, signal);
90
+ return;
91
+ }
92
+ const table = await deps.readProcessTable().catch(() => null);
93
+ if (!table) {
94
+ await deps.signalTree(child, signal);
95
+ return;
96
+ }
97
+ for (const target of selectAttachedProcessTree(table, pid)) {
98
+ try {
99
+ deps.signalPid(target, signal);
100
+ }
101
+ catch {
102
+ if (target === pid) {
103
+ signalDirectChild(child, signal);
104
+ }
105
+ // Descendants that exited since the snapshot are already gone.
106
+ }
107
+ }
108
+ }
109
+ export function readProcessTable() {
110
+ if (process.platform === "win32") {
111
+ // tree-kill's taskkill /T owns Windows; there is no session to detach into.
112
+ return Promise.resolve(null);
113
+ }
114
+ if (process.platform === "linux") {
115
+ return readLinuxProcessTable();
116
+ }
117
+ return readPsProcessTable();
118
+ }
119
+ async function readLinuxProcessTable() {
120
+ let names;
121
+ try {
122
+ names = await readdir("/proc");
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ const entries = await Promise.all(names
128
+ .filter((name) => /^\d+$/.test(name))
129
+ .map(async (name) => {
130
+ try {
131
+ return parseProcStat(await readFile(`/proc/${name}/stat`, "utf8"));
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ }));
137
+ const table = entries.filter((entry) => entry !== null);
138
+ return table.length > 0 ? table : null;
139
+ }
140
+ // /proc/<pid>/stat is "pid (comm) state ppid pgrp session ...". comm may itself
141
+ // contain spaces and parentheses, so fields are read after the last ")".
142
+ export function parseProcStat(stat) {
143
+ const commEnd = stat.lastIndexOf(")");
144
+ if (commEnd === -1)
145
+ return null;
146
+ const pid = Number.parseInt(stat, 10);
147
+ const fields = stat
148
+ .slice(commEnd + 1)
149
+ .trim()
150
+ .split(/\s+/);
151
+ const ppid = Number.parseInt(fields[1] ?? "", 10);
152
+ const session = Number.parseInt(fields[3] ?? "", 10);
153
+ if (!Number.isInteger(pid) || !Number.isInteger(ppid) || !Number.isInteger(session)) {
154
+ return null;
155
+ }
156
+ return { pid, ppid, sessionLeader: session === pid };
157
+ }
158
+ // BSD ps (macOS) reports no usable session id, but both BSD and procps flag a
159
+ // session leader with "s" in STAT.
160
+ function readPsProcessTable() {
161
+ return new Promise((resolve) => {
162
+ execFile("ps", ["-A", "-o", "pid=,ppid=,stat="], { timeout: 2000, maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => {
163
+ if (error) {
164
+ resolve(null);
165
+ return;
166
+ }
167
+ const table = parsePsProcessTable(stdout);
168
+ resolve(table.length > 0 ? table : null);
169
+ });
170
+ });
171
+ }
172
+ export function parsePsProcessTable(output) {
173
+ const table = [];
174
+ for (const line of output.split("\n")) {
175
+ const [pidText, ppidText, stat = ""] = line.trim().split(/\s+/);
176
+ const pid = Number.parseInt(pidText ?? "", 10);
177
+ const ppid = Number.parseInt(ppidText ?? "", 10);
178
+ if (!Number.isInteger(pid) || !Number.isInteger(ppid))
179
+ continue;
180
+ table.push({ pid, ppid, sessionLeader: stat.includes("s") });
181
+ }
182
+ return table;
183
+ }
38
184
  function signalDirectChild(child, signal) {
39
185
  try {
40
186
  child.kill(signal);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camerontaylor/paseo-server",
3
- "version": "0.13.0-fork.1",
3
+ "version": "0.13.0-fork.2",
4
4
  "description": "Paseo backend server",
5
5
  "files": [
6
6
  "dist/server",
@@ -107,11 +107,11 @@
107
107
  "@agentclientprotocol/sdk": "^0.17.1",
108
108
  "@anthropic-ai/claude-agent-sdk": "0.3.246",
109
109
  "@anthropic-ai/sdk": "^0.104.2",
110
- "@camerontaylor/paseo-client": "0.13.0-fork.1",
111
- "@camerontaylor/paseo-highlight": "0.13.0-fork.1",
112
- "@camerontaylor/paseo-plugin": "0.13.0-fork.1",
113
- "@camerontaylor/paseo-protocol": "0.13.0-fork.1",
114
- "@camerontaylor/paseo-relay": "0.13.0-fork.1",
110
+ "@camerontaylor/paseo-client": "0.13.0-fork.2",
111
+ "@camerontaylor/paseo-highlight": "0.13.0-fork.2",
112
+ "@camerontaylor/paseo-plugin": "0.13.0-fork.2",
113
+ "@camerontaylor/paseo-protocol": "0.13.0-fork.2",
114
+ "@camerontaylor/paseo-relay": "0.13.0-fork.2",
115
115
  "@isaacs/ttlcache": "^2.1.4",
116
116
  "@modelcontextprotocol/sdk": "1.29.0",
117
117
  "@opencode-ai/sdk": "1.14.46",