@unbrained/pm-web 2026.7.25 → 2026.7.27

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,48 @@
1
+ import { type MutationEvent } from "@unbrained/pm-cli/sdk";
2
+ import { type SSEEvent } from "./sse.js";
3
+ /** Options for the async generator subscription seam. Mirrors the SDK's `SubscribeMutationEventsOptions`. */
4
+ export interface SubscribeOptions {
5
+ /** Tracker root path (`.agents/pm` under the project directory). */
6
+ pmRoot: string;
7
+ /** Opaque cursor or inclusive ISO timestamp lower bound. */
8
+ since: string;
9
+ /** Delay between empty catch-up reads. */
10
+ intervalMs: number;
11
+ /** Cancellation signal for a long-lived subscription. */
12
+ signal: AbortSignal;
13
+ }
14
+ /** Result of the subscribe seam — an async generator yielding mutation events. */
15
+ export type SubscribeFn = (options: SubscribeOptions) => AsyncGenerator<MutationEvent, void, void>;
16
+ /** Dependencies for `createMutationEventReconciler`, all injectable for testing. */
17
+ export interface MutationEventWatcherDeps {
18
+ /** Delay between empty catch-up reads passed to the SDK subscription (default 250, floor 10). */
19
+ intervalMs?: number;
20
+ /** Reconcile interval for the periodic sweep (default 2000, floor 500). */
21
+ reconcileMs?: number;
22
+ /** Returns the currently active (SSE-connected) project ids. */
23
+ getActiveProjectIds?: () => string[];
24
+ /** Resolves a project id to its on-disk directory, or null when the project row is absent. */
25
+ resolveProjectDir?: (projectId: string) => Promise<string | null>;
26
+ /** SDK subscription factory; defaults to the real `subscribeMutationEvents`. */
27
+ subscribe?: SubscribeFn;
28
+ /** Checks whether an item mutation was already announced by this instance; returns true and consumes it. */
29
+ consumeSignaledItemMutation?: (projectId: string, itemId: string) => boolean;
30
+ /** Emits an SSE event to the local clients of one project. */
31
+ emit?: (projectId: string, event: SSEEvent) => void;
32
+ /** Error sink for non-abort errors from a subscription loop. */
33
+ onError?: (err: unknown) => void;
34
+ }
35
+ /**
36
+ * Pure, testable reconciler. Holds per-project subscription state across
37
+ * reconcile cycles. Drives the watcher without timers — tests call `reconcile()`
38
+ * directly. Follows the same dependency-injection style as `createProjectWatchCycle`.
39
+ */
40
+ export declare function createMutationEventReconciler(deps?: MutationEventWatcherDeps): {
41
+ reconcile: () => Promise<void>;
42
+ stopAll: () => Promise<void>;
43
+ };
44
+ /**
45
+ * Start the mutation-event watcher. Returns a stop function, mirroring
46
+ * `startProjectWatcher`. Disabled entirely when `PM_REALTIME_MUTATION_EVENTS=false`.
47
+ */
48
+ export declare function startMutationEventWatcher(deps?: MutationEventWatcherDeps): () => void;
@@ -0,0 +1,171 @@
1
+ // Per-project mutation-event subscription manager.
2
+ //
3
+ // The primary out-of-band change detector for pm-web. Subscribes to the pm SDK's
4
+ // `subscribeMutationEvents` async generator for each active project, which reads
5
+ // from a persistent derived index of committed mutation facts (NOT a filesystem
6
+ // scan). Another process's mutation appears on this follower within `intervalMs`.
7
+ //
8
+ // Raw file edits that bypass pm (git merge, rsync restore, manual writes) produce
9
+ // NO mutation event — the stream is a committed-mutation fact stream derived from
10
+ // history. The filesystem sweep in project-watcher.ts is therefore KEPT as a safety
11
+ // net for those bypass-pm writes; this module is the primary path for pm-authored
12
+ // mutations.
13
+ //
14
+ // Events are emitted LOCALLY only (deliverProjectEvent, NOT broadcastProjectEvent).
15
+ // Every pm-web instance reads the same shared volume and will independently observe
16
+ // the same mutation events; publishing to the Postgres bus would duplicate them
17
+ // across instances. Each instance dedupes against its own API broadcasts via the
18
+ // per-item signal in sse.ts.
19
+ import path from "node:path";
20
+ import { subscribeMutationEvents } from "@unbrained/pm-cli/sdk";
21
+ import { resolveProjectDir } from "./pm-runner.js";
22
+ import { consumeSignaledItemMutation, deliverProjectEvent, getActiveProjectIds, } from "./sse.js";
23
+ const DEFAULT_INTERVAL_MS = 250;
24
+ const MIN_INTERVAL_MS = 10;
25
+ const DEFAULT_RECONCILE_MS = 2_000;
26
+ const MIN_RECONCILE_MS = 500;
27
+ function positiveIntEnv(name, fallback) {
28
+ const raw = process.env[name];
29
+ if (!raw)
30
+ return fallback;
31
+ const n = Number.parseInt(raw, 10);
32
+ return Number.isFinite(n) && n > 0 ? n : fallback;
33
+ }
34
+ // Determine whether an error from the subscription loop is an AbortError caused
35
+ // by a deliberate stop (project went inactive or shutdown). Those must NOT be
36
+ // reported as errors — only genuine failures are routed to onError.
37
+ function isAbortError(err) {
38
+ if (err instanceof Error) {
39
+ return err.name === "AbortError" || /^aborted/i.test(err.message);
40
+ }
41
+ return false;
42
+ }
43
+ /**
44
+ * Pure, testable reconciler. Holds per-project subscription state across
45
+ * reconcile cycles. Drives the watcher without timers — tests call `reconcile()`
46
+ * directly. Follows the same dependency-injection style as `createProjectWatchCycle`.
47
+ */
48
+ export function createMutationEventReconciler(deps = {}) {
49
+ const intervalMs = Math.max(MIN_INTERVAL_MS, deps.intervalMs ?? positiveIntEnv("PM_MUTATION_EVENT_INTERVAL_MS", DEFAULT_INTERVAL_MS));
50
+ const getIds = deps.getActiveProjectIds ?? getActiveProjectIds;
51
+ const resolveDir = deps.resolveProjectDir ?? resolveProjectDir;
52
+ const subscribe = deps.subscribe ?? subscribeMutationEvents;
53
+ const consumeSignal = deps.consumeSignaledItemMutation ?? consumeSignaledItemMutation;
54
+ const emit = deps.emit ?? deliverProjectEvent;
55
+ const onError = deps.onError ?? (() => undefined);
56
+ const subs = new Map();
57
+ const dirCache = new Map();
58
+ let inFlight = false;
59
+ // Start (or restart after error) a detached async loop that consumes the SDK
60
+ // generator for one project. Runs independently of reconcile; errors abort
61
+ // the subscription and the next reconcile re-establishes it from the stored cursor.
62
+ function startLoop(projectId, dir, since) {
63
+ const controller = new AbortController();
64
+ const sub = { controller, cursor: since, done: false };
65
+ subs.set(projectId, sub);
66
+ const pmRoot = path.join(dir, ".agents", "pm");
67
+ // Detached async loop — intentionally not awaited by reconcile.
68
+ void (async () => {
69
+ try {
70
+ const gen = subscribe({ pmRoot, since, intervalMs, signal: controller.signal });
71
+ for await (const event of gen) {
72
+ sub.cursor = event.cursor;
73
+ // Skip events that this instance's own API already announced (per-item dedupe).
74
+ if (consumeSignal(projectId, event.item_id))
75
+ continue;
76
+ emit(projectId, {
77
+ type: "workspace-changed",
78
+ data: {
79
+ source: "mutation-events",
80
+ itemId: event.item_id,
81
+ operation: event.type,
82
+ author: event.author,
83
+ },
84
+ });
85
+ }
86
+ }
87
+ catch (err) {
88
+ if (!isAbortError(err))
89
+ onError(err);
90
+ }
91
+ finally {
92
+ sub.done = true;
93
+ }
94
+ })();
95
+ }
96
+ const reconcile = async () => {
97
+ if (inFlight)
98
+ return; // never overlap reconciles
99
+ inFlight = true;
100
+ try {
101
+ const ids = getIds();
102
+ const active = new Set(ids);
103
+ // Abort and drop subscriptions for projects no longer active.
104
+ for (const [id, sub] of subs) {
105
+ if (!active.has(id)) {
106
+ sub.controller.abort();
107
+ subs.delete(id);
108
+ dirCache.delete(id);
109
+ }
110
+ }
111
+ // Clean up dir cache for inactive projects.
112
+ for (const id of [...dirCache.keys()])
113
+ if (!active.has(id))
114
+ dirCache.delete(id);
115
+ for (const projectId of ids) {
116
+ try {
117
+ let dir = dirCache.get(projectId);
118
+ if (dir === undefined) {
119
+ dir = await resolveDir(projectId);
120
+ dirCache.set(projectId, dir);
121
+ }
122
+ if (!dir)
123
+ continue;
124
+ const existing = subs.get(projectId);
125
+ if (existing) {
126
+ // If the loop exited (e.g. after an error), restart from the stored cursor.
127
+ if (existing.done) {
128
+ startLoop(projectId, dir, existing.cursor);
129
+ }
130
+ // Otherwise the loop is still running — leave it alone.
131
+ }
132
+ else {
133
+ // Newly-active project: start at the tail (no history replay).
134
+ startLoop(projectId, dir, new Date().toISOString());
135
+ }
136
+ }
137
+ catch (err) {
138
+ onError(err);
139
+ }
140
+ }
141
+ }
142
+ finally {
143
+ inFlight = false;
144
+ }
145
+ };
146
+ const stopAll = async () => {
147
+ for (const sub of subs.values())
148
+ sub.controller.abort();
149
+ subs.clear();
150
+ dirCache.clear();
151
+ };
152
+ return { reconcile, stopAll };
153
+ }
154
+ /**
155
+ * Start the mutation-event watcher. Returns a stop function, mirroring
156
+ * `startProjectWatcher`. Disabled entirely when `PM_REALTIME_MUTATION_EVENTS=false`.
157
+ */
158
+ export function startMutationEventWatcher(deps = {}) {
159
+ if (process.env.PM_REALTIME_MUTATION_EVENTS === "false")
160
+ return () => undefined;
161
+ const reconcileMs = Math.max(MIN_RECONCILE_MS, deps.reconcileMs ?? positiveIntEnv("PM_MUTATION_EVENT_RECONCILE_MS", DEFAULT_RECONCILE_MS));
162
+ const onError = deps.onError ?? ((err) => console.error("Mutation-event watcher reconcile failed", err instanceof Error ? err.message : err));
163
+ const { reconcile, stopAll } = createMutationEventReconciler({ ...deps, onError });
164
+ const timer = setInterval(() => { void reconcile().catch(onError); }, reconcileMs);
165
+ timer.unref();
166
+ return () => {
167
+ clearInterval(timer);
168
+ void stopAll();
169
+ };
170
+ }
171
+ //# sourceMappingURL=mutation-event-watcher.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mutation-event-watcher.js","sourceRoot":"","sources":["../../src/services/mutation-event-watcher.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,5 @@
1
- import { PmCliError, EXIT_CODE, type GetItemAtResult } from "@unbrained/pm-cli/sdk";
2
- export { PmCliError, EXIT_CODE, type GetItemAtResult };
1
+ import { PmClient, PmCliError, isPmCliExpectedError, EXIT_CODE, type GetItemAtResult } from "@unbrained/pm-cli/sdk";
2
+ export { PmCliError, isPmCliExpectedError, EXIT_CODE, type GetItemAtResult };
3
3
  export declare class Semaphore {
4
4
  private readonly limit;
5
5
  private active;
@@ -9,6 +9,22 @@ export declare class Semaphore {
9
9
  }
10
10
  export declare function projectsRoot(): string;
11
11
  export declare function getProjectDir(userId: string, slug: string): string;
12
+ /**
13
+ * Resolve a project id to its on-disk directory, or `null` when the project row
14
+ * is genuinely absent.
15
+ *
16
+ * Shared by both out-of-band change detectors (the mutation-event subscription
17
+ * and the filesystem safety-net sweep), which each cache the result per active
18
+ * SSE session. It lives here because this module already owns the
19
+ * project-id → path mapping via {@link getProjectDir}.
20
+ *
21
+ * Database errors are deliberately **not** swallowed: a transient `pool.query`
22
+ * failure must reach the caller's per-project error handling so the lookup is
23
+ * retried. Returning `null` on failure would let a caller cache "no such
24
+ * project" for the whole session and permanently stop watching it. `null`
25
+ * therefore means "the row is absent", which is safe to cache.
26
+ */
27
+ export declare function resolveProjectDir(projectId: string): Promise<string | null>;
12
28
  export declare function initProject(userId: string, slug: string, prefix: string): Promise<void>;
13
29
  export declare function projectExists(userId: string, slug: string): boolean;
14
30
  export interface PmRunOptions {
@@ -24,6 +40,8 @@ export interface PmRunResult {
24
40
  stderr: string;
25
41
  ok: boolean;
26
42
  parsed?: unknown;
43
+ /** pm CLI exit code from either the SDK dispatcher or spawned CLI fallback. */
44
+ exitCode?: number;
27
45
  }
28
46
  export interface EnsureGraphExtensionResult {
29
47
  ok: boolean;
@@ -32,6 +50,21 @@ export interface EnsureGraphExtensionResult {
32
50
  error?: string;
33
51
  }
34
52
  export declare function ensureGraphExtension(userId: string, slug: string): Promise<EnsureGraphExtensionResult>;
53
+ /**
54
+ * Return a cached {@link PmClient} for a workspace pm-root, creating one on
55
+ * first use. The SDK owns extension activation and serialization internally;
56
+ * caching avoids reconstructing the immutable workspace defaults while each
57
+ * call still receives the SDK's current extension snapshot. Author identity is
58
+ * resolved by the SDK's default detection, preserving prior CLI behaviour.
59
+ */
60
+ export declare function getPmClient(pmRoot: string): PmClient;
61
+ /** Drop a cached client when its workspace is deleted. */
62
+ export declare function evictPmClient(pmRoot: string): void;
63
+ /**
64
+ * Read a workspace's parsed `settings.json` for the search-tuning resolvers.
65
+ * Returns `{}` when absent so resolvers fall back to their built-in defaults.
66
+ */
67
+ export declare function readPmSettings(userId: string, slug: string): unknown;
35
68
  export declare function runPm(opts: PmRunOptions): Promise<PmRunResult>;
36
69
  /**
37
70
  * Reconstruct a single item at a one-based version or ISO timestamp using the
@@ -1,10 +1,11 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { getItemAt, PmCliError, EXIT_CODE, } from "@unbrained/pm-cli/sdk";
4
+ import { pool } from "../db.js";
5
+ import { getItemAt, PM_TOOL_PARAMETERS_SCHEMA, PmClient, PmCliError, isPmCliExpectedError, EXIT_CODE, } from "@unbrained/pm-cli/sdk";
5
6
  // Re-exported so route handlers and tests can reference the verified projection
6
7
  // shape and the typed error class without reaching into the SDK package map.
7
- export { PmCliError, EXIT_CODE };
8
+ export { PmCliError, isPmCliExpectedError, EXIT_CODE };
8
9
  const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
9
10
  const MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
10
11
  function positiveInteger(value, fallback) {
@@ -54,6 +55,28 @@ const PM_GRAPH_EXTENSION_PATH = process.env.PM_GRAPH_EXTENSION_PATH ||
54
55
  export function getProjectDir(userId, slug) {
55
56
  return path.join(projectsRoot(), userId, slug);
56
57
  }
58
+ /**
59
+ * Resolve a project id to its on-disk directory, or `null` when the project row
60
+ * is genuinely absent.
61
+ *
62
+ * Shared by both out-of-band change detectors (the mutation-event subscription
63
+ * and the filesystem safety-net sweep), which each cache the result per active
64
+ * SSE session. It lives here because this module already owns the
65
+ * project-id → path mapping via {@link getProjectDir}.
66
+ *
67
+ * Database errors are deliberately **not** swallowed: a transient `pool.query`
68
+ * failure must reach the caller's per-project error handling so the lookup is
69
+ * retried. Returning `null` on failure would let a caller cache "no such
70
+ * project" for the whole session and permanently stop watching it. `null`
71
+ * therefore means "the row is absent", which is safe to cache.
72
+ */
73
+ export async function resolveProjectDir(projectId) {
74
+ const res = await pool.query("SELECT user_id, slug FROM pm_projects WHERE id = $1", [projectId]);
75
+ const row = res.rows[0];
76
+ if (!row)
77
+ return null;
78
+ return getProjectDir(row.user_id, row.slug);
79
+ }
57
80
  let cachedPmCommand;
58
81
  function pmCliCommand() {
59
82
  if (process.env.PM_CLI_BIN)
@@ -135,6 +158,7 @@ async function runProcess(cwd, args, options = {}) {
135
158
  stdout: Buffer.concat(stdout).toString("utf8"),
136
159
  stderr: failure ? `${stderrText}${stderrText ? "\n" : ""}${failure}` : stderrText,
137
160
  ok: code === 0 && !failure,
161
+ exitCode: typeof code === "number" ? code : undefined,
138
162
  });
139
163
  });
140
164
  child.stdin.on("error", () => undefined);
@@ -278,15 +302,288 @@ export async function ensureGraphExtension(userId, slug) {
278
302
  }
279
303
  return { ok: true, installed: true, active: true };
280
304
  }
