peaks-cli 1.3.3 → 1.3.5

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 (61) hide show
  1. package/dist/src/cli/commands/core-artifact-commands.js +6 -3
  2. package/dist/src/cli/commands/hook-handle.d.ts +2 -2
  3. package/dist/src/cli/commands/hook-handle.js +5 -10
  4. package/dist/src/cli/commands/hooks-commands.js +44 -29
  5. package/dist/src/cli/commands/project-commands.js +15 -5
  6. package/dist/src/cli/commands/workflow-commands.js +2 -1
  7. package/dist/src/cli/commands/workspace-commands.js +1 -2
  8. package/dist/src/cli/program.js +3 -2
  9. package/dist/src/services/dashboard/project-dashboard-service.d.ts +23 -0
  10. package/dist/src/services/dashboard/project-dashboard-service.js +21 -0
  11. package/dist/src/services/dispatch/sub-agent-dispatcher.d.ts +45 -40
  12. package/dist/src/services/dispatch/sub-agent-dispatcher.js +25 -20
  13. package/dist/src/services/ide/adapters/claude-code-adapter.js +27 -2
  14. package/dist/src/services/ide/adapters/trae-adapter.d.ts +19 -11
  15. package/dist/src/services/ide/adapters/trae-adapter.js +45 -19
  16. package/dist/src/services/ide/hook-protocol.d.ts +7 -4
  17. package/dist/src/services/ide/hook-protocol.js +7 -4
  18. package/dist/src/services/ide/ide-types.d.ts +61 -16
  19. package/dist/src/services/ide/resource-profile.d.ts +52 -0
  20. package/dist/src/services/ide/resource-profile.js +33 -0
  21. package/dist/src/services/memory/project-context-service.js +2 -1
  22. package/dist/src/services/memory/project-memory-service.js +4 -3
  23. package/dist/src/services/perf/perf-baseline-service.js +2 -1
  24. package/dist/src/services/progress/progress-service.d.ts +23 -103
  25. package/dist/src/services/progress/progress-service.js +24 -137
  26. package/dist/src/services/scan/file-size-scan.d.ts +4 -0
  27. package/dist/src/services/scan/file-size-scan.js +32 -3
  28. package/dist/src/services/session/getSessionDir.d.ts +1 -0
  29. package/dist/src/services/session/getSessionDir.js +27 -0
  30. package/dist/src/services/session/index.d.ts +1 -0
  31. package/dist/src/services/session/index.js +1 -0
  32. package/dist/src/services/skills/hooks-settings-service.d.ts +57 -5
  33. package/dist/src/services/skills/hooks-settings-service.js +153 -28
  34. package/dist/src/services/standards/ide-aware-standards-service.d.ts +94 -0
  35. package/dist/src/services/standards/ide-aware-standards-service.js +89 -0
  36. package/dist/src/services/standards/project-standards-service.d.ts +1 -2
  37. package/dist/src/shared/incrementing-number.d.ts +0 -8
  38. package/dist/src/shared/incrementing-number.js +11 -1
  39. package/dist/src/shared/version.d.ts +1 -1
  40. package/dist/src/shared/version.js +1 -1
  41. package/package.json +1 -1
  42. package/scripts/install-skills.mjs +112 -2
  43. package/skills/peaks-ide/SKILL.md +1 -1
  44. package/skills/peaks-ide/references/audit-log-helper.md +52 -0
  45. package/skills/peaks-qa/SKILL.md +104 -62
  46. package/skills/peaks-qa/references/qa-fanout-contract.md +6 -6
  47. package/skills/peaks-rd/SKILL.md +88 -73
  48. package/skills/peaks-solo/SKILL.md +52 -22
  49. package/skills/peaks-solo/references/browser-workflow.md +22 -20
  50. package/skills/peaks-solo/references/runbook.md +21 -21
  51. package/skills/peaks-solo/references/sub-agent-dispatch.md +44 -1
  52. package/skills/peaks-solo/references/swarm-dispatch-contract.md +9 -9
  53. package/skills/peaks-ui/SKILL.md +18 -9
  54. package/dist/src/cli/commands/progress-close-kill.d.ts +0 -51
  55. package/dist/src/cli/commands/progress-close-kill.js +0 -152
  56. package/dist/src/cli/commands/progress-commands.d.ts +0 -3
  57. package/dist/src/cli/commands/progress-commands.js +0 -379
  58. package/dist/src/cli/commands/progress-start-spawn.d.ts +0 -59
  59. package/dist/src/cli/commands/progress-start-spawn.js +0 -140
  60. package/dist/src/cli/commands/progress-watch-render.d.ts +0 -80
  61. package/dist/src/cli/commands/progress-watch-render.js +0 -308
