@getpaseo/server 0.1.97 → 0.1.98

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.
Files changed (69) hide show
  1. package/dist/server/server/agent/agent-manager.d.ts +11 -3
  2. package/dist/server/server/agent/agent-manager.js +94 -22
  3. package/dist/server/server/agent/agent-prompt.d.ts +1 -1
  4. package/dist/server/server/agent/agent-prompt.js +3 -10
  5. package/dist/server/server/agent/agent-sdk-types.d.ts +9 -3
  6. package/dist/server/server/agent/create-agent/create.d.ts +2 -0
  7. package/dist/server/server/agent/create-agent/create.js +8 -7
  8. package/dist/server/server/agent/lifecycle-command.d.ts +15 -1
  9. package/dist/server/server/agent/lifecycle-command.js +9 -2
  10. package/dist/server/server/agent/mcp-server.js +254 -115
  11. package/dist/server/server/agent/provider-notices.d.ts +3 -0
  12. package/dist/server/server/agent/provider-notices.js +5 -0
  13. package/dist/server/server/agent/provider-registry.d.ts +2 -0
  14. package/dist/server/server/agent/provider-registry.js +10 -3
  15. package/dist/server/server/agent/provider-snapshot-manager.d.ts +3 -0
  16. package/dist/server/server/agent/provider-snapshot-manager.js +11 -2
  17. package/dist/server/server/agent/providers/claude/agent.js +257 -143
  18. package/dist/server/server/agent/providers/claude/models.js +7 -3
  19. package/dist/server/server/agent/providers/codex-app-server-agent.d.ts +4 -3
  20. package/dist/server/server/agent/providers/codex-app-server-agent.js +43 -1
  21. package/dist/server/server/agent/providers/copilot-acp-agent.js +4 -1
  22. package/dist/server/server/agent/providers/diagnostic-utils.d.ts +9 -0
  23. package/dist/server/server/agent/providers/diagnostic-utils.js +188 -0
  24. package/dist/server/server/agent/providers/mock-slow-provider.js +1 -1
  25. package/dist/server/server/agent/providers/opencode/server-manager.d.ts +29 -2
  26. package/dist/server/server/agent/providers/opencode/server-manager.js +83 -17
  27. package/dist/server/server/agent/providers/opencode-agent.d.ts +2 -0
  28. package/dist/server/server/agent/providers/opencode-agent.js +14 -9
  29. package/dist/server/server/agent/providers/pi/agent.js +27 -14
  30. package/dist/server/server/bootstrap.d.ts +2 -0
  31. package/dist/server/server/bootstrap.js +32 -2
  32. package/dist/server/server/managed-processes/managed-processes.d.ts +76 -0
  33. package/dist/server/server/managed-processes/managed-processes.js +326 -0
  34. package/dist/server/server/resolve-worktree-creation-intent.d.ts +3 -0
  35. package/dist/server/server/resolve-worktree-creation-intent.js +3 -3
  36. package/dist/server/server/session.d.ts +12 -1
  37. package/dist/server/server/session.js +230 -40
  38. package/dist/server/server/speech/providers/openai/runtime.js +3 -4
  39. package/dist/server/server/websocket-server.d.ts +1 -0
  40. package/dist/server/server/websocket-server.js +11 -0
  41. package/dist/server/server/workspace-archive-service.js +2 -3
  42. package/dist/server/server/workspace-directory.js +5 -5
  43. package/dist/server/server/workspace-reconciliation-service.js +2 -2
  44. package/dist/server/server/worktree-core.d.ts +1 -0
  45. package/dist/server/server/worktree-core.js +5 -1
  46. package/dist/server/services/quota-fetcher/manifest.d.ts +4 -0
  47. package/dist/server/services/quota-fetcher/manifest.js +47 -0
  48. package/dist/server/services/quota-fetcher/provider.d.ts +17 -0
  49. package/dist/server/services/quota-fetcher/provider.js +2 -0
  50. package/dist/server/services/quota-fetcher/providers/claude.d.ts +26 -0
  51. package/dist/server/services/quota-fetcher/providers/claude.js +217 -0
  52. package/dist/server/services/quota-fetcher/providers/codex.d.ts +23 -0
  53. package/dist/server/services/quota-fetcher/providers/codex.js +211 -0
  54. package/dist/server/services/quota-fetcher/providers/copilot.d.ts +17 -0
  55. package/dist/server/services/quota-fetcher/providers/copilot.js +75 -0
  56. package/dist/server/services/quota-fetcher/providers/cursor.d.ts +17 -0
  57. package/dist/server/services/quota-fetcher/providers/cursor.js +123 -0
  58. package/dist/server/services/quota-fetcher/providers/grok.d.ts +18 -0
  59. package/dist/server/services/quota-fetcher/providers/grok.js +89 -0
  60. package/dist/server/services/quota-fetcher/providers/kimi.d.ts +20 -0
  61. package/dist/server/services/quota-fetcher/providers/kimi.js +89 -0
  62. package/dist/server/services/quota-fetcher/providers/zai.d.ts +17 -0
  63. package/dist/server/services/quota-fetcher/providers/zai.js +58 -0
  64. package/dist/server/services/quota-fetcher/service.d.ts +28 -0
  65. package/dist/server/services/quota-fetcher/service.js +58 -0
  66. package/dist/server/services/quota-fetcher/usage.d.ts +22 -0
  67. package/dist/server/services/quota-fetcher/usage.js +49 -0
  68. package/dist/server/utils/directory-suggestions.js +98 -2
  69. package/package.json +5 -5