305
+ // ---------------------------------------------------------------------------
306
+ // In-process SDK dispatch
307
+ // ---------------------------------------------------------------------------
308
+ //
309
+ // The per-request `pm` binary spawn was the single biggest scalability defect
310
+ // in this package: every read/write of a workspace forked a node process, ran
311
+ // the CLI bootstrap, and acquired file locks through the OS. The pm CLI SDK
312
+ // (2026.7.26) exposes the same command runners the CLI uses as a typed,
313
+ // in-process `PmClient`. We now dispatch latency-bounded actions through one
314
+ // cached client configuration per workspace pm-root and retain the bounded
315
+ // spawn path for unsupported or potentially long-running actions. The latter
316
+ // prevents the SDK's process-wide activation queue from head-of-line blocking
317
+ // unrelated workspaces (tracked upstream as unbraind/pm-cli#742).
318
+ /**
319
+ * Actions that cannot be served by `PmClient.run` and must keep using the
320
+ * `pm` binary spawn. Each is documented with the concrete reason it is kept.
321
+ */
322
+ const SPAWN_FALLBACK_ACTIONS = new Set([
323
+ // Semantic search can invoke remote providers. Keep it outside the SDK's
324
+ // process-wide activation queue until unbraind/pm-cli#742 is resolved.
325
+ "search",
326
+ // Bulk updates can touch many items and must not block unrelated workspaces
327
+ // behind the SDK's process-wide activation queue.
328
+ "update-many",
329
+ // Dependency/schema upgrades are long-running maintenance operations.
330
+ "upgrade",
331
+ // Linked acceptance suites execute arbitrary project commands and can run for
332
+ // minutes, so test-all must not occupy the process-wide SDK queue.
333
+ "test-all",
334
+ // Full validation, health diagnostics, and garbage collection scan workspace
335
+ // state and may invoke extension hooks or external checks.
336
+ "validate",
337
+ "health",
338
+ "gc",
339
+ // `pm guide` is a static help renderer with no SDK action.
340
+ "guide",
341
+ // Search-index rebuild is a long-running maintenance command not exposed as an SDK action.
342
+ "reindex",
343
+ // Workspace normalization is a maintenance command not exposed as an SDK action.
344
+ "normalize",
345
+ // Dedupe audit is a governance report not exposed as an SDK action.
346
+ "dedupe-audit",
347
+ // Comments audit is a governance report not exposed as an SDK action.
348
+ "comments-audit",
349
+ // Calendar rendering is a presentation command not exposed as an SDK action.
350
+ "calendar",
351
+ // Test-runs history is not exposed as an SDK action.
352
+ "test-runs",
353
+ // `pm templates list/show` is not a native SDK action.
354
+ "templates",
355
+ // `PmClient.run("plan", {options:{subcommand,id}})` does not accept the plan id
356
+ // as an option key (only the typed convenience methods `planShow(id)` /
357
+ // `planAddStep(id,...)` do). Converting all ~17 plan routes to typed methods is
358
+ // out of scope for the spawn-removal hot path; plan stays on the spawn fallback.
359
+ "plan",
360
+ // pm-graph is an extension that emits its own JSON to stdout, which the graph
361
+ // routes `JSON.parse` directly. Running it through `PmClient.run` would change
362
+ // the result shape the routes depend on, so it stays on the spawn path.
363
+ "pm-graph",
364
+ ]);
365
+ /**
366
+ * Per-positional option key mapping for actions whose CLI form takes positional
367
+ * arguments (e.g. `pm get <id>`, `pm restore <id> <target>`). `PmClient.run`
368
+ * takes a single options bag, so positionals must be mapped onto named keys.
369
+ * Actions not listed here take options only (positionals are not expected).
370
+ */
371
+ const POSITIONAL_KEYS = {
372
+ init: ["prefix"],
373
+ get: ["id"],
374
+ update: ["id"],
375
+ close: ["id", "reason"],
376
+ delete: ["id"],
377
+ comments: ["id", "add"],
378
+ notes: ["id", "add"],
379
+ learnings: ["id", "add"],
380
+ test: ["id", "add"],
381
+ files: ["id"],
382
+ docs: ["id"],
383
+ deps: ["id"],
384
+ append: ["id", "body"],
385
+ restore: ["id", "target"],
386
+ claim: ["id"],
387
+ release: ["id"],
388
+ copy: ["id"],
389
+ focus: ["id"],
390
+ "start-task": ["id"],
391
+ "pause-task": ["id"],
392
+ "close-task": ["id", "reason"],
393
+ history: ["id"],
394
+ config: ["scope", "configAction", "key", "value"],
395
+ };
396
+ const PM_CLIENT_CACHE_MAX = positiveInteger(process.env.PM_WEB_PM_CLIENT_CACHE_MAX, 256);
397
+ /** Bounded least-recently-used `PmClient` cache keyed by workspace pm-root. */
398
+ const pmClientCache = new Map();
399
+ /**
400
+ * Return a cached {@link PmClient} for a workspace pm-root, creating one on
401
+ * first use. The SDK owns extension activation and serialization internally;
402
+ * caching avoids reconstructing the immutable workspace defaults while each
403
+ * call still receives the SDK's current extension snapshot. Author identity is
404
+ * resolved by the SDK's default detection, preserving prior CLI behaviour.
405
+ */
406
+ export function getPmClient(pmRoot) {
407
+ let client = pmClientCache.get(pmRoot);
408
+ if (client) {
409
+ pmClientCache.delete(pmRoot);
410
+ pmClientCache.set(pmRoot, client);
411
+ return client;
412
+ }
413
+ if (pmClientCache.size >= PM_CLIENT_CACHE_MAX) {
414
+ const leastRecentlyUsed = pmClientCache.keys().next().value;
415
+ if (typeof leastRecentlyUsed === "string")
416
+ pmClientCache.delete(leastRecentlyUsed);
417
+ }
418
+ client = new PmClient({
419
+ pmRoot,
420
+ cwd: path.dirname(path.dirname(pmRoot)),
421
+ });
422
+ pmClientCache.set(pmRoot, client);
423
+ return client;
424
+ }
425
+ /** Drop a cached client when its workspace is deleted. */
426
+ export function evictPmClient(pmRoot) {
427
+ pmClientCache.delete(pmRoot);
428
+ }
429
+ /**
430
+ * Read a workspace's parsed `settings.json` for the search-tuning resolvers.
431
+ * Returns `{}` when absent so resolvers fall back to their built-in defaults.
432
+ */
433
+ export function readPmSettings(userId, slug) {
434
+ const settingsPath = path.join(getProjectDir(userId, slug), ".agents", "pm", "settings.json");
435
+ try {
436
+ return JSON.parse(fs.readFileSync(settingsPath, "utf8"));
437
+ }
438
+ catch {
439
+ return {};
440
+ }
441
+ }
442
+ /**
443
+ * Convert a kebab-case CLI flag name to the camelCase SDK option key.
444
+ *
445
+ * `PmClient.run` accepts single-word flag names as-is but **silently ignores**
446
+ * multi-word kebab names (e.g. `dry-run`, `include-body`, `filter-status`),
447
+ * which is catastrophic for boolean guards like `--dry-run`. The SDK option
448
+ * contract is camelCase, so `--filter-deadline-before` must become
449
+ * `filterDeadlineBefore`. Single-word names pass through unchanged.
450
+ */
451
+ function kebabToCamel(flag) {
452
+ if (!flag.includes("-"))
453
+ return flag;
454
+ return flag.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase());
455
+ }
456
+ /**
457
+ * Derive boolean option arity from the SDK's canonical action-scoped tool
458
+ * schema. This keeps the adapter aligned when pm adds an option and lets string
459
+ * values begin with `--` without being mistaken for another flag.
460
+ */
461
+ function booleanOptionsByAction() {
462
+ const result = new Map();
463
+ const branches = PM_TOOL_PARAMETERS_SCHEMA.oneOf ?? [];
464
+ for (const branch of branches) {
465
+ const properties = branch.properties ?? {};
466
+ const action = properties["action"]?.const;
467
+ if (typeof action !== "string")
468
+ continue;
469
+ const keys = Object.entries(properties)
470
+ .filter(([, property]) => property.type === "boolean")
471
+ .map(([key]) => key);
472
+ result.set(action, new Set(keys));
473
+ }
474
+ return result;
475
+ }
476
+ const BOOLEAN_OPTIONS_BY_ACTION = booleanOptionsByAction();
477
+ /**
478
+ * Parse a CLI-style argv tail (without the leading `--json` injected by the
479
+ * spawn path) into the action name, a camelCase options bag, and positionals.
480
+ * `--json` is dropped: the SDK returns structured objects, never JSON text.
481
+ */
482
+ function parsePmArgs(args) {
483
+ const action = args[0] ?? "";
484
+ const booleanOptions = BOOLEAN_OPTIONS_BY_ACTION.get(action) ?? new Set();
485
+ const options = {};
486
+ const positionals = [];
487
+ for (let i = 1; i < args.length; i++) {
488
+ const arg = args[i];
489
+ if (arg === "--json")
490
+ continue;
491
+ if (arg === "--") {
492
+ while (++i < args.length)
493
+ positionals.push(args[i]);
494
+ break;
495
+ }
496
+ if (arg.startsWith("--no-")) {
497
+ options[kebabToCamel(arg.slice(5))] = false;
498
+ }
499
+ else if (arg.startsWith("--")) {
500
+ const equalsIndex = arg.indexOf("=");
501
+ const rawFlag = arg.slice(2, equalsIndex === -1 ? undefined : equalsIndex);
502
+ const key = kebabToCamel(rawFlag);
503
+ if (equalsIndex !== -1) {
504
+ options[key] = arg.slice(equalsIndex + 1);
505
+ continue;
506
+ }
507
+ if (booleanOptions.has(key)) {
508
+ options[key] = true;
509
+ continue;
510
+ }
511
+ const next = args[i + 1];
512
+ if (next !== undefined) {
513
+ options[key] = next;
514
+ i++;
515
+ }
516
+ else {
517
+ options[key] = true;
518
+ }
519
+ }
520
+ else {
521
+ positionals.push(arg);
522
+ }
523
+ }
524
+ return { action, options, positionals };
525
+ }
526
+ /** Merge mapped positionals onto the options bag for `PmClient.run`. */
527
+ function withPositionals(action, positionals, options) {
528
+ const keys = POSITIONAL_KEYS[action];
529
+ if (!keys)
530
+ return options;
531
+ const merged = { ...options };
532
+ for (let i = 0; i < keys.length && i < positionals.length; i++) {
533
+ merged[keys[i]] = positionals[i];
534
+ }
535
+ return merged;
536
+ }
537
+ /**
538
+ * Dispatch a supported action in-process through {@link PmClient}.
539
+ *
540
+ * Supported actions go through the generic `client.run(action, {options})`
541
+ * dispatcher, which accepts native and extension-contributed actions alike.
542
+ *
543
+ * The result object is returned both as `parsed` (structured) and stringified
544
+ * into `stdout`, so routes that read either field keep working.
545
+ */
546
+ async function runPmInProcess(opts, dir) {
547
+ const pmRoot = path.join(dir, ".agents", "pm");
548
+ const client = getPmClient(pmRoot);
549
+ const { action, options, positionals } = parsePmArgs(opts.args);
550
+ try {
551
+ const result = await client.run(action, {
552
+ options: withPositionals(action, positionals, options),
553
+ });
554
+ const stdout = JSON.stringify(result) ?? "";
555
+ return { stdout, stderr: "", ok: true, parsed: result };
556
+ }
557
+ catch (err) {
558
+ if ((err instanceof PmCliError || isPmCliExpectedError(err)) &&
559
+ err.message.startsWith("Unsupported native pm action:")) {
560
+ return null;
561
+ }
562
+ if (err instanceof PmCliError || isPmCliExpectedError(err)) {
563
+ return { stdout: "", stderr: err.message, ok: false, parsed: undefined, exitCode: err.exitCode };
564
+ }
565
+ return { stdout: "", stderr: err instanceof Error ? err.message : String(err), ok: false, parsed: undefined };
566
+ }
567
+ }
281
568
  export async function runPm(opts) {
282
569
  const dir = getProjectDir(opts.userId, opts.slug);
570
+ const action = opts.args[0] ?? "";
571
+ // Supported actions run in-process through the cached PmClient — no spawn.
572
+ if (!SPAWN_FALLBACK_ACTIONS.has(action) &&
573
+ opts.input === undefined &&
574
+ opts.timeoutMs === undefined) {
575
+ const sdkResult = await runPmInProcess(opts, dir);
576
+ if (sdkResult)
577
+ return sdkResult;
578
+ }
579
+ // Fallback: spawn the pm binary for actions the SDK dispatcher cannot serve.
283
580
  const args = opts.jsonOutput ? ["--json", ...opts.args] : opts.args;
284
581
  const result = await runSerialized(dir, () => runProcess(dir, args, {
285
582
  input: opts.input,
286
583
  timeoutMs: opts.timeoutMs,
287
584
  env: { PM_GRAPH_PROJECT_KEY: `${opts.userId}:${opts.slug}` },
288
585
  }));
289
- const { stdout, stderr, ok } = result;
586
+ const { stdout, stderr, ok, exitCode } = result;
290
587
  let parsed;
291
588
  if (opts.jsonOutput && ok && stdout) {
292
589
  try {
@@ -296,7 +593,7 @@ export async function runPm(opts) {
296
593
  parsed = { raw: stdout };
297
594
  }
298
595
  }
299
- return { stdout, stderr, ok, parsed };
596
+ return { stdout, stderr, ok, parsed, exitCode };
300
597
  }
301
598
  /**
302
599
  * Reconstruct a single item at a one-based version or ISO timestamp using the
@@ -317,6 +614,7 @@ export async function runGetItemAt(userId, slug, itemId, ref) {
317
614
  }
318
615
  export function deleteProjectDir(userId, slug) {
319
616
  const dir = getProjectDir(userId, slug);
617
+ evictPmClient(path.join(dir, ".agents", "pm"));
320
618
  if (fs.existsSync(dir)) {
321
619
  fs.rmSync(dir, { recursive: true, force: true });
322
620
  }