@@ -1,51 +0,0 @@
1
- /**
2
- * Best-effort close of a spawned `peaks progress watch`
3
- * window. Used by `peaks progress close` (manual escape
4
- * hatch) and by the watch-side auto-exit when the sub-agent
5
- * hits a terminal phase.
6
- *
7
- * The close is best-effort by design: we never throw from
8
- * individual signals. One failed close primitive is a UX
9
- * paper cut, not a correctness bug — the caller still clears
10
- * the spawn record after this returns.
11
- *
12
- * Cross-platform strategy:
13
- *
14
- * - macOS: pkill the watch process by command pattern
15
- * (matches the project path, so we never close the
16
- * wrong window), then send AppleScript to Terminal.app
17
- * to close the window by `custom title`. Terminal.app
18
- * is the dominant macOS terminal, and `custom title` is
19
- * the only stable identifier we can target from outside
20
- * the running shell.
21
- * - Linux: pkill the watch process, then try `wmctrl -c
22
- * peaks-cli-progress` to close the terminal window by
23
- * WM class (set in `progress start` for alacritty /
24
- * kitty; gnome-terminal / konsole / xfce4-terminal
25
- * close on their own when the child exits). wmctrl is
26
- * not always installed; we silently no-op on
27
- * "command not found" (exit 127) and surface other
28
- * errors as warnings.
29
- * - Windows: `taskkill /F /FI "WINDOWTITLE eq
30
- * peaks-cli:*"` to kill the cmd.exe wrapper. We use
31
- * the title prefix because the exact title includes the
32
- * `--reason` suffix which we do not know here.
33
- *
34
- * The kill is intentionally not a single primitive (e.g.
35
- * `process.kill(-pid, 'SIGTERM')` on the process group).
36
- * The launcher's PID is the spawn-time PID (osascript on
37
- * macOS, gnome-terminal on Linux), not the long-lived
38
- * watch process — and the long-lived process is the one we
39
- * actually need to terminate to make the terminal close.
40
- * Targeting by command pattern (pkill) + window title
41
- * (AppleScript / wmctrl / taskkill) is more reliable than
42
- * PID chasing across detached children.
43
- */
44
- import type { ProgressSpawnRecord } from '../../services/progress/progress-service.js';
45
- export type KillSpawnedTerminalResult = {
46
- /** Each signal that was successfully sent. */
47
- signals: string[];
48
- /** Soft failures (e.g. pkill matched no process, wmctrl missing). */
49
- warnings: string[];
50
- };
51
- export declare function killSpawnedTerminal(record: ProgressSpawnRecord, canonicalProjectRoot: string, currentPlatform: NodeJS.Platform): Promise<KillSpawnedTerminalResult>;
@@ -1,152 +0,0 @@
1
- /**
2
- * Best-effort close of a spawned `peaks progress watch`
3
- * window. Used by `peaks progress close` (manual escape
4
- * hatch) and by the watch-side auto-exit when the sub-agent
5
- * hits a terminal phase.
6
- *
7
- * The close is best-effort by design: we never throw from
8
- * individual signals. One failed close primitive is a UX
9
- * paper cut, not a correctness bug — the caller still clears
10
- * the spawn record after this returns.
11
- *
12
- * Cross-platform strategy:
13
- *
14
- * - macOS: pkill the watch process by command pattern
15
- * (matches the project path, so we never close the
16
- * wrong window), then send AppleScript to Terminal.app
17
- * to close the window by `custom title`. Terminal.app
18
- * is the dominant macOS terminal, and `custom title` is
19
- * the only stable identifier we can target from outside
20
- * the running shell.
21
- * - Linux: pkill the watch process, then try `wmctrl -c
22
- * peaks-cli-progress` to close the terminal window by
23
- * WM class (set in `progress start` for alacritty /
24
- * kitty; gnome-terminal / konsole / xfce4-terminal
25
- * close on their own when the child exits). wmctrl is
26
- * not always installed; we silently no-op on
27
- * "command not found" (exit 127) and surface other
28
- * errors as warnings.
29
- * - Windows: `taskkill /F /FI "WINDOWTITLE eq
30
- * peaks-cli:*"` to kill the cmd.exe wrapper. We use
31
- * the title prefix because the exact title includes the
32
- * `--reason` suffix which we do not know here.
33
- *
34
- * The kill is intentionally not a single primitive (e.g.
35
- * `process.kill(-pid, 'SIGTERM')` on the process group).
36
- * The launcher's PID is the spawn-time PID (osascript on
37
- * macOS, gnome-terminal on Linux), not the long-lived
38
- * watch process — and the long-lived process is the one we
39
- * actually need to terminate to make the terminal close.
40
- * Targeting by command pattern (pkill) + window title
41
- * (AppleScript / wmctrl / taskkill) is more reliable than
42
- * PID chasing across detached children.
43
- */
44
- import { execFile } from 'node:child_process';
45
- import { promisify } from 'node:util';
46
- import { getErrorMessage } from '../cli-helpers.js';
47
- const execFileAsync = promisify(execFile);
48
- export async function killSpawnedTerminal(record, canonicalProjectRoot, currentPlatform) {
49
- const signals = [];
50
- const warnings = [];
51
- // The watch command we spawned, escaped for use as a pkill
52
- // pattern. We anchor on `progress watch` (NOT `peaks progress
53
- // watch`) because the actual cmdline is `.../peaks.js progress
54
- // watch --project /path` — the literal substring
55
- // `peaks progress watch` does NOT appear in the cmdline
56
- // (there is a `.js` between `peaks` and `progress`).
57
- // Anchoring on the verb + the project path is specific
58
- // enough to not hit any user-owned `progress watch` process
59
- // for a different project.
60
- const watchPattern = `progress watch.*--project ${canonicalProjectRoot.replace(/[\\"\s]/g, '\\$&')}`;
61
- if (currentPlatform === 'darwin') {
62
- // pkill exit codes: 0 = matched & signalled, 1 = no processes
63
- // matched (silent miss), 2 = syntax error (warning), 3 = fatal
64
- // (warning). macOS pkill writes nothing to stderr on a clean
65
- // miss, so the exit code is the only signal we have.
66
- await trySignal('pkill', ['-f', watchPattern], signals, 'pkill-watch', warnings, /no.*process/i, new Set([1]));
67
- // AppleScript to close the Terminal.app window by
68
- // custom title. We use `every window whose custom title
69
- // is` so we only close the right tab. AppleScript returns
70
- // a non-zero exit when the window is already gone, the
71
- // app is not running, or the title does not match — all
72
- // of which are silent misses from the user's perspective
73
- // (the user-facing outcome is identical to the success
74
- // case: the window is no longer visible). Treat any
75
- // non-zero exit as silent.
76
- try {
77
- const escapedTitle = record.windowTitle.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
78
- await execFileAsync('osascript', [
79
- '-e',
80
- `tell application "Terminal" to close (every window whose custom title is "${escapedTitle}")`
81
- ]);
82
- signals.push('osascript-close-window');
83
- }
84
- catch {
85
- // Silent miss. See comment above.
86
- }
87
- }
88
- else if (currentPlatform === 'linux') {
89
- // Same pkill exit code semantics as macOS.
90
- await trySignal('pkill', ['-f', watchPattern], signals, 'pkill-watch', warnings, /no.*process/i, new Set([1]));
91
- // wmctrl by WM class (set in `progress start`). Missing
92
- // wmctrl is silent (exit 127) — most distros ship it but
93
- // headless / minimal installs do not.
94
- await trySignal('wmctrl', ['-c', 'peaks-cli-progress'], signals, 'wmctrl-close-class', warnings, /not found|No such file/i, new Set([127]));
95
- }
96
- else if (currentPlatform === 'win32') {
97
- // Title prefix is set in `progress start` to `peaks-cli:`.
98
- // We match the prefix because the full title includes
99
- // the `--reason` suffix which we do not know here.
100
- // taskkill exit codes: 0 = success, 1 = no tasks matched
101
- // (silent miss — the window is already gone), 128 = error.
102
- const titlePrefix = 'peaks-cli:';
103
- await trySignal('taskkill', ['/F', '/FI', `WINDOWTITLE eq ${titlePrefix}*`], signals, 'taskkill-window-title', warnings, /no.*task/i, new Set([1]));
104
- }
105
- else {
106
- warnings.push(`unsupported platform: ${currentPlatform}`);
107
- }
108
- return { signals, warnings };
109
- }
110
- /**
111
- * Run a single close primitive. If it throws AND either
112
- * (a) the error matches the "expected" stderr pattern
113
- * (e.g. "no process matched" for pkill, "command not
114
- * found" for wmctrl) — most platforms print this on
115
- * stderr; or
116
- * (b) the exit code is in `silentMissExitCodes` (pkill 1,
117
- * wmctrl 127, taskkill 1) — the primitive ran, found
118
- * nothing, and is not telling us via stderr,
119
- * we silently no-op — that is the success case for the
120
- * primitive. Other errors are appended to `warnings` for
121
- * the caller to surface. On a clean resolve, the named
122
- * signal is appended to `signals`.
123
- */
124
- async function trySignal(command, args, signals, signal, warnings, expectedFailurePattern, silentMissExitCodes) {
125
- try {
126
- await execFileAsync(command, args);
127
- }
128
- catch (error) {
129
- // execFile's error object exposes `code` as either a
130
- // numeric exit code (when the process ran) or a string
131
- // system code like 'ENOENT' (when the binary itself
132
- // is missing). Only numeric exit codes are candidates
133
- // for silent-miss.
134
- const execError = error;
135
- if (typeof execError.code === 'number' && silentMissExitCodes.has(execError.code)) {
136
- // Exit code says "ran, but found nothing to act on".
137
- // The user-facing outcome is identical to the success
138
- // case, so do not surface a warning.
139
- return;
140
- }
141
- const message = getErrorMessage(error);
142
- if (expectedFailurePattern.test(message)) {
143
- return;
144
- }
145
- warnings.push(`${command}: ${message}`);
146
- return;
147
- }
148
- // Reached only if execFile resolves (exit 0). All three
149
- // primitives exit non-zero on a miss, so a clean resolve
150
- // means the signal landed.
151
- signals.push(signal);
152
- }
@@ -1,3 +0,0 @@
1
- import { Command } from 'commander';
2
- import { type ProgramIO } from '../cli-helpers.js';
3
- export declare function registerProgressCommands(program: Command, io: ProgramIO): void;
@@ -1,379 +0,0 @@
1
- import { spawn } from 'node:child_process';
2
- import { platform } from 'node:os';
3
- import chalk from 'chalk';
4
- import ora from 'ora';
5
- import { clearSpawnRecord, isRecentSpawn, phaseAutoClosesSpawn, readSpawnRecord, readSubAgentProgress, resolveProgressProjectRoot, subAgentProgressPath, subAgentSpawnPath, writeSpawnRecord, writeSubAgentProgress } from '../../services/progress/progress-service.js';
6
- import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
7
- import { fail, ok } from '../../shared/result.js';
8
- import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
9
- import { killSpawnedTerminal } from './progress-close-kill.js';
10
- import { buildStartSpawn } from './progress-start-spawn.js';
11
- import { WatchRenderer } from './progress-watch-render.js';
12
- export function registerProgressCommands(program, io) {
13
- const progress = program.command('progress').description('Sub-agent progress surfacing (LLM-side step writes, user-side watch, auto-spawn new terminal)');
14
- // ─────────────────────────────────────────────────────────────────
15
- // peaks progress step
16
- // LLM-side: called by the LLM on phase transitions. Near-zero
17
- // token cost — one Bash call per phase change. Writes
18
- // `.peaks/_sub_agents/<sid>/subagent-progress.json`. No auto-spawn
19
- // here; the LLM invokes `peaks progress start` separately when
20
- // the user-visible window needs to open.
21
- // ─────────────────────────────────────────────────────────────────
22
- addJsonOption(progress
23
- .command('step')
24
- .description('Record a sub-agent step / phase transition. Idempotent on (step, phase); transitions append to history.')
25
- .requiredOption('--request-id <rid>', 'the same <rid> used by peaks request init')
26
- .requiredOption('--role <role>', 'rd | qa | ui | sc | prd (the role of the sub-agent calling this)')
27
- .requiredOption('--step <text>', 'free-form human-readable step label, e.g. "running test/ut"')
28
- .requiredOption('--phase <phase>', 'starting | running | verifying | completing | finished | failed | idle')
29
- .option('--verdict <verdict>', 'pass | return-to-rd | blocked (only when phase is finished or failed)')
30
- .option('--project <path>', 'target project root (defaults to git root or cwd)')
31
- .option('--reason <text>', 'human-readable reason for the step write, recorded in the response data')).action((options) => {
32
- try {
33
- const projectRoot = options.project !== undefined
34
- ? options.project
35
- : resolveProgressProjectRoot(undefined, process.cwd());
36
- const canonical = resolveCanonicalProjectRoot(projectRoot);
37
- const data = writeSubAgentProgress({
38
- projectRoot: canonical,
39
- requestId: options.requestId,
40
- role: options.role,
41
- step: options.step,
42
- phase: options.phase,
43
- ...(options.verdict !== undefined ? { verdict: options.verdict } : {}),
44
- ...(options.reason !== undefined ? { outerSessionId: options.reason } : {})
45
- });
46
- printResult(io, ok('progress.step', {
47
- projectRoot: canonical,
48
- path: subAgentProgressPath(canonical),
49
- sessionId: data.sessionId,
50
- requestId: data.requestId,
51
- role: data.role,
52
- phase: data.current.phase,
53
- step: data.current.step,
54
- startedAt: data.current.startedAt,
55
- updatedAt: data.current.updatedAt
56
- }), options.json);
57
- }
58
- catch (error) {
59
- printResult(io, fail('progress.step', 'PROGRESS_STEP_FAILED', getErrorMessage(error), { projectRoot: options.project }, ['Verify the project path exists and that peaks workspace init has been run for it']), options.json);
60
- process.exitCode = 1;
61
- }
62
- });
63
- // ─────────────────────────────────────────────────────────────────
64
- // peaks progress watch
65
- // User-side: run in a separate terminal. 1s poll + ASCII
66
- // spinner + elapsed. --once for a single snapshot.
67
- // ─────────────────────────────────────────────────────────────────
68
- addJsonOption(progress
69
- .command('watch')
70
- .description('Watch the sub-agent progress file in a loop (1s poll + ASCII spinner). --once for a single snapshot.')
71
- .option('--project <path>', 'target project root (defaults to git root or cwd)')
72
- .option('--once', 'print a single snapshot and exit (for use in scripts or statusline hooks)', false)
73
- .option('--interval-ms <ms>', 'poll interval in milliseconds (default 1000)', (value) => Number.parseInt(value, 10))).action(async (options) => {
74
- try {
75
- const projectRoot = options.project !== undefined
76
- ? options.project
77
- : resolveProgressProjectRoot(undefined, process.cwd());
78
- const canonical = resolveCanonicalProjectRoot(projectRoot);
79
- const intervalMs = options.intervalMs !== undefined && Number.isFinite(options.intervalMs) && options.intervalMs > 0
80
- ? options.intervalMs
81
- : 1000;
82
- if (options.once === true) {
83
- const result = readSubAgentProgress({ projectRoot: canonical });
84
- if (!result.ok) {
85
- printResult(io, fail('progress.watch', 'NO_PROGRESS_DATA', `No progress file present yet (${result.reason})`, { projectRoot: canonical, path: subAgentProgressPath(canonical) }, ['Run peaks progress step once on the LLM side to bootstrap the file']), options.json);
86
- process.exitCode = 1;
87
- return;
88
- }
89
- printResult(io, ok('progress.watch.snapshot', {
90
- projectRoot: canonical,
91
- path: result.path,
92
- data: result.data
93
- }), options.json);
94
- return;
95
- }
96
- // Long-running watch loop. The render layer lives in
97
- // ./progress-watch-render.ts; the watch loop just polls
98
- // the file and calls renderer.tick(data, n). The renderer
99
- // owns cursor positioning and in-place overwrite so the
100
- // output does not grow line by line as it did in the
101
- // previous console.log-based implementation.
102
- const renderer = new WatchRenderer({
103
- projectRoot: canonical,
104
- progressFilePath: subAgentProgressPath(canonical)
105
- });
106
- renderer.start();
107
- // Hint line is painted once BELOW the dynamic block, so
108
- // it is not erased on each tick. The user sees the
109
- // spinner + bar above, the static hint at the bottom.
110
- io.stdout(chalk.gray(' press Ctrl-C to stop watching\n'));
111
- let tick = 0;
112
- // eslint-disable-next-line no-constant-condition
113
- while (true) {
114
- const result = readSubAgentProgress({ projectRoot: canonical });
115
- const data = result.ok ? result.data : null;
116
- renderer.tick(data, tick);
117
- tick += 1;
118
- // Auto-close: when the sub-agent reaches a terminal
119
- // phase (finished or failed per phaseAutoClosesSpawn),
120
- // paint the final frame so the user can read the
121
- // verdict, then exit. Exiting the watch process makes
122
- // most terminal emulators close the window
123
- // (Terminal.app / gnome-terminal / konsole all do;
124
- // alacritty / kitty keep it). We also clear the
125
- // spawn record so a subsequent `peaks progress close`
126
- // reports "nothing to close" instead of "closed a
127
- // ghost record".
128
- //
129
- // We deliberately do NOT auto-close on `blocked` —
130
- // `blocked` means the user needs to read the watch
131
- // output and decide what to do.
132
- if (data !== null && phaseAutoClosesSpawn(data.current.phase)) {
133
- renderer.finalize(data);
134
- clearSpawnRecord(canonical);
135
- return;
136
- }
137
- await new Promise((resolveWait) => setTimeout(resolveWait, intervalMs));
138
- }
139
- }
140
- catch (error) {
141
- printResult(io, fail('progress.watch', 'PROGRESS_WATCH_FAILED', getErrorMessage(error), { projectRoot: options.project }, ['Verify the project path exists and that the progress file is readable']), options.json);
142
- process.exitCode = 1;
143
- }
144
- });
145
- // ─────────────────────────────────────────────────────────────────
146
- // peaks progress start
147
- // The "auto-spawn a new terminal running watch" entry point.
148
- // Called by the LLM at the first phase transition of a slice,
149
- // once per session. Cross-platform: macOS uses osascript with
150
- // Terminal.app; Linux tries gnome-terminal / konsole /
151
- // xterm in order; Windows uses `start cmd`. The user can
152
- // close the new terminal at any time; re-invoking is a no-op
153
- // if a watch is already running in another terminal.
154
- // ─────────────────────────────────────────────────────────────────
155
- addJsonOption(progress
156
- .command('start')
157
- .description('Auto-spawn a new terminal running `peaks progress watch` for this project. Called by the LLM at the first phase transition; the user can close the new terminal at any time.')
158
- .option('--project <path>', 'target project root (defaults to git root or cwd)')
159
- .option('--reason <text>', 'human-readable reason for the auto-spawn, recorded in the response data')
160
- .option('--quiet', 'suppress human-readable output (the Task-tool PreToolUse hook uses this to keep the LLM context clean)')).action(async (options) => {
161
- try {
162
- const projectRoot = options.project !== undefined
163
- ? options.project
164
- : resolveProgressProjectRoot(undefined, process.cwd());
165
- const canonical = resolveCanonicalProjectRoot(projectRoot);
166
- // Idempotency check: when the Task-tool PreToolUse hook fires
167
- // `peaks progress start` on every Task call, a fresh terminal
168
- // should NOT be spawned if a watch window was already opened for
169
- // this session within the last 5 minutes. The user closes the
170
- // window deliberately; we honor that until the record ages out.
171
- const recent = isRecentSpawn(canonical);
172
- if (recent.recent) {
173
- if (options.quiet !== true) {
174
- // Non-hook path: keep the human feedback (so an LLM running
175
- // peaks progress start manually understands the no-op).
176
- }
177
- printResult(io, ok('progress.start', {
178
- projectRoot: canonical,
179
- spawned: false,
180
- idempotent: true,
181
- reason: recent.reason,
182
- ageMs: recent.ageMs,
183
- note: 'a recent spawn record exists; the watch window is presumed open. Re-run after 5 min (TTL) or `peaks progress close` to force a fresh spawn.'
184
- }, [], recent.reason === 'recent-spawn'
185
- ? []
186
- : ['run `peaks progress close` to clear the stale record and force a fresh spawn']), options.json);
187
- return;
188
- }
189
- const currentPlatform = platform();
190
- const peaksBin = process.argv[1] ?? 'peaks';
191
- const reasonSuffix = options.reason !== undefined ? ` — ${options.reason}` : '';
192
- // Window / tab title shared across platforms. The user
193
- // asked for visible "this is peaks-cli" branding so the
194
- // spawned terminal is identifiable at a glance; the title
195
- // also makes `peaks progress close` self-documenting.
196
- //
197
- // Em-dash (U+2014) instead of a colon. On Windows, cmd /c's
198
- // script parser interprets a `:` even inside quotes as a
199
- // drive-letter prefix, so `peaks-cli: sub-agent progress`
200
- // triggers the "Windows 找不到文件 'sub-agent'" dialog. The
201
- // em-dash is a no-op for cmd / c, bash, and AppleScript
202
- // string parsing — the visible branding is preserved.
203
- const windowTitle = `peaks-cli — sub-agent progress${reasonSuffix}`;
204
- const watchCommand = `${peaksBin} progress watch --project "${canonical}"`;
205
- // Build the platform-specific spawn command + args. This
206
- // is extracted to ./progress-start-spawn.ts so the three
207
- // platform branches can be unit-tested without spawning
208
- // a real terminal.
209
- const spawnSpec = buildStartSpawn({
210
- peaksBin,
211
- projectRoot: canonical,
212
- windowTitle,
213
- platform: currentPlatform
214
- });
215
- if (!spawnSpec.ok) {
216
- printResult(io, fail('progress.start', 'UNSUPPORTED_PLATFORM', `Cannot auto-spawn a terminal on platform "${currentPlatform}". Run \`peaks progress watch --project "${canonical}"\` in a new terminal yourself.`, { projectRoot: canonical }, ['macOS / Linux / Windows are supported; other platforms need a manual terminal']), options.json);
217
- process.exitCode = 1;
218
- return;
219
- }
220
- const spawnCommand = spawnSpec.command;
221
- const spawnArgs = spawnSpec.args;
222
- // Brief ora feedback while the terminal is launching.
223
- // Skipped entirely in non-TTY mode (the LLM calls this
224
- // from a Bash tool, where ora would just hang on
225
- // animation) and in --json mode (where the structured
226
- // response is the only signal the caller consumes).
227
- const showSpinner = process.stdout.isTTY === true && options.json !== true;
228
- const spinner = showSpinner
229
- ? ora(`auto-spawning ${spawnCommand}…`).start()
230
- : null;
231
- try {
232
- // spawn() with detached:true + unref() is the documented
233
- // Node.js way to start a long-lived child from a CLI
234
- // without blocking. We ignore stdio because the spawned
235
- // terminal owns the child process group from now on; the
236
- // peaks CLI exits and the terminal keeps running.
237
- const child = spawn(spawnCommand, spawnArgs, { detached: true, stdio: 'ignore' });
238
- child.unref();
239
- // Give the spawn a beat to surface EACCES/ENOENT. We do
240
- // not await the child (it is intentionally long-lived).
241
- await new Promise((resolveSpawn, rejectSpawn) => {
242
- const timer = setTimeout(() => resolveSpawn(), 200);
243
- child.once('error', (spawnError) => {
244
- clearTimeout(timer);
245
- rejectSpawn(spawnError);
246
- });
247
- child.once('spawn', () => {
248
- clearTimeout(timer);
249
- resolveSpawn();
250
- });
251
- });
252
- if (spinner !== null) {
253
- spinner.succeed(`spawned ${spawnCommand} (new window is opening)`);
254
- }
255
- // Persist the spawn record so `peaks progress close` (and
256
- // the watch-side auto-exit) can find and kill the window
257
- // later. We write the record AFTER the spawn fires so a
258
- // failed spawn never leaves a stale record behind. The
259
- // record is per-session: a session rotation invalidates it
260
- // because the new session gets a fresh record path.
261
- const spawnRecord = writeSpawnRecord({
262
- projectRoot: canonical,
263
- pid: child.pid ?? 0,
264
- platform: currentPlatform,
265
- command: spawnCommand,
266
- args: spawnArgs,
267
- ...(options.reason !== undefined ? { reason: options.reason } : {}),
268
- windowTitle
269
- });
270
- printResult(io, ok('progress.start', {
271
- projectRoot: canonical,
272
- platform: currentPlatform,
273
- spawned: `${spawnCommand} ${spawnArgs.join(' ')}`,
274
- watchCommand,
275
- ...(options.reason !== undefined ? { reason: options.reason } : {}),
276
- ...(spawnRecord === null
277
- ? {
278
- spawnRecord: null,
279
- warning: 'no peaks session binding — `peaks progress close` will not be able to find this window. Close it manually.'
280
- }
281
- : {
282
- spawnRecord: {
283
- path: subAgentSpawnPath(canonical),
284
- windowTitle: spawnRecord.windowTitle,
285
- spawnedAt: spawnRecord.spawnedAt
286
- }
287
- }),
288
- autoClose: 'the watch window will close itself when the sub-agent hits `finished` or `failed`',
289
- note: 'A new terminal window is opening in the background. It will run `peaks progress watch` and refresh every second. Close the new terminal at any time, or run `peaks progress close` to programmatically close it.'
290
- }), options.json);
291
- return;
292
- }
293
- catch (spawnError) {
294
- if (spinner !== null) {
295
- spinner.fail(`auto-spawn failed: ${getErrorMessage(spawnError)}`);
296
- }
297
- printResult(io, fail('progress.start', 'TERMINAL_SPAWN_FAILED', `Auto-spawn failed: ${getErrorMessage(spawnError)}. Run \`peaks progress watch --project "${canonical}"\` in a new terminal yourself.`, { projectRoot: canonical, platform: currentPlatform, attempted: `${spawnCommand} ${spawnArgs.join(' ')}` }, ['Verify a terminal emulator is installed (e.g. gnome-terminal / Terminal.app)']), options.json);
298
- process.exitCode = 1;
299
- return;
300
- }
301
- }
302
- catch (error) {
303
- printResult(io, fail('progress.start', 'PROGRESS_START_FAILED', getErrorMessage(error), { projectRoot: options.project }, ['Verify the project path exists and a terminal emulator is available']), options.json);
304
- process.exitCode = 1;
305
- }
306
- });
307
- // ─────────────────────────────────────────────────────────────────
308
- // peaks progress close
309
- // Manual escape hatch: kill the spawned watch window and
310
- // clear the spawn record. Idempotent — re-running is a no-op
311
- // once the record is gone, and the response distinguishes
312
- // "nothing to close" from "closed it" so callers / hooks can
313
- // tell the difference. The close is best-effort: if the
314
- // watch process has already exited but the record is stale,
315
- // we still clear the record.
316
- // ─────────────────────────────────────────────────────────────────
317
- addJsonOption(progress
318
- .command('close')
319
- .description('Close the spawned `peaks progress watch` window for this session. Idempotent: re-running when no window is open is a no-op.')
320
- .option('--project <path>', 'target project root (defaults to git root or cwd)')).action(async (options) => {
321
- try {
322
- const projectRoot = options.project !== undefined
323
- ? options.project
324
- : resolveProgressProjectRoot(undefined, process.cwd());
325
- const canonical = resolveCanonicalProjectRoot(projectRoot);
326
- const result = readSpawnRecord(canonical);
327
- if (!result.ok) {
328
- // Differentiate the failure modes so callers can decide
329
- // whether to surface a warning. no-binding means peaks
330
- // workspace init has not been run; no-spawn-record /
331
- // invalid-json means there is nothing to close (start
332
- // has not been called this session, or the window
333
- // already auto-closed and the record was cleared).
334
- if (result.reason === 'no-binding') {
335
- printResult(io, fail('progress.close', 'NO_BINDING', 'no peaks session binding — nothing to close', { projectRoot: canonical, path: subAgentSpawnPath(canonical) }, ['Run peaks workspace init for this project first']), options.json);
336
- process.exitCode = 1;
337
- return;
338
- }
339
- printResult(io, ok('progress.close', {
340
- projectRoot: canonical,
341
- closed: false,
342
- reason: result.reason,
343
- note: 'no spawn record found — nothing to close (start has not been called this session, or the window has already auto-closed)'
344
- }), options.json);
345
- return;
346
- }
347
- const record = result.data;
348
- // Best-effort close. We try three signals in order:
349
- // (1) `pkill -f <watch command>` — the long-lived watch
350
- // process. Killing it makes the terminal emulator
351
- // close on most platforms (Terminal.app, gnome-
352
- // terminal, konsole) but not all (alacritty, kitty
353
- // keep the window).
354
- // (2) macOS: AppleScript to close the Terminal.app
355
- // window with the matching custom title.
356
- // (3) Linux: wmctrl/xdotool by WM class as a fallback.
357
- // Windows: taskkill /F /FI on the window title.
358
- // We never throw from the close path — a failed close is
359
- // a UX paper cut, not a correctness bug. The record is
360
- // still cleared so the next `progress start` does not
361
- // see a stale record.
362
- const closeResult = await killSpawnedTerminal(record, canonical, platform());
363
- clearSpawnRecord(canonical);
364
- printResult(io, ok('progress.close', {
365
- projectRoot: canonical,
366
- closed: closeResult.signals.length > 0,
367
- signals: closeResult.signals,
368
- warnings: closeResult.warnings,
369
- windowTitle: record.windowTitle,
370
- spawnedAt: record.spawnedAt,
371
- note: 'spawn record cleared. The next `peaks progress start` will spawn a fresh window.'
372
- }), options.json);
373
- }
374
- catch (error) {
375
- printResult(io, fail('progress.close', 'PROGRESS_CLOSE_FAILED', getErrorMessage(error), { projectRoot: options.project }, ['Verify the project path exists and that peaks workspace init has been run for it']), options.json);
376
- process.exitCode = 1;
377
- }
378
- });
379
- }
@@ -1,59 +0,0 @@
1
- /**
2
- * Pure platform-specific spawn-args construction for
3
- * `peaks progress start`. Extracted out of the commander
4
- * action handler so the three platform branches (darwin /
5
- * linux / win32) can be unit-tested without spawning real
6
- * terminals.
7
- *
8
- * The CLI action calls `buildStartSpawn` once per start
9
- * attempt and feeds the returned `{ command, args }` pair to
10
- * `child_process.spawn(..., { detached: true, stdio: 'ignore' })`.
11
- *
12
- * Cross-platform strategy (see progress-commands.ts for the
13
- * full design notes):
14
- *
15
- * - macOS: `osascript -e 'tell application "Terminal" to
16
- * do script "<shell>"'`. The shell command sets
17
- * the title via the OSC 0 escape (printf), runs
18
- * the brand banner, then exec's the watch.
19
- * - Linux: First existing terminal emulator in
20
- * { gnome-terminal, konsole, xfce4-terminal,
21
- * tilix, alacritty, kitty }, with per-emulator
22
- * flag translation. The shell inside also sets
23
- * the title via OSC 0 (emulator --title is
24
- * best-effort only — the shell overrides it).
25
- * - Win32: `cmd /c start "<title>" cmd /k <shell>`. The
26
- * shell uses the `title` builtin (cmd.exe
27
- * builtin) to re-anchor the title before the
28
- * watch runs, because cmd.exe overrides the
29
- * start-title with the running command name.
30
- *
31
- * The function never throws on unsupported platforms; it
32
- * returns a discriminated `unsupported` result so the caller
33
- * can surface a clean error envelope.
34
- */
35
- export type StartSpawnSpec = {
36
- ok: true;
37
- command: string;
38
- args: string[];
39
- } | {
40
- ok: false;
41
- unsupported: true;
42
- };
43
- export type BuildStartSpawnOptions = {
44
- /** The peaks binary path the spawned shell will invoke. */
45
- peaksBin: string;
46
- /** Canonical project root (used to build the watch command). */
47
- projectRoot: string;
48
- /** Window/tab title shared across platforms. */
49
- windowTitle: string;
50
- /** Current platform (from `os.platform()`). */
51
- platform: NodeJS.Platform;
52
- /**
53
- * Override for terminal detection on Linux. Defaults to
54
- * `existsSync('/usr/bin/<name>')` for each candidate. Tests
55
- * pass a stub to make the linux branch deterministic.
56
- */
57
- linuxTerminalExists?: (name: string) => boolean;
58
- };
59
- export declare function buildStartSpawn(options: BuildStartSpawnOptions): StartSpawnSpec;