@@ -3,12 +3,13 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { homedir, tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { z } from "zod";
6
+ import { withTimeout } from "../../../../utils/promise-timeout.js";
6
7
  import { importSessionFromPersistence } from "../../provider-session-import.js";
7
8
  import { runProviderTurn } from "../provider-runner.js";
8
9
  import { checkProviderLaunchAvailable, resolveProviderLaunch, } from "../../provider-launch-config.js";
9
10
  import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
10
11
  import { composeSystemPromptParts } from "../../system-prompt.js";
11
- import { buildBinaryDiagnosticRows, formatDiagnosticStatus, formatProviderDiagnostic, formatProviderDiagnosticError, toDiagnosticErrorMessage, } from "../diagnostic-utils.js";
12
+ import { buildBinaryDiagnosticRows, buildCommandResolutionDiagnosticRows, formatDiagnosticStatus, formatProviderDiagnostic, formatProviderDiagnosticError, toDiagnosticErrorMessage, } from "../diagnostic-utils.js";
12
13
  import { getUserMessageText, streamPiHistory, } from "./history-mapper.js";
13
14
  import { PiCliRuntime } from "./cli-runtime.js";
14
15
  import { revertPiConversation } from "./rewind.js";
@@ -1567,24 +1568,33 @@ export class PiRpcAgentClient {
1567
1568
  });
1568
1569
  }
1569
1570
  async isAvailable() {
1570
- const launch = await this.resolvePiLaunch();
1571
- const availability = await checkProviderLaunchAvailable(launch);
1572
- if (!availability.available) {
1573
- return false;
1574
- }
1575
- const runtimeSession = await this.runtime.startSession({ cwd: homedir() }).catch(() => null);
1576
- if (!runtimeSession) {
1577
- return false;
1578
- }
1579
1571
  try {
1580
- return (await runtimeSession.getAvailableModels()).length > 0;
1572
+ return await withTimeout((async () => {
1573
+ const launch = await this.resolvePiLaunch();
1574
+ const availability = await checkProviderLaunchAvailable(launch);
1575
+ if (!availability.available) {
1576
+ return false;
1577
+ }
1578
+ const runtimeSession = await this.runtime
1579
+ .startSession({ cwd: homedir() })
1580
+ .catch(() => null);
1581
+ if (!runtimeSession) {
1582
+ return false;
1583
+ }
1584
+ try {
1585
+ return (await runtimeSession.getAvailableModels()).length > 0;
1586
+ }
1587
+ catch {
1588
+ return false;
1589
+ }
1590
+ finally {
1591
+ await runtimeSession.close().catch(() => undefined);
1592
+ }
1593
+ })(), 2000, "Pi availability check timed out");
1581
1594
  }
1582
1595
  catch {
1583
1596
  return false;
1584
1597
  }
1585
- finally {
1586
- await runtimeSession.close().catch(() => undefined);
1587
- }
1588
1598
  }
