@agentworkforce/cli 0.2.1 → 0.4.0

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.
package/dist/cli.js CHANGED
@@ -1,12 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn, spawnSync } from 'node:child_process';
3
3
  import { randomBytes } from 'node:crypto';
4
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
4
5
  import { constants, homedir } from 'node:os';
5
- import { join, resolve as resolvePath } from 'node:path';
6
+ import { dirname, isAbsolute, join, resolve as resolvePath } from 'node:path';
6
7
  import { pathToFileURL } from 'node:url';
7
8
  import { HARNESS_VALUES, PERSONA_TAGS, PERSONA_TIERS, personaCatalog, routingProfiles, useSelection } from '@agentworkforce/workload-router';
8
9
  import { buildInteractiveSpec, detectHarnesses, formatDropWarnings, resolveMcpServersLenient, resolveStringMapLenient } from '@agentworkforce/harness-kit';
9
10
  import { launchOnMount } from '@relayfile/local-mount';
11
+ import ora from 'ora';
10
12
  import { loadLocalPersonas } from './local-personas.js';
11
13
  const USAGE = `Usage: agent-workforce <command> [args...]
12
14
 
@@ -19,19 +21,28 @@ Commands:
19
21
 
20
22
  Flags:
21
23
  --install-in-repo Install skills into the repo's
22
- .claude/skills/ (legacy). By default,
24
+ harness-conventional directory
25
+ (.claude/skills, .opencode/skills,
26
+ .agents/skills, etc.). By default,
23
27
  interactive claude sessions stage
24
28
  skills under ~/.agent-workforce/
25
- sessions/<id>/ and pass --plugin-dir
26
- so the repo is never touched.
29
+ sessions/<id>/ and pass --plugin-dir;
30
+ interactive opencode sessions run
31
+ inside a @relayfile/local-mount
32
+ sandbox so npx prpm install / npx
33
+ skills add writes never touch the
34
+ real repo.
27
35
  --clean Launch interactive claude inside a
28
36
  @relayfile/local-mount sandbox that
29
- hides the repo's CLAUDE.md,
37
+ also hides the repo's CLAUDE.md,
30
38
  CLAUDE.local.md, .claude, and
31
39
  .mcp.json from the session. Persona
32
40
  skills and keychain auth are
33
- preserved. Claude interactive only;
34
- incompatible with --install-in-repo.
41
+ preserved. No-op for opencode
42
+ (mount is already on by default);
43
+ codex still warns and proceeds
44
+ without a mount. Incompatible
45
+ with --install-in-repo.
35
46
  list [flags] List available personas from the cascade (pwd → home →
36
47
  library). By default shows one row per persona at the
37
48
  recommended tier for its intent; pass --all to see every
@@ -46,6 +57,14 @@ Commands:
46
57
  --filter-tag <tag> only show personas carrying this tag
47
58
  (${PERSONA_TAGS.join(' | ')})
48
59
  --no-display-description hide the DESCRIPTION column
60
+ show <persona>[@<tier>]
61
+ Print the fully-resolved spec for a single persona,
62
+ including which cascade layer defined it (pwd, home,
63
+ library). By default shows only the recommended tier for
64
+ the persona's intent; pass @<tier> to pick one, or --all
65
+ to see every tier. Flags:
66
+ --all include every tier (overrides default)
67
+ --json emit the resolved PersonaSpec as JSON
49
68
  harness check Probe which harnesses (claude, codex, opencode) are
50
69
  installed and runnable on this machine.
51
70
 
@@ -60,6 +79,7 @@ Examples:
60
79
  agent-workforce agent my-posthog@best
61
80
  agent-workforce agent review@best-value "look at the diff on this branch"
62
81
  agent-workforce list
82
+ agent-workforce show posthog
63
83
  agent-workforce harness check
64
84
  `;