1589
1599
  async getDiagnostic() {
1590
1600
  try {
@@ -1633,6 +1643,9 @@ export class PiRpcAgentClient {
1633
1643
  }
1634
1644
  return {
1635
1645
  diagnostic: formatProviderDiagnostic("Pi", [
1646
+ ...(await buildCommandResolutionDiagnosticRows(launch, {
1647
+ knownBinaryNames: [launch.command],
1648
+ })),
1636
1649
  ...(await buildBinaryDiagnosticRows(launch, availability)),
1637
1650
  { label: "Configured providers", value: configuredProvidersValue },
1638
1651
  {
@@ -25,6 +25,7 @@ import type { AgentProviderRuntimeSettingsMap, ProviderOverride } from "./agent/
25
25
  import type { PersistedConfig } from "./persisted-config.js";
26
26
  import { type ServiceProxySubsystem } from "./service-proxy.js";
27
27
  import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
28
+ import { type ManagedProcessRegistry } from "./managed-processes/managed-processes.js";
28
29
  import { type HostnamesConfig } from "./hostnames.js";
29
30
  import { type DaemonAuthConfig } from "./auth.js";
30
31
  export declare function createTerminalActivityRouteHandler(terminalManager: TerminalManager): express.RequestHandler;
@@ -97,6 +98,7 @@ export interface PaseoDaemonConfig {
97
98
  log?: PersistedConfig["log"];
98
99
  onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void;
99
100
  pushNotificationSender?: PushNotificationSender;
101
+ managedProcesses?: ManagedProcessRegistry;
100
102
  }
101
103
  export interface PaseoDaemon {
102
104
  config: PaseoDaemonConfig;
@@ -52,12 +52,15 @@ export function parseListenString(listen) {
52
52
  }
53
53
  // 6. host:port — TCP
54
54
  if (listen.includes(":")) {
55
- const [host, portStr] = listen.split(":");
55
+ const lastColonIdx = listen.lastIndexOf(":");
56
+ const host = listen.slice(0, lastColonIdx);
57
+ const portStr = listen.slice(lastColonIdx + 1);
56
58
  const parsedPort = parseInt(portStr, 10);
57
59
  if (!Number.isFinite(parsedPort)) {
58
60
  throw new Error(`Invalid port in listen string: ${listen}`);
59
61
  }
60
- return { type: "tcp", host: host || "127.0.0.1", port: parsedPort };
62
+ const cleanHost = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
63
+ return { type: "tcp", host: cleanHost || "127.0.0.1", port: parsedPort };
61
64
  }
62
65
  throw new Error(`Invalid listen string: ${listen}`);
63
66
  }
@@ -105,6 +108,8 @@ import { createServiceProxySubsystem } from "./service-proxy.js";
105
108
  import { ScriptHealthMonitor } from "./script-health-monitor.js";
106
109
  import { createScriptStatusEmitter } from "./script-status-projection.js";
107
110
  import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
111
+ import { createManagedProcessRegistry, createSystemManagedProcessTable, } from "./managed-processes/managed-processes.js";
112
+ import { terminateWithTreeKill } from "../utils/tree-kill.js";
108
113
  import { isHostnameAllowed } from "./hostnames.js";
109
114
  import { createRequireBearerMiddleware, isAgentMcpRequestAuthorized, } from "./auth.js";
110
115
  const MAX_MCP_DEBUG_BATCH_ITEMS = 10;
@@ -207,6 +212,23 @@ function summarizeAgentMcpDebugBody(body) {
207
212
  ...(body.length > messages.length ? { omitted: body.length - messages.length } : {}),
208
213
  };
209
214
  }
215
+ function createBootstrapManagedProcessRegistry(config, logger) {
216
+ if (config.managedProcesses) {
217
+ return config.managedProcesses;
218
+ }
219
+ return createManagedProcessRegistry({
220
+ paseoHome: config.paseoHome,
221
+ processTable: createSystemManagedProcessTable(),
222
+ terminateProcess: terminateWithTreeKill,
223
+ logger,
224
+ });
225
+ }
226
+ async function reconcileManagedProcessLedger(managedProcesses, logger) {
227
+ const reapResult = await managedProcesses.reapStale();
228
+ if (reapResult.checked > 0 || reapResult.errors.length > 0) {
229
+ logger.info(reapResult, "Managed helper process ledger reconciled");
230
+ }
231
+ }
210
232
  export async function createPaseoDaemon(config, rootLogger) {
211
233
  const logger = rootLogger.child({ module: "bootstrap" });
212
234
  const bootstrapStart = performance.now();
@@ -233,6 +255,13 @@ export async function createPaseoDaemon(config, rootLogger) {
233
255
  }, logger);
234
256
  const serverId = getOrCreateServerId(config.paseoHome, { logger });
235
257
  const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.paseoHome, logger);
258
+ const managedProcesses = createBootstrapManagedProcessRegistry(config, logger);
259
+ // Reconcile the helper-process ledger in the background so it never blocks the
260
+ // daemon from coming up; terminating a live leftover can take a few seconds.
261
+ // Best-effort, so a failure is logged here rather than crashing startup.
262
+ void reconcileManagedProcessLedger(managedProcesses, logger).catch((error) => {
263
+ logger.warn({ err: error }, "Failed to reconcile managed helper process ledger");
264
+ });
236
265
  let relayTransport = null;
237
266
  const staticDir = config.staticDir;
238
267
  const downloadTokenTtlMs = config.downloadTokenTtlMs ?? 60000;
@@ -434,6 +463,7 @@ export async function createPaseoDaemon(config, rootLogger) {
434
463
  runtimeSettings: config.agentProviderSettings,
435
464
  providerOverrides: config.providerOverrides,
436
465
  workspaceGitService,
466
+ managedProcesses,
437
467
  isDev: config.isDev === true,
438
468
  extraClients: config.agentClients,
439
469
  });
@@ -0,0 +1,76 @@
1
+ import type { Logger } from "pino";
2
+ import type { ProcessTerminator, TreeKillTarget } from "../../utils/tree-kill.js";
3
+ export interface ManagedProcessSnapshot {
4
+ pid: number;
5
+ commandLine: string | null;
6
+ startedAt: string | null;
7
+ }
8
+ export type ManagedProcessInspection = {
9
+ status: "alive";
10
+ snapshot: ManagedProcessSnapshot;
11
+ } | {
12
+ status: "not-found";
13
+ } | {
14
+ status: "error";
15
+ error: unknown;
16
+ };
17
+ export interface ManagedProcessTable {
18
+ inspect(pid: number): Promise<ManagedProcessInspection>;
19
+ }
20
+ export interface ManagedProcessCommandRunner {
21
+ exec(command: string, args: string[]): Promise<{
22
+ stdout: string;
23
+ stderr: string;
24
+ }>;
25
+ }
26
+ export interface ManagedProcessOwner {
27
+ provider: string;
28
+ kind: string;
29
+ }
30
+ export interface ManagedProcessRecordInput {
31
+ owner: ManagedProcessOwner;
32
+ pid: number;
33
+ command: string;
34
+ args: string[];
35
+ metadata?: Record<string, unknown>;
36
+ }
37
+ export interface ManagedProcessRecord extends ManagedProcessRecordInput {
38
+ id: string;
39
+ metadata: Record<string, unknown>;
40
+ identity: {
41
+ commandLine: string | null;
42
+ startedAt: string | null;
43
+ };
44
+ createdAt: string;
45
+ }
46
+ export interface ManagedProcessReapResult {
47
+ checked: number;
48
+ dead: number;
49
+ mismatched: number;
50
+ removed: number;
51
+ terminated: number;
52
+ errors: Array<{
53
+ id: string;
54
+ message: string;
55
+ }>;
56
+ }
57
+ export interface ManagedProcessRegistry {
58
+ record(input: ManagedProcessRecordInput): Promise<ManagedProcessRecord>;
59
+ remove(id: string): Promise<void>;
60
+ list(): Promise<ManagedProcessRecord[]>;
61
+ reapStale(): Promise<ManagedProcessReapResult>;
62
+ }
63
+ interface ManagedProcessRegistryOptions {
64
+ paseoHome: string;
65
+ processTable: ManagedProcessTable;
66
+ terminateProcess: ProcessTerminator;
67
+ logger: Logger;
68
+ }
69
+ export declare function createManagedProcessRegistry(options: ManagedProcessRegistryOptions): ManagedProcessRegistry;
70
+ export declare function createSystemManagedProcessTable(options?: {
71
+ platform?: NodeJS.Platform;
72
+ commandRunner?: ManagedProcessCommandRunner;
73
+ }): ManagedProcessTable;
74
+ export declare function createPidTarget(pid: number): TreeKillTarget;
75
+ export {};
76
+ //# sourceMappingURL=managed-processes.d.ts.map
@@ -0,0 +1,326 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+ import { z } from "zod";
5
+ import { writeJsonFileAtomic } from "../atomic-file.js";
6
+ import { execCommand } from "../../utils/spawn.js";
7
+ const MANAGED_PROCESS_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5000;
8
+ const MANAGED_PROCESS_FORCE_SHUTDOWN_TIMEOUT_MS = 1000;
9
+ const MANAGED_PROCESS_EXIT_POLL_INTERVAL_MS = 50;
10
+ const MANAGED_PROCESS_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
11
+ // `ps -o lstart` emits a fixed-width 24-char ctime stamp, e.g. "Sat Jun 20 10:30:40 2026".
12
+ const POSIX_LSTART_WIDTH = 24;
13
+ const ManagedProcessRecordSchema = z.object({
14
+ id: z.string().min(1),
15
+ owner: z.object({
16
+ provider: z.string().min(1),
17
+ kind: z.string().min(1),
18
+ }),
19
+ pid: z.number().int().positive(),
20
+ command: z.string().min(1),
21
+ args: z.array(z.string()),
22
+ metadata: z.record(z.string(), z.unknown()).default({}),
23
+ identity: z.object({
24
+ commandLine: z.string().nullable(),
25
+ startedAt: z.string().nullable(),
26
+ }),
27
+ createdAt: z.string().min(1),
28
+ });
29
+ const WindowsProcessSnapshotSchema = z.object({
30
+ ProcessId: z.number().int().positive(),
31
+ CommandLine: z.string().nullable().optional(),
32
+ CreationDate: z.string().nullable().optional(),
33
+ });
34
+ export function createManagedProcessRegistry(options) {
35
+ return new FileBackedManagedProcessRegistry(options);
36
+ }
37
+ export function createSystemManagedProcessTable(options) {
38
+ return new SystemManagedProcessTable({
39
+ platform: options?.platform ?? process.platform,
40
+ commandRunner: options?.commandRunner ?? {
41
+ exec: execCommand,
42
+ },
43
+ });
44
+ }
45
+ class SystemManagedProcessTable {
46
+ constructor(options) {
47
+ this.platform = options.platform;
48
+ this.commandRunner = options.commandRunner;
49
+ }
50
+ async inspect(pid) {
51
+ if (!Number.isInteger(pid) || pid <= 0) {
52
+ return { status: "not-found" };
53
+ }
54
+ try {
55
+ return this.platform === "win32"
56
+ ? await this.inspectWindows(pid)
57
+ : await this.inspectPosix(pid);
58
+ }
59
+ catch (error) {
60
+ return { status: "error", error };
61
+ }
62
+ }
63
+ async inspectPosix(pid) {
64
+ let stdout;
65
+ try {
66
+ ({ stdout } = await this.commandRunner.exec("ps", [
67
+ "-ww",
68
+ "-p",
69
+ String(pid),
70
+ "-o",
71
+ "lstart=",
72
+ "-o",
73
+ "command=",
74
+ ]));
75
+ }
76
+ catch (error) {
77
+ // `ps -p <pid>` exits non-zero when no process matches the pid; a numeric
78
+ // exit code means ps ran and found nothing, distinct from ps failing to run.
79
+ return isCommandExitFailure(error) ? { status: "not-found" } : { status: "error", error };
80
+ }
81
+ const line = stdout.trimEnd();
82
+ if (!line) {
83
+ return { status: "not-found" };
84
+ }
85
+ const startedAt = line.slice(0, POSIX_LSTART_WIDTH).trim();
86
+ const commandLine = line.slice(POSIX_LSTART_WIDTH).trim();
87
+ return {
88
+ status: "alive",
89
+ snapshot: {
90
+ pid,
91
+ commandLine: commandLine || null,
92
+ startedAt: startedAt || null,
93
+ },
94
+ };
95
+ }
96
+ async inspectWindows(pid) {
97
+ const command = [
98
+ `$process = Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}';`,
99
+ "if ($process) { $process | Select-Object ProcessId,CommandLine,CreationDate | ConvertTo-Json -Compress }",
100
+ ].join(" ");
101
+ const { stdout } = await this.commandRunner.exec("powershell.exe", [
102
+ "-NoProfile",
103
+ "-NonInteractive",
104
+ "-Command",
105
+ command,
106
+ ]);
107
+ const trimmed = stdout.trim();
108
+ if (!trimmed) {
109
+ return { status: "not-found" };
110
+ }
111
+ const parsed = WindowsProcessSnapshotSchema.parse(JSON.parse(trimmed));
112
+ return {
113
+ status: "alive",
114
+ snapshot: {
115
+ pid,
116
+ commandLine: parsed.CommandLine ?? null,
117
+ startedAt: parsed.CreationDate ?? null,
118
+ },
119
+ };
120
+ }
121
+ }
122
+ class FileBackedManagedProcessRegistry {
123
+ constructor(options) {
124
+ this.directory = path.join(options.paseoHome, "runtime", "managed-processes");
125
+ this.processTable = options.processTable;
126
+ this.terminateProcess = options.terminateProcess;
127
+ this.logger = options.logger.child({ module: "managed-processes" });
128
+ }
129
+ async record(input) {
130
+ const inspection = await this.processTable.inspect(input.pid);
131
+ const snapshot = inspection.status === "alive" ? inspection.snapshot : null;
132
+ const record = {
133
+ id: randomUUID(),
134
+ owner: input.owner,
135
+ pid: input.pid,
136
+ command: input.command,
137
+ args: input.args,
138
+ metadata: input.metadata ?? {},
139
+ identity: {
140
+ commandLine: snapshot?.commandLine ?? null,
141
+ startedAt: snapshot?.startedAt ?? null,
142
+ },
143
+ createdAt: new Date().toISOString(),
144
+ };
145
+ await writeJsonFileAtomic(this.recordPath(record.id), record);
146
+ return record;
147
+ }
148
+ async remove(id) {
149
+ await fs.rm(this.recordPath(id), { force: true });
150
+ }
151
+ async list() {
152
+ const entries = await this.readEntries();
153
+ return entries.map((entry) => entry.record);
154
+ }
155
+ async reapStale() {
156
+ const result = {
157
+ checked: 0,
158
+ dead: 0,
159
+ mismatched: 0,
160
+ removed: 0,
161
+ terminated: 0,
162
+ errors: [],
163
+ };
164
+ for (const entry of await this.readEntries()) {
165
+ result.checked += 1;
166
+ try {
167
+ const inspection = await this.processTable.inspect(entry.record.pid);
168
+ if (inspection.status === "not-found") {
169
+ await fs.rm(entry.path, { force: true });
170
+ result.dead += 1;
171
+ result.removed += 1;
172
+ continue;
173
+ }
174
+ if (inspection.status === "error") {
175
+ // Inspection failed, so we cannot tell whether the helper is still
176
+ // alive. Keep the record and retry on the next reconcile rather than
177
+ // orphaning a live process by deleting its record without killing it.
178
+ const message = inspection.error instanceof Error ? inspection.error.message : String(inspection.error);
179
+ result.errors.push({ id: entry.record.id, message });
180
+ this.logger.warn({
181
+ err: inspection.error,
182
+ id: entry.record.id,
183
+ pid: entry.record.pid,
184
+ owner: entry.record.owner,
185
+ }, "Could not inspect managed helper process; leaving record for next reconcile");
186
+ continue;
187
+ }
188
+ const snapshot = inspection.snapshot;
189
+ if (!processIdentityMatches(entry.record, snapshot)) {
190
+ await fs.rm(entry.path, { force: true });
191
+ result.mismatched += 1;
192
+ result.removed += 1;
193
+ continue;
194
+ }
195
+ await this.terminateProcess(createPidTarget(entry.record.pid), {
196
+ gracefulTimeoutMs: MANAGED_PROCESS_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
197
+ forceTimeoutMs: MANAGED_PROCESS_FORCE_SHUTDOWN_TIMEOUT_MS,
198
+ onForceSignal: () => {
199
+ this.logger.warn({
200
+ pid: entry.record.pid,
201
+ owner: entry.record.owner,
202
+ timeoutMs: MANAGED_PROCESS_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
203
+ }, "Managed helper process did not exit after SIGTERM; sending SIGKILL");
204
+ },
205
+ });
206
+ await fs.rm(entry.path, { force: true });
207
+ result.terminated += 1;
208
+ result.removed += 1;
209
+ }
210
+ catch (error) {
211
+ const message = error instanceof Error ? error.message : String(error);
212
+ result.errors.push({ id: entry.record.id, message });
213
+ this.logger.warn({ err: error, id: entry.record.id, pid: entry.record.pid, owner: entry.record.owner }, "Failed to reap managed helper process");
214
+ }
215
+ }
216
+ return result;
217
+ }
218
+ recordPath(id) {
219
+ if (!MANAGED_PROCESS_ID_PATTERN.test(id)) {
220
+ throw new Error(`Invalid managed process record id: ${id}`);
221
+ }
222
+ return path.join(this.directory, `${id}.json`);
223
+ }
224
+ async readEntries() {
225
+ let fileNames;
226
+ try {
227
+ fileNames = await fs.readdir(this.directory);
228
+ }
229
+ catch (error) {
230
+ if (isNodeErrorWithCode(error, "ENOENT")) {
231
+ return [];
232
+ }
233
+ throw error;
234
+ }
235
+ const entries = [];
236
+ for (const fileName of fileNames) {
237
+ if (!fileName.endsWith(".json")) {
238
+ continue;
239
+ }
240
+ const filePath = path.join(this.directory, fileName);
241
+ try {
242
+ const raw = await fs.readFile(filePath, "utf8");
243
+ const parsed = ManagedProcessRecordSchema.parse(JSON.parse(raw));
244
+ entries.push({ path: filePath, record: parsed });
245
+ }
246
+ catch (error) {
247
+ // A single corrupt or partially-written record must not abort the whole
248
+ // reconcile and leave every other leftover un-reaped. Skip it.
249
+ this.logger.warn({ err: error, file: fileName }, "Skipping unreadable managed process record");
250
+ }
251
+ }
252
+ return entries;
253
+ }
254
+ }
255
+ function processIdentityMatches(record, snapshot) {
256
+ if (record.identity.startedAt && snapshot.startedAt) {
257
+ if (record.identity.startedAt !== snapshot.startedAt) {
258
+ return false;
259
+ }
260
+ return snapshot.commandLine ? commandLineMatchesRecord(record, snapshot.commandLine) : true;
261
+ }
262
+ if (record.identity.commandLine && snapshot.commandLine) {
263
+ return (normalizeCommandLine(record.identity.commandLine) ===
264
+ normalizeCommandLine(snapshot.commandLine));
265
+ }
266
+ return snapshot.commandLine ? commandLineMatchesRecord(record, snapshot.commandLine) : false;
267
+ }
268
+ function commandLineMatchesRecord(record, commandLine) {
269
+ // Require the command name and args as one contiguous run, not scattered
270
+ // tokens. Without exact process identity (lstart), a reused PID whose command
271
+ // line merely mentions "opencode", "serve" and the port elsewhere must not be
272
+ // mistaken for our leftover and killed.
273
+ const normalized = normalizeCommandLine(commandLine);
274
+ const commandName = path.basename(record.command).toLowerCase();
275
+ const signature = [commandName, ...record.args].map((token) => token.toLowerCase()).join(" ");
276
+ return normalized.includes(signature);
277
+ }
278
+ function normalizeCommandLine(commandLine) {
279
+ return commandLine.replace(/\s+/g, " ").trim().toLowerCase();
280
+ }
281
+ export function createPidTarget(pid) {
282
+ return {
283
+ pid,
284
+ exitCode: null,
285
+ signalCode: null,
286
+ kill(signal) {
287
+ process.kill(pid, signal);
288
+ return true;
289
+ },
290
+ // The reaper has no ChildProcess handle for a leftover from a previous
291
+ // daemon, so it observes exit by polling the pid. Without this, termination
292
+ // can never see a graceful SIGTERM exit and always waits out the full
293
+ // graceful+force window before escalating to SIGKILL.
294
+ once(_event, listener) {
295
+ const timer = setInterval(() => {
296
+ if (!isProcessAlive(pid)) {
297
+ clearInterval(timer);
298
+ listener();
299
+ }
300
+ }, MANAGED_PROCESS_EXIT_POLL_INTERVAL_MS);
301
+ timer.unref();
302
+ },
303
+ };
304
+ }
305
+ function isProcessAlive(pid) {
306
+ try {
307
+ process.kill(pid, 0);
308
+ return true;
309
+ }
310
+ catch (error) {
311
+ return isNodeErrorWithCode(error, "EPERM");
312
+ }
313
+ }
314
+ function isCommandExitFailure(error) {
315
+ // execFile rejects with a numeric `code` (the process exit status) when the
316
+ // command ran and exited non-zero; a string `code` (e.g. "ENOENT") means it
317
+ // never ran.
318
+ return typeof error?.code === "number";
319
+ }
320
+ function isNodeErrorWithCode(error, code) {
321
+ return (typeof error === "object" &&
322
+ error !== null &&
323
+ "code" in error &&
324
+ error.code === code);
325
+ }
326
+ //# sourceMappingURL=managed-processes.js.map
@@ -3,16 +3,19 @@ import type { WorktreeSource } from "../utils/worktree.js";
3
3
  export type WorktreeCreationIntent = WorktreeSource;
4
4
  export type ResolveWorktreeCreationIntentInput = {
5
5
  worktreeSlug: string;
6
+ branchName?: string;
6
7
  refName?: string;
7
8
  action?: "branch-off";
8
9
  githubPrNumber?: undefined;
9
10
  } | {
10
11
  worktreeSlug?: string;
12
+ branchName?: string;
11
13
  refName?: string;
12
14
  action: "checkout";
13
15
  githubPrNumber?: number;
14
16
  } | {
15
17
  worktreeSlug?: string;
18
+ branchName?: string;
16
19
  refName?: string;
17
20
  action?: undefined;
18
21
  githubPrNumber: number;
@@ -10,7 +10,7 @@ export async function resolveWorktreeCreationIntent(input, repoRoot, deps) {
10
10
  return {
11
11
  kind: "branch-off",
12
12
  baseBranch: input.refName?.trim() || (await resolveDefaultBranch(repoRoot, deps)),
13
- branchName: input.worktreeSlug,
13
+ branchName: input.branchName ?? input.worktreeSlug,
14
14
  };
15
15
  }
16
16
  if (input.action === "checkout") {
@@ -43,13 +43,13 @@ export async function resolveWorktreeCreationIntent(input, repoRoot, deps) {
43
43
  return {
44
44
  kind: "branch-off",
45
45
  baseBranch: input.refName.trim(),
46
- branchName: input.worktreeSlug,
46
+ branchName: input.branchName ?? input.worktreeSlug,
47
47
  };
48
48
  }
49
49
  return {
50
50
  kind: "branch-off",
51
51
  baseBranch: await resolveDefaultBranch(repoRoot, deps),
52
- branchName: input.worktreeSlug,
52
+ branchName: input.branchName ?? input.worktreeSlug,
53
53
  };
54
54
  }
55
55
  async function resolveGitHubPrCheckoutIntent(params) {