65
85
  function die(msg, withUsage = true) {
@@ -108,11 +128,30 @@ function parseSelector(sel) {
108
128
  const kind = local.byId.has(key) ? 'local' : 'repo';
109
129
  return { kind, spec: result, tier };
110
130
  }
131
+ /**
132
+ * Resolve the `<harness>` placeholder used in persona systemPrompts.
133
+ * Personas embed `<harness>` inside example install commands (e.g.
134
+ * `npx prpm install <ref> --as <harness>`) so the docstring stays
135
+ * harness-agnostic in source. The active harness is known at selection
136
+ * time, so swap it in here to give the model a concrete command.
137
+ *
138
+ * Exported for test coverage. Only `<harness>` is resolved today; other
139
+ * angle-bracketed tokens in the prompt (e.g. `<ref>`, `<repo-url>`,
140
+ * `<query>`) are deliberately left as LLM-facing placeholders.
141
+ */
142
+ export function resolveSystemPromptPlaceholders(prompt, harness) {
143
+ return prompt.replaceAll('<harness>', harness);
144
+ }
111
145
  function buildSelection(spec, tier, kind) {
146
+ const rawRuntime = spec.tiers[tier];
147
+ const runtime = {
148
+ ...rawRuntime,
149
+ systemPrompt: resolveSystemPromptPlaceholders(rawRuntime.systemPrompt, rawRuntime.harness)
150
+ };
112
151
  return {
113
152
  personaId: spec.id,
114
153
  tier,
115
- runtime: spec.tiers[tier],
154
+ runtime,
116
155
  skills: spec.skills,
117
156
  rationale: kind === 'local' ? `local-override: ${spec.id}` : `cli-tier-override: ${tier}`,
118
157
  ...(spec.env ? { env: spec.env } : {}),
@@ -159,18 +198,61 @@ function signalExitCode(signal) {
159
198
  const num = constants.signals[signal];
160
199
  return 128 + (num ?? 1);
161
200
  }
162
- function runInstall(command, label) {
201
+ /**
202
+ * Derive a meaningful exit code from a `spawnSync` result. `spawnSync`
203
+ * sets `status` to null and `signal` to the signal name (e.g. `SIGINT`)
204
+ * when the child was killed before it could set its own exit code, so
205
+ * a naive `res.status ?? 1` collapses Ctrl-C / SIGTERM onto generic
206
+ * failure instead of the conventional 128+N.
207
+ */
208
+ function subprocessExitCode(res) {
209
+ if (res.status !== null)
210
+ return res.status;
211
+ if (res.signal)
212
+ return signalExitCode(res.signal);
213
+ return 1;
214
+ }
215
+ function runInstall(command, label, cwd) {
163
216
  const [bin, ...args] = command;
164
217
  if (!bin)
165
218
  return;
166
219
  process.stderr.write(`• ${label}\n`);
167
- const res = spawnSync(bin, args, { stdio: 'inherit', shell: false });
168
- if (res.status !== 0) {
169
- const code = res.status ?? 1;
220
+ const res = spawnSync(bin, args, { stdio: 'inherit', shell: false, ...(cwd ? { cwd } : {}) });
221
+ const code = subprocessExitCode(res);
222
+ if (code !== 0) {
170
223
  process.stderr.write(`${label} failed (exit ${code}). Aborting.\n`);
171
224
  process.exit(code);
172
225
  }
173
226
  }
227
+ /**
228
+ * Thrown by `runInstallOrThrow` when the install subprocess exits non-zero.
229
+ * Carries the underlying exit code so the mount-branch catch can surface it
230
+ * to the user instead of collapsing every failure onto 127.
231
+ */
232
+ class InstallCommandError extends Error {
233
+ exitCode;
234
+ constructor(label, exitCode) {
235
+ super(`${label} failed (exit ${exitCode})`);
236
+ this.name = 'InstallCommandError';
237
+ this.exitCode = exitCode;
238
+ }
239
+ }
240
+ /**
241
+ * Install variant that throws instead of calling `process.exit` on failure.
242
+ * Used inside `launchOnMount`'s `onBeforeLaunch`, so mount teardown runs
243
+ * before the error surfaces.
244
+ */
245
+ function runInstallOrThrow(command, label, cwd) {
246
+ const [bin, ...args] = command;
247
+ if (!bin)
248
+ return;
249
+ process.stderr.write(`• ${label}\n`);
250
+ const res = spawnSync(bin, args, { stdio: 'inherit', shell: false, cwd });
251
+ const code = subprocessExitCode(res);
252
+ if (code !== 0) {
253
+ throw new InstallCommandError(label, code);
254
+ }
255
+ }
174
256
  function runCleanup(command, commandString) {
175
257
  if (commandString === ':')
176
258
  return;
@@ -185,11 +267,19 @@ function runCleanup(command, commandString) {
185
267
  * created. The workload-router cleanup only covers the install subtree
186
268
  * (`<root>/claude/plugin`), so without this step empty parent dirs
187
269
  * would accumulate under `~/.agent-workforce/sessions/`.
270
+ *
271
+ * Uses `fs.rmSync` rather than `spawnSync('rm', …)` so the teardown works
272
+ * on Windows where `rm` isn't on PATH.
188
273
  */
189
274
  function removeSessionRoot(sessionRoot) {
190
275
  if (!sessionRoot)
191
276
  return;
192
- spawnSync('rm', ['-rf', sessionRoot], { stdio: 'ignore', shell: false });
277
+ try {
278
+ rmSync(sessionRoot, { recursive: true, force: true });
279
+ }
280
+ catch {
281
+ /* best-effort — if teardown fails the dir is harmless under ~/.agent-workforce/sessions */
282
+ }
193
283
  }
194
284
  /**
195
285
  * Compute the absolute root directory for an interactive claude session.
@@ -213,6 +303,49 @@ function sessionInstallRoot(sessionRoot) {
213
303
  function sessionMountDir(sessionRoot) {
214
304
  return join(sessionRoot, 'mount');
215
305
  }
306
+ /**
307
+ * Remove every `--agent <id>` pair from a harness argv. Used on the non-mount
308
+ * opencode path where we cannot safely materialize the persona's
309
+ * opencode.json (it would land in the user's real repo), so we fall back to
310
+ * launching opencode without a persona-specific agent selection.
311
+ *
312
+ * Strips all occurrences rather than just the first — the current producer
313
+ * (harness-kit's opencode branch) emits exactly one pair, so both behaviors
314
+ * are equivalent today, but "remove all" is idempotent and safer if a future
315
+ * caller ever appends a second `--agent` for any reason. A trailing `--agent`
316
+ * with no following value is preserved so the malformed argv surfaces at the
317
+ * harness rather than getting silently swallowed here.
318
+ */
319
+ export function stripAgentFlag(args) {
320
+ const out = [];
321
+ for (let i = 0; i < args.length; i += 1) {
322
+ if (args[i] === '--agent' && i + 1 < args.length) {
323
+ i += 1;
324
+ continue;
325
+ }
326
+ out.push(args[i]);
327
+ }
328
+ return out;
329
+ }
330
+ /**
331
+ * Validate that a configFile's relative path is safe to resolve under a
332
+ * sandbox/session directory. Rejects absolute paths and any segment equal to
333
+ * `..` so a malformed or adversarial persona cannot escape the mount via
334
+ * `join()` and overwrite files elsewhere. Called at materialization time so
335
+ * the failure surfaces with a clear path before any disk write happens.
336
+ */
337
+ export function assertSafeRelativePath(relPath) {
338
+ if (!relPath) {
339
+ throw new Error('configFile path must be a non-empty relative path');
340
+ }
341
+ if (isAbsolute(relPath)) {
342
+ throw new Error(`configFile path must be relative; got absolute path ${JSON.stringify(relPath)}`);
343
+ }
344
+ const segments = relPath.split(/[\\/]+/);
345
+ if (segments.some((s) => s === '..')) {
346
+ throw new Error(`configFile path must not contain ".." segments; got ${JSON.stringify(relPath)}`);
347
+ }
348
+ }
216
349
  /** Patterns hidden from an interactive claude session when `--clean` is set.
217
350
  * Applied by `@relayfile/local-mount` with gitignore semantics, so bare names
218
351
  * match at any depth in the project tree (e.g. `.claude` hides both
@@ -224,38 +357,84 @@ export const CLEAN_IGNORED_PATTERNS = [
224
357
  '.mcp.json'
225
358
  ];
226
359
  /**
227
- * Decide whether `--clean` should engage for this run. Returns a warning
228
- * string when the flag was requested but cannot apply (e.g. non-claude
229
- * harness); callers emit the warning to stderr and proceed with
230
- * `useClean: false`. Pure no side effects, trivially testable.
360
+ * Skill-install artifacts that should never be copied into the mount nor
361
+ * synced back to the real repo. Applied to non-claude interactive sessions
362
+ * that rely on the mount to keep `npx skills add` / `npx prpm install`
363
+ * writes out of the user's project tree. Covers every per-provider output
364
+ * root that skill.sh / prpm scatter into on install — missing one here
365
+ * re-introduces repo pollution, so this list is deliberately superset-y.
366
+ * Claude sessions use `installRoot` for out-of-repo staging instead, so
367
+ * these patterns don't apply there.
368
+ */
369
+ export const SKILL_INSTALL_IGNORED_PATTERNS = [
370
+ // skill.sh universal install root + per-harness symlink farms
371
+ '.agents',
372
+ '.claude/skills',
373
+ '.factory/skills',
374
+ '.kiro/skills',
375
+ 'skills',
376
+ // prpm `--as <harness>` output roots
377
+ '.opencode',
378
+ '.skills',
379
+ // provider lockfiles written at the repo root
380
+ 'prpm.lock',
381
+ 'skills-lock.json'
382
+ ];
383
+ /**
384
+ * Decide whether to run the interactive session inside a
385
+ * `@relayfile/local-mount` sandbox.
386
+ *
387
+ * - Claude: mount only engages when the user passes `--clean` explicitly
388
+ * (its purpose there is to hide CLAUDE.md / .claude / .mcp.json from the
389
+ * session). Out-of-repo skill staging is handled separately via
390
+ * `installRoot` + `--plugin-dir`.
391
+ * - Opencode: the SDK cannot stage skills out-of-repo for this harness
392
+ * (there is no `installRoot` support), so the mount is the only way to
393
+ * keep `npx prpm install` / `npx skills add` writes out of the project.
394
+ * Default to mount unless the user opts in with `--install-in-repo`.
395
+ * - Codex: no auto-mount yet. `--clean` still emits the existing
396
+ * "claude-only" warning so current behavior is preserved.
397
+ *
398
+ * Pure — no side effects, trivially testable.
231
399
  */
232
- export function decideCleanMode(harness, clean) {
233
- if (!clean)
234
- return { useClean: false };
235
- if (harness !== 'claude') {
400
+ export function decideCleanMode(harness, clean, installInRepo = false) {
401
+ if (harness === 'claude') {
402
+ return { useClean: clean };
403
+ }
404
+ if (harness === 'opencode') {
405
+ if (installInRepo)
406
+ return { useClean: false };
407
+ return { useClean: true };
408
+ }
409
+ if (clean) {
236
410
  return {
237
411
  useClean: false,
238
412
  warning: `--clean is only supported for the claude harness (this session uses ${harness}). Ignoring flag.`
239
413
  };
240
414
  }
241
- return { useClean: true };
415
+ return { useClean: false };
242
416
  }
243
417
  async function runInteractive(selection, options = {}) {
244
418
  const { runtime, personaId, tier } = selection;
245
- // Out-of-repo staging only applies to the claude harness today (it's the
246
- // only one with a `--plugin-dir` hook). For codex/opencode, keep the legacy
247
- // behavior of installing into the repo's harness-conventional directory.
248
- // The --install-in-repo flag forces legacy behavior across the board.
249
- const useSessionDir = !options.installInRepo && runtime.harness === 'claude';
250
- const sessionRoot = useSessionDir ? generateSessionRoot(personaId) : undefined;
251
- const installRoot = sessionRoot ? sessionInstallRoot(sessionRoot) : undefined;
252
- // --clean applies to interactive claude only. For non-claude harnesses, warn
253
- // and drop the flag (same pattern as pluginDirs warnings in buildInteractiveSpec).
254
- const cleanDecision = decideCleanMode(runtime.harness, options.clean === true);
419
+ // `installRoot` (out-of-repo skill staging via `--plugin-dir`) is currently
420
+ // claude-only; the workload-router SDK throws if it's set for other
421
+ // harnesses. For opencode, we instead keep installs out of the repo by
422
+ // running them inside a @relayfile/local-mount sandbox (see `useClean`
423
+ // below). The --install-in-repo flag forces legacy in-repo installs
424
+ // across the board.
425
+ const cleanDecision = decideCleanMode(runtime.harness, options.clean === true, options.installInRepo === true);
255
426
  if (cleanDecision.warning) {
256
427
  process.stderr.write(`warning: ${cleanDecision.warning}\n`);
257
428
  }
258
429
  const useClean = cleanDecision.useClean;
430
+ // A session dir is needed whenever we either (a) stage skills out-of-repo
431
+ // via claude's installRoot, or (b) open a mount. Opencode reaches (b) by
432
+ // default; claude reaches both when --clean is set, and just (a) otherwise.
433
+ const useSessionDir = !options.installInRepo && (runtime.harness === 'claude' || useClean);
434
+ const sessionRoot = useSessionDir ? generateSessionRoot(personaId) : undefined;
435
+ const installRoot = sessionRoot && runtime.harness === 'claude'
436
+ ? sessionInstallRoot(sessionRoot)
437
+ : undefined;
259
438
  const ctx = useSelection(selection, installRoot !== undefined ? { installRoot } : {});
260
439
  const { install } = ctx;
261
440
  process.stderr.write(`→ ${personaId} [${tier}] via ${runtime.harness} (${runtime.model})\n`);
@@ -268,16 +447,21 @@ async function runInteractive(selection, options = {}) {
268
447
  // the plugin scaffold (mkdir + manifest + symlink) so `--plugin-dir` has a
269
448
  // valid target even for skill-less personas like posthog. Gate on the
270
449
  // command string rather than `installs.length` so we don't skip that.
271
- if (install.commandString !== ':') {
272
- const skillIds = install.plan.installs.map((i) => i.skillId).join(', ');
273
- const targetLabel = installRoot ? ` → ${installRoot}` : '';
274
- const label = install.plan.installs.length === 0
275
- ? `Staging session plugin dir${targetLabel}`
276
- : `Installing skills: ${skillIds}${targetLabel}`;
277
- runInstall(install.command, label);
450
+ const skillIds = install.plan.installs.map((i) => i.skillId).join(', ');
451
+ const installLabel = install.plan.installs.length === 0
452
+ ? `Staging session plugin dir${installRoot ? ` → ${installRoot}` : ''}`
453
+ : `Installing skills: ${skillIds}${installRoot ? ` → ${installRoot}` : ''}`;
454
+ // When useClean engages on a non-claude harness, the install must run
455
+ // INSIDE the mount so `.opencode/skills/`, `.agents/skills/`, prpm.lock,
456
+ // etc. land in the sandbox rather than the real repo. We defer it to
457
+ // `onBeforeLaunch` below instead of pre-running here.
458
+ const deferInstallToMount = useClean && runtime.harness !== 'claude' && install.commandString !== ':';
459
+ if (install.commandString !== ':' && !deferInstallToMount) {
460
+ runInstall(install.command, installLabel);
278
461
  }
279
462
  const spec = buildInteractiveSpec({
280
463
  harness: runtime.harness,
464
+ personaId,
281
465
  model: runtime.model,
282
466
  systemPrompt: runtime.systemPrompt,
283
467
  mcpServers: resolvedMcp,
@@ -286,7 +470,26 @@ async function runInteractive(selection, options = {}) {
286
470
  });
287
471
  for (const w of spec.warnings)
288
472
  process.stderr.write(`warning: ${w}\n`);
289
- const finalArgs = spec.initialPrompt ? [...spec.args, spec.initialPrompt] : [...spec.args];
473
+ // Config-file materialization strategy:
474
+ // - Mount path (opencode default, claude --clean): write each configFile
475
+ // into the mount dir via onBeforeLaunch, so it lives only in the
476
+ // sandbox and is torn down with the session.
477
+ // - Non-mount path: today the only configFile producer is opencode
478
+ // (opencode.json for the --agent wiring), and the non-mount opencode
479
+ // path only engages under --install-in-repo. Writing opencode.json
480
+ // into the user's real repo would pollute the working tree, so we
481
+ // degrade: drop --agent from the argv, warn, and launch opencode with
482
+ // its default agent. The persona's prompt will not be applied in that
483
+ // mode; users who want it should drop --install-in-repo (the mount
484
+ // default handles this cleanly).
485
+ const hasConfigFiles = spec.configFiles.length > 0;
486
+ const degradeConfigFiles = hasConfigFiles && !useClean;
487
+ let effectiveArgs = spec.args;
488
+ if (degradeConfigFiles) {
489
+ process.stderr.write('warning: --install-in-repo cannot safely materialize the persona agent config (would write opencode.json into your repo); launching without --agent. Drop --install-in-repo to apply the persona prompt.\n');
490
+ effectiveArgs = stripAgentFlag(spec.args);
491
+ }
492
+ const finalArgs = spec.initialPrompt ? [...effectiveArgs, spec.initialPrompt] : [...effectiveArgs];
290
493
  // Print a sanitized summary rather than raw argv: spec.args for the claude
291
494
  // harness contains the resolved --mcp-config JSON and the full system
292
495
  // prompt, either of which can carry secrets (Bearer tokens, API keys) once
@@ -310,42 +513,194 @@ async function runInteractive(selection, options = {}) {
310
513
  if (spec.initialPrompt)
311
514
  summary.push('initial-prompt=<systemPrompt>');
312
515
  if (useClean)
313
- summary.push('clean=on');
516
+ summary.push('mount=on');
314
517
  process.stderr.write(`• spawning ${spec.bin} (${summary.join(', ')})\n`);
315
- // --clean branch: delegate process lifecycle (spawn, signal forwarding,
316
- // syncback, cleanup) to @relayfile/local-mount. Skill plugin dir lives
317
- // outside the mount, so claude resolves --plugin-dir normally regardless
318
- // of cwd.
518
+ // Mount branch: delegate process lifecycle (spawn, signal forwarding,
519
+ // syncback, cleanup) to @relayfile/local-mount.
520
+ //
521
+ // For claude: mount engages only when `--clean` is set, to hide the repo's
522
+ // claude config from the session. Skill plugin dir lives outside the mount
523
+ // at an absolute path, so claude resolves `--plugin-dir` normally.
524
+ //
525
+ // For opencode: mount engages by default (unless `--install-in-repo`), and
526
+ // the install itself runs inside the mount via `onBeforeLaunch` so that
527
+ // `npx prpm install` / `npx skills add` writes land in the sandbox. The
528
+ // skill-install paths are added to `ignoredPatterns` so they are neither
529
+ // copied in from the real repo nor synced back on exit.
319
530
  if (useClean && sessionRoot) {
320
531
  const mountDir = sessionMountDir(sessionRoot);
321
- process.stderr.write(`• clean mount ${mountDir}\n`);
532
+ const ignoredPatterns = runtime.harness === 'claude'
533
+ ? [...CLEAN_IGNORED_PATTERNS]
534
+ : [...SKILL_INSTALL_IGNORED_PATTERNS];
535
+ // Anything we materialize into the mount via onBeforeLaunch must be
536
+ // hidden from the mount-mirror in both directions: without this, any
537
+ // opencode.json already present in the real repo would be copied into
538
+ // the mount (masking the per-session agent config we write), and the
539
+ // fresh write from onBeforeLaunch would sync back out on exit and
540
+ // pollute the user's working tree. Added dynamically so this stays
541
+ // generic for any future configFile producer.
542
+ for (const file of spec.configFiles) {
543
+ ignoredPatterns.push(file.path);
544
+ }
545
+ process.stderr.write(`• sandbox mount → ${mountDir}\n`);
546
+ // Three-stage SIGINT handler layered on top of launchOnMount's own signal
547
+ // forwarding. launchOnMount catches the first SIGINT to kill the child
548
+ // and run its finalize() (autoSync.stop + final syncBack), which walks
549
+ // both trees and can take several seconds on a large repo — during
550
+ // which the terminal otherwise looks frozen.
551
+ //
552
+ // 1st press → start an ora spinner so the pause is visibly live
553
+ // (replaces the prior static print). onAfterSync below
554
+ // transitions the spinner into a succeed/fail state once
555
+ // relayfile reports the sync result.
556
+ // 2nd press → update the spinner text to the "aborting" warning and
557
+ // abort `shutdownSignal`, which local-mount 0.5+ respects
558
+ // by skipping autosync's draining reconcile and returning
559
+ // the partial count from the final syncBack. Cleanup
560
+ // still runs, so no leaked mount dir.
561
+ // 3rd press → hard escape: synchronously rm the mount root and
562
+ // process.exit(130) in case the abort never resolves.
563
+ const shutdownController = new AbortController();
564
+ let sigintCount = 0;
565
+ let syncSpinner;
566
+ const forceExitHandler = () => {
567
+ sigintCount += 1;
568
+ if (sigintCount === 1) {
569
+ syncSpinner = ora({
570
+ text: 'Syncing session changes back to the repo… (Ctrl-C again to skip)',
571
+ stream: process.stderr
572
+ }).start();
573
+ return;
574
+ }
575
+ if (sigintCount === 2) {
576
+ if (syncSpinner) {
577
+ syncSpinner.text =
578
+ 'Aborting sync — partial changes will be propagated. (Ctrl-C again to force quit)';
579
+ }
580
+ shutdownController.abort();
581
+ return;
582
+ }
583
+ if (syncSpinner) {
584
+ syncSpinner.fail('Force-quit: mount teardown skipped. Session dir may be left behind.');
585
+ syncSpinner = undefined;
586
+ }
587
+ else {
588
+ process.stderr.write('\n✗ Force-quit: mount teardown skipped. Session dir may be left behind.\n');
589
+ }
590
+ // Node-native removal rather than `rm -rf` so the emergency path
591
+ // works on Windows too.
592
+ try {
593
+ rmSync(sessionRoot, { recursive: true, force: true });
594
+ }
595
+ catch {
596
+ /* swallow — we're exiting anyway */
597
+ }
598
+ process.exit(130);
599
+ };
600
+ process.on('SIGINT', forceExitHandler);
322
601
  try {
323
602
  const result = await launchOnMount({
324
603
  cli: spec.bin,
325
604
  projectDir: process.cwd(),
326
605
  mountDir,
327
606
  args: finalArgs,
328
- ignoredPatterns: [...CLEAN_IGNORED_PATTERNS],
607
+ ignoredPatterns,
329
608
  // launchOnMount passes `env` straight to the child spawn, so without
330
609
  // merging process.env we'd strip PATH/HOME/etc. Match the non-clean
331
610
  // branch: persona env overlays the inherited environment.
332
611
  env: resolvedEnv ? { ...process.env, ...resolvedEnv } : process.env,
333
- agentName: personaId
612
+ agentName: personaId,
613
+ // Second Ctrl-C aborts this signal → local-mount skips autosync's
614
+ // draining reconcile and returns the partial syncBack count. Cleanup
615
+ // still runs, so there's no leaked mount dir.
616
+ shutdownSignal: shutdownController.signal,
617
+ // Report sync stats so the user sees confirmation rather than a
618
+ // silent pause between the child exiting and the CLI returning.
619
+ //
620
+ // NOTE: `count` is bidirectional per relayfile's onAfterSync
621
+ // contract (see @relayfile/local-mount launch.d.ts) — it sums
622
+ // autosync activity in *both* directions (inbound project→mount
623
+ // and outbound mount→project, including deletes) plus the final
624
+ // mount→project syncBack. Phrasing this as "synced back to the
625
+ // repo" earlier misled sessions where inbound events dominated:
626
+ // a user who did no edits still saw "Synced 15 changes back"
627
+ // because ambient initial-mirror traffic counted. Phrase as "file
628
+ // events during session" so we don't overclaim direction.
629
+ onAfterSync: (count) => {
630
+ const aborted = shutdownController.signal.aborted;
631
+ const qualifier = aborted ? ' (partial)' : '';
632
+ const message = count > 0
633
+ ? `Session complete — ${count} file event${count === 1 ? '' : 's'} during session${qualifier}.`
634
+ : 'Session complete — no file events.';
635
+ if (syncSpinner) {
636
+ syncSpinner.succeed(message);
637
+ syncSpinner = undefined;
638
+ }
639
+ else {
640
+ process.stderr.write(`✓ ${message}\n`);
641
+ }
642
+ },
643
+ ...(deferInstallToMount || hasConfigFiles
644
+ ? {
645
+ onBeforeLaunch: (dir) => {
646
+ if (deferInstallToMount) {
647
+ runInstallOrThrow(install.command, installLabel, dir);
648
+ }
649
+ for (const file of spec.configFiles) {
650
+ assertSafeRelativePath(file.path);
651
+ const target = join(dir, file.path);
652
+ // mkdir -p for any subdirs in file.path — the
653
+ // InteractiveConfigFile contract allows nested relative
654
+ // paths, and writeFileSync would otherwise throw ENOENT.
655
+ mkdirSync(dirname(target), { recursive: true });
656
+ writeFileSync(target, file.contents, 'utf8');
657
+ }
658
+ }
659
+ }
660
+ : {})
334
661
  });
335
662
  return result.exitCode;
336
663
  }
337
664
  catch (err) {
665
+ // If the spinner is still live when we error out, mark it failed so
666
+ // the pending animation doesn't hang around under the error message.
667
+ if (syncSpinner) {
668
+ syncSpinner.fail('Sync did not complete');
669
+ syncSpinner = undefined;
670
+ }
671
+ // InstallCommandError carries the real install exit code — surfacing
672
+ // it (rather than collapsing onto 127) lets callers distinguish a
673
+ // failed `npx prpm install` from a missing harness binary.
674
+ if (err instanceof InstallCommandError) {
675
+ process.stderr.write(`${err.message}. Aborting.\n`);
676
+ return err.exitCode;
677
+ }
338
678
  const e = err;
339
679
  if (e.code === 'ENOENT') {
340
- process.stderr.write(`Failed to spawn "${spec.bin}" inside clean mount: binary not found on PATH. Install the ${runtime.harness} CLI and retry.\n`);
341
- }
342
- else {
343
- process.stderr.write(`Failed to launch clean mount: ${e.message}\n`);
680
+ process.stderr.write(`Failed to spawn "${spec.bin}" inside sandbox mount: binary not found on PATH. Install the ${runtime.harness} CLI and retry.\n`);
681
+ return 127;
344
682
  }
345
- return 127;
683
+ process.stderr.write(`Failed to launch sandbox mount: ${e.message}\n`);
684
+ return 1;
346
685
  }
347
686
  finally {
348
- runCleanup(install.cleanupCommand, install.cleanupCommandString);
687
+ // Defensive: if neither onAfterSync nor the catch branch stopped the
688
+ // spinner (e.g. unexpected exit path), stop it cleanly here so the
689
+ // terminal is not left in spinner state.
690
+ if (syncSpinner) {
691
+ syncSpinner.stop();
692
+ syncSpinner = undefined;
693
+ }
694
+ process.removeListener('SIGINT', forceExitHandler);
695
+ // When the install ran inside the mount, its cleanup paths are
696
+ // mount-relative (e.g. `.skills/<name>`, `skills/<name>`) and
697
+ // running cleanup here would resolve them against the real repo
698
+ // cwd — potentially `rm -rf`ing pre-existing user content. The
699
+ // mount dir is removed wholesale by `removeSessionRoot` below, so
700
+ // the install's cleanup is redundant anyway in that case.
701
+ if (!deferInstallToMount) {
702
+ runCleanup(install.cleanupCommand, install.cleanupCommandString);
703
+ }
349
704
  removeSessionRoot(sessionRoot);
350
705
  }
351
706
  }
@@ -573,6 +928,192 @@ function runList(args) {
573
928
  }
574
929
  process.exit(0);
575
930
  }
931
+ function parseShowArgs(args) {
932
+ let json = false;
933
+ let all = false;
934
+ let selector;
935
+ for (const arg of args) {
936
+ if (arg === '--json') {
937
+ json = true;
938
+ }
939
+ else if (arg === '--all') {
940
+ all = true;
941
+ }
942
+ else if (arg === '-h' || arg === '--help') {
943
+ process.stdout.write('Usage: agent-workforce show <persona>[@<tier>] [--all] [--json]\n');
944
+ process.exit(0);
945
+ }
946
+ else if (arg.startsWith('--')) {
947
+ die(`show: unexpected flag "${arg}".`);
948
+ }
949
+ else if (selector === undefined) {
950
+ selector = arg;
951
+ }
952
+ else {
953
+ die(`show: unexpected argument "${arg}".`);
954
+ }
955
+ }
956
+ if (!selector)
957
+ die('show: missing persona name.');
958
+ return { selector, json, all };
959
+ }
960
+ function resolveShowTarget(selector, all) {
961
+ const at = selector.indexOf('@');
962
+ const key = at === -1 ? selector : selector.slice(0, at);
963
+ const tierRaw = at === -1 ? undefined : selector.slice(at + 1);
964
+ if (!key)
965
+ die('show: missing persona name before "@".');
966
+ let explicitTier;
967
+ if (tierRaw !== undefined) {
968
+ if (!PERSONA_TIERS.includes(tierRaw)) {
969
+ die(`show: invalid tier "${tierRaw}". Must be one of: ${PERSONA_TIERS.join(', ')}`);
970
+ }
971
+ explicitTier = tierRaw;
972
+ if (all) {
973
+ die('show: --all cannot be combined with an explicit @<tier> suffix.');
974
+ }
975
+ }
976
+ const localSpec = local.byId.get(key);
977
+ let spec;
978
+ let source = 'library';
979
+ if (localSpec) {
980
+ spec = localSpec;
981
+ source = local.sources.get(key) ?? 'pwd';
982
+ }
983
+ else {
984
+ const byIntent = personaCatalog[key];
985
+ if (byIntent) {
986
+ spec = byIntent;
987
+ }
988
+ else {
989
+ const byId = Object.values(personaCatalog).find((p) => p.id === key);
990
+ if (byId)
991
+ spec = byId;
992
+ }
993
+ }
994
+ if (!spec) {
995
+ const result = resolveSpec(key);
996
+ if ('error' in result)
997
+ die(result.error, false);
998
+ spec = result;
999
+ }
1000
+ let tiers;
1001
+ if (all) {
1002
+ tiers = [...PERSONA_TIERS];
1003
+ }
1004
+ else if (explicitTier) {
1005
+ tiers = [explicitTier];
1006
+ }
1007
+ else {
1008
+ const rule = routingProfiles.default.intents[spec.intent];
1009
+ tiers = [rule?.tier ?? 'best-value'];
1010
+ }
1011
+ return { spec, source, tiers, explicitTier };
1012
+ }
1013
+ function indent(text, prefix) {
1014
+ return text
1015
+ .split('\n')
1016
+ .map((line) => (line.length > 0 ? prefix + line : line))
1017
+ .join('\n');
1018
+ }
1019
+ function formatPersonaShow(spec, source, tiers, tierNote) {
1020
+ const lines = [];
1021
+ lines.push(`PERSONA ${spec.id}`);
1022
+ lines.push(`SOURCE ${source}`);
1023
+ lines.push(`INTENT ${spec.intent}`);
1024
+ lines.push(`TAGS ${spec.tags.length ? spec.tags.join(', ') : '(none)'}`);
1025
+ lines.push(`DESCRIPTION ${spec.description}`);
1026
+ lines.push(`TIERS SHOWN ${tiers.join(', ')}${tierNote ? ` (${tierNote})` : ''}`);
1027
+ lines.push('');
1028
+ lines.push('SKILLS');
1029
+ if (spec.skills.length === 0) {
1030
+ lines.push(' (none)');
1031
+ }
1032
+ else {
1033
+ for (const s of spec.skills) {
1034
+ lines.push(` - ${s.id}`);
1035
+ lines.push(` source: ${s.source}`);
1036
+ lines.push(` description: ${s.description}`);
1037
+ }
1038
+ }
1039
+ lines.push('');
1040
+ lines.push('MCP SERVERS');
1041
+ const servers = Object.entries(spec.mcpServers ?? {});
1042
+ if (servers.length === 0) {
1043
+ lines.push(' (none)');
1044
+ }
1045
+ else {
1046
+ for (const [name, server] of servers) {
1047
+ lines.push(` - ${name} (${server.type})`);
1048
+ if (server.type === 'stdio') {
1049
+ lines.push(` command: ${server.command}${server.args?.length ? ' ' + server.args.join(' ') : ''}`);
1050
+ if (server.env && Object.keys(server.env).length > 0) {
1051
+ lines.push(` env: ${Object.keys(server.env).join(', ')}`);
1052
+ }
1053
+ }
1054
+ else {
1055
+ lines.push(` url: ${server.url}`);
1056
+ if (server.headers && Object.keys(server.headers).length > 0) {
1057
+ lines.push(` headers: ${Object.keys(server.headers).join(', ')}`);
1058
+ }
1059
+ }
1060
+ }
1061
+ }
1062
+ lines.push('');
1063
+ lines.push('PERMISSIONS');
1064
+ const perms = spec.permissions;
1065
+ if (!perms || (!perms.allow?.length && !perms.deny?.length && !perms.mode)) {
1066
+ lines.push(' (none)');
1067
+ }
1068
+ else {
1069
+ if (perms.mode)
1070
+ lines.push(` mode: ${perms.mode}`);
1071
+ if (perms.allow?.length)
1072
+ lines.push(` allow: ${perms.allow.join(', ')}`);
1073
+ if (perms.deny?.length)
1074
+ lines.push(` deny: ${perms.deny.join(', ')}`);
1075
+ }
1076
+ lines.push('');
1077
+ lines.push('ENV');
1078
+ const envKeys = Object.keys(spec.env ?? {});
1079
+ if (envKeys.length === 0) {
1080
+ lines.push(' (none)');
1081
+ }
1082
+ else {
1083
+ for (const k of envKeys)
1084
+ lines.push(` ${k}=${spec.env[k]}`);
1085
+ }
1086
+ for (const tier of tiers) {
1087
+ const rt = spec.tiers[tier];
1088
+ lines.push('');
1089
+ lines.push(`TIER: ${tier}`);
1090
+ lines.push(` harness: ${rt.harness}`);
1091
+ lines.push(` model: ${rt.model}`);
1092
+ lines.push(` reasoning: ${rt.harnessSettings.reasoning}`);
1093
+ lines.push(` timeout: ${rt.harnessSettings.timeoutSeconds}s`);
1094
+ lines.push(' systemPrompt:');
1095
+ lines.push(indent(rt.systemPrompt, ' '));
1096
+ }
1097
+ return lines.join('\n') + '\n';
1098
+ }
1099
+ function runShow(args) {
1100
+ const { selector, json, all } = parseShowArgs(args);
1101
+ const { spec, source, tiers, explicitTier } = resolveShowTarget(selector, all);
1102
+ const tierNote = all
1103
+ ? 'all tiers'
1104
+ : explicitTier
1105
+ ? 'explicit @<tier>'
1106
+ : 'recommended for intent; pass --all or @<tier> to override';
1107
+ if (json) {
1108
+ const projectedTiers = Object.fromEntries(tiers.map((t) => [t, spec.tiers[t]]));
1109
+ const projected = { ...spec, tiers: projectedTiers };
1110
+ process.stdout.write(JSON.stringify({ source, spec: projected }, null, 2) + '\n');
1111
+ }
1112
+ else {
1113
+ process.stdout.write(formatPersonaShow(spec, source, tiers, tierNote));
1114
+ }
1115
+ process.exit(0);
1116
+ }
576
1117
  function runHarnessCheck() {
577
1118
  const results = detectHarnesses();
578
1119
  process.stdout.write(formatAvailabilityTable(results));
@@ -590,6 +1131,9 @@ async function main() {
590
1131
  if (subcommand === 'list') {
591
1132
  runList(rest);
592
1133
  }
1134
+ if (subcommand === 'show') {
1135
+ runShow(rest);
1136
+ }
593
1137
  if (subcommand === 'harness') {
594
1138
  const [action, ...extra] = rest;
595
1139
  if (!action || action === '-h' || action === '--help') {