pi-crew 0.6.0 → 0.6.3

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 (135) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/README.md +70 -31
  3. package/docs/issue-29-analysis.md +189 -0
  4. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
  5. package/package.json +2 -2
  6. package/src/agents/agent-config.ts +2 -1
  7. package/src/benchmark/feedback-loop.ts +4 -2
  8. package/src/config/config.ts +106 -15
  9. package/src/errors.ts +107 -0
  10. package/src/extension/async-notifier.ts +6 -2
  11. package/src/extension/crew-cleanup.ts +8 -5
  12. package/src/extension/cross-extension-rpc.ts +48 -0
  13. package/src/extension/management.ts +464 -109
  14. package/src/extension/register.ts +194 -34
  15. package/src/extension/registration/commands.ts +4 -3
  16. package/src/extension/registration/subagent-helpers.ts +2 -2
  17. package/src/extension/registration/subagent-tools.ts +3 -1
  18. package/src/extension/registration/team-tool.ts +3 -1
  19. package/src/extension/registration/viewers.ts +3 -2
  20. package/src/extension/run-export.ts +16 -1
  21. package/src/extension/run-import.ts +16 -0
  22. package/src/extension/team-tool/anchor.ts +5 -1
  23. package/src/extension/team-tool/api.ts +12 -7
  24. package/src/extension/team-tool/cancel.ts +3 -3
  25. package/src/extension/team-tool/config-patch.ts +15 -1
  26. package/src/extension/team-tool/explain.ts +1 -1
  27. package/src/extension/team-tool/handle-schedule.ts +40 -0
  28. package/src/extension/team-tool/inspect.ts +3 -3
  29. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  30. package/src/extension/team-tool/respond.ts +2 -2
  31. package/src/extension/team-tool/run.ts +60 -11
  32. package/src/extension/team-tool/status.ts +1 -1
  33. package/src/extension/team-tool.ts +175 -47
  34. package/src/hooks/registry.ts +84 -12
  35. package/src/hooks/types.ts +12 -3
  36. package/src/i18n.ts +15 -2
  37. package/src/observability/exporters/otlp-exporter.ts +73 -0
  38. package/src/plugins/plugin-define.ts +6 -0
  39. package/src/plugins/plugin-registry.ts +32 -0
  40. package/src/plugins/plugins/index.ts +3 -0
  41. package/src/plugins/plugins/nextjs.ts +19 -0
  42. package/src/plugins/plugins/vite.ts +14 -0
  43. package/src/plugins/plugins/vitest.ts +14 -0
  44. package/src/runtime/adaptive-plan.ts +24 -0
  45. package/src/runtime/agent-control.ts +6 -3
  46. package/src/runtime/async-runner.ts +86 -3
  47. package/src/runtime/background-runner.ts +529 -144
  48. package/src/runtime/chain-parser.ts +5 -1
  49. package/src/runtime/chain-runner.ts +67 -3
  50. package/src/runtime/checkpoint.ts +262 -180
  51. package/src/runtime/child-pi.ts +165 -38
  52. package/src/runtime/crash-recovery.ts +25 -14
  53. package/src/runtime/crew-agent-records.ts +6 -3
  54. package/src/runtime/cross-extension-rpc.ts +34 -8
  55. package/src/runtime/diagnostic-export.ts +4 -5
  56. package/src/runtime/dynamic-script-runner.ts +9 -6
  57. package/src/runtime/foreground-watchdog.ts +3 -3
  58. package/src/runtime/heartbeat-watcher.ts +19 -2
  59. package/src/runtime/intercom-bridge.ts +7 -0
  60. package/src/runtime/iteration-hooks.ts +1 -1
  61. package/src/runtime/live-agent-manager.ts +6 -3
  62. package/src/runtime/live-irc.ts +4 -2
  63. package/src/runtime/manifest-cache.ts +4 -0
  64. package/src/runtime/orphan-worker-registry.ts +444 -0
  65. package/src/runtime/parallel-utils.ts +2 -1
  66. package/src/runtime/parent-guard.ts +70 -16
  67. package/src/runtime/pi-args.ts +437 -14
  68. package/src/runtime/pi-spawn.ts +1 -0
  69. package/src/runtime/post-checks.ts +11 -4
  70. package/src/runtime/retry-runner.ts +9 -3
  71. package/src/runtime/{drift-detectors.ts → run-drift.ts} +1 -1
  72. package/src/runtime/run-tracker.ts +38 -10
  73. package/src/runtime/sandbox.ts +42 -21
  74. package/src/runtime/scheduler.ts +25 -2
  75. package/src/runtime/semaphore.ts +2 -1
  76. package/src/runtime/settings-store.ts +14 -2
  77. package/src/runtime/skill-effectiveness.ts +109 -64
  78. package/src/runtime/skill-instructions.ts +114 -33
  79. package/src/runtime/stale-reconciler.ts +310 -69
  80. package/src/runtime/subagent-manager.ts +265 -53
  81. package/src/runtime/subprocess-tool-registry.ts +2 -2
  82. package/src/runtime/task-health.ts +76 -0
  83. package/src/runtime/task-id.ts +20 -0
  84. package/src/runtime/task-packet.ts +13 -1
  85. package/src/runtime/task-runner/live-executor.ts +1 -1
  86. package/src/runtime/task-runner/progress.ts +1 -1
  87. package/src/runtime/task-runner/state-helpers.ts +110 -6
  88. package/src/runtime/task-runner.ts +98 -67
  89. package/src/runtime/team-runner.ts +186 -20
  90. package/src/runtime/usage-tracker.ts +4 -2
  91. package/src/runtime/verification-gates.ts +36 -9
  92. package/src/schema/team-tool-schema.ts +27 -9
  93. package/src/skills/discover-skills.ts +9 -3
  94. package/src/state/active-run-registry.ts +170 -38
  95. package/src/state/artifact-store.ts +25 -13
  96. package/src/state/atomic-write-v2.ts +86 -0
  97. package/src/state/atomic-write.ts +346 -55
  98. package/src/state/blob-store.ts +178 -10
  99. package/src/state/contracts.ts +2 -1
  100. package/src/state/crew-init.ts +161 -28
  101. package/src/state/decision-ledger.ts +172 -111
  102. package/src/state/event-log-rotation.ts +82 -52
  103. package/src/state/event-log.ts +270 -75
  104. package/src/state/health-store.ts +71 -0
  105. package/src/state/hook-instinct-bridge.ts +2 -1
  106. package/src/state/locks.ts +109 -20
  107. package/src/state/mailbox.ts +45 -7
  108. package/src/state/observation-store.ts +4 -1
  109. package/src/state/run-graph.ts +141 -130
  110. package/src/state/run-metrics.ts +24 -8
  111. package/src/state/state-store.ts +333 -44
  112. package/src/state/task-claims.ts +9 -2
  113. package/src/tools/safe-bash.ts +69 -20
  114. package/src/types/new-api-types.ts +10 -5
  115. package/src/ui/keybinding-map.ts +2 -1
  116. package/src/ui/live-run-sidebar.ts +1 -1
  117. package/src/ui/loaders.ts +4 -0
  118. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  119. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  120. package/src/ui/run-action-dispatcher.ts +4 -3
  121. package/src/ui/run-snapshot-cache.ts +1 -1
  122. package/src/ui/status-colors.ts +2 -1
  123. package/src/ui/syntax-highlight.ts +2 -1
  124. package/src/ui/tool-render.ts +13 -3
  125. package/src/utils/env-filter.ts +66 -0
  126. package/src/utils/file-coalescer.ts +9 -1
  127. package/src/utils/fs-watch.ts +4 -2
  128. package/src/utils/gh-protocol.ts +2 -1
  129. package/src/utils/paths.ts +80 -5
  130. package/src/utils/redaction.ts +6 -1
  131. package/src/utils/safe-paths.ts +287 -10
  132. package/src/worktree/cleanup.ts +117 -26
  133. package/src/worktree/worktree-manager.ts +128 -15
  134. package/test-bugs-all.mjs +10 -6
  135. package/test-integration-check.ts +4 -0
@@ -114,7 +114,11 @@ function tokenize(input: string): Token[] {
114
114
  class ChainParser {
115
115
  private pos = 0;
116
116
 
117
- constructor(private tokens: Token[]) {}
117
+ private tokens: Token[];
118
+
119
+ constructor(tokens: Token[]) {
120
+ this.tokens = tokens;
121
+ }
118
122
 
119
123
  parse(): ChainStep[] {
120
124
  const steps: ChainStep[] = [];
@@ -11,6 +11,8 @@
11
11
  */
12
12
 
13
13
  import type { HandoffSummary, HandoffManager, TaskPacket, TaskResult } from "./handoff-manager.ts";
14
+ import { parseChainDSL } from "./chain-parser.ts";
15
+ import type { ChainStep as DSLChainStep } from "./chain-parser.ts";
14
16
 
15
17
  /**
16
18
  * Single step in a chain.
@@ -104,10 +106,16 @@ export class ChainRunner {
104
106
  /** Maximum size per handoff entry to prevent memory issues from large artifacts */
105
107
  private static readonly MAX_HANDOFF_ENTRY_SIZE = 5000; // bytes per entry
106
108
 
109
+ private taskRunner: ChainTaskRunner;
110
+ private handoffManager: HandoffManager;
111
+
107
112
  constructor(
108
- private taskRunner: ChainTaskRunner,
109
- private handoffManager: HandoffManager,
110
- ) {}
113
+ taskRunner: ChainTaskRunner,
114
+ handoffManager: HandoffManager,
115
+ ) {
116
+ this.taskRunner = taskRunner;
117
+ this.handoffManager = handoffManager;
118
+ }
111
119
 
112
120
  /**
113
121
  * Parse chain syntax: step1 -> step2 -> step3
@@ -123,10 +131,25 @@ export class ChainRunner {
123
131
  * parseChain('"Research AI trends" -> "Analyze findings"')
124
132
  * parseChain("@step1 --model claude-opus-3 -> @step2")
125
133
  *
134
+ * Also supports DSL syntax from chain-parser for advanced constructs:
135
+ * parseChain("step1 -> parallel(step2, step3) -> step4")
136
+ * parseChain("step1:3 -> step2 --with-context -> step3")
137
+ *
126
138
  * @param chainString - The chain string to parse
127
139
  * @returns Parsed chain specification
128
140
  */
129
141
  parseChain(chainString: string): ChainSpec {
142
+ // Try DSL parser first for advanced syntax (parallel groups, loop counts, flags)
143
+ // Falls back to the simple split parser if DSL parsing fails
144
+ if (this.hasDSLConstructs(chainString)) {
145
+ try {
146
+ const dslSteps = parseChainDSL(chainString);
147
+ return this.dslToChainSpec(dslSteps, chainString);
148
+ } catch {
149
+ // DSL parse failed; fall through to simple parser
150
+ }
151
+ }
152
+
130
153
  const stepStrings = chainString.split("->").map(s => s.trim());
131
154
 
132
155
  const steps: ChainStep[] = stepStrings.map((step, index) => {
@@ -337,6 +360,47 @@ export class ChainRunner {
337
360
  return parsed;
338
361
  }
339
362
 
363
+ /**
364
+ * Detect if chainString uses DSL constructs that require chain-parser.
365
+ * DSL features: parallel(...), :loopCount, --with-context flag
366
+ */
367
+ private hasDSLConstructs(chainString: string): boolean {
368
+ return /\bparallel\s*\(/.test(chainString) ||
369
+ /\w+:\d+\b/.test(chainString) ||
370
+ /--with-context/.test(chainString);
371
+ }
372
+
373
+ /**
374
+ * Convert DSL AST steps (from chain-parser) to ChainSpec.
375
+ */
376
+ private dslToChainSpec(dslSteps: DSLChainStep[], chainString: string): ChainSpec {
377
+ const steps: ChainStep[] = dslSteps.map((dslStep, index) => {
378
+ // For parallel groups, use a synthetic step name
379
+ if (dslStep.parallel) {
380
+ return {
381
+ name: dslStep.name,
382
+ context: {
383
+ parallel: dslStep.parallel.map(p => ({ name: p.name, loopCount: p.loopCount, withContext: p.withContext, args: p.args })),
384
+ },
385
+ loopCount: dslStep.loopCount,
386
+ };
387
+ }
388
+ const step: ChainStep = { name: dslStep.name };
389
+ if (dslStep.loopCount) step.context = { ...step.context, loopCount: dslStep.loopCount };
390
+ if (dslStep.withContext) step.context = { ...step.context, withContext: true };
391
+ if (dslStep.args && dslStep.args.length > 0) step.context = { ...step.context, args: dslStep.args };
392
+ return step;
393
+ });
394
+
395
+ // Extract global overrides using existing logic
396
+ const globalModel = this.extractGlobalFlag(chainString, "global-model");
397
+ const globalSkill = this.extractGlobalFlag(chainString, "global-skill");
398
+ const globalThinking = this.extractGlobalFlag(chainString, "global-thinking") as "fast" | "standard" | "deep" | undefined;
399
+ const continueOnError = this.extractGlobalFlag(chainString, "continue-on-error") === "true";
400
+
401
+ return { steps, globalModel, globalSkill, globalThinking, continueOnError };
402
+ }
403
+
340
404
  /**
341
405
  * Sanitize identifier to prevent injection.
342
406
  */
@@ -1,28 +1,30 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { projectCrewRoot } from "../utils/paths.ts";
3
4
  import { assertSafePathId } from "../utils/safe-paths.ts";
5
+ import { logInternalError } from "../utils/internal-error.ts";
4
6
 
5
7
  export interface Checkpoint {
6
- runId: string;
7
- taskId: string;
8
- step: number;
9
- context: string;
10
- progress: string;
11
- savedAt: number;
12
- agentId: string;
13
- agentModel?: string;
8
+ runId: string;
9
+ taskId: string;
10
+ step: number;
11
+ context: string;
12
+ progress: string;
13
+ savedAt: number;
14
+ agentId: string;
15
+ agentModel?: string;
14
16
  }
15
17
 
16
18
  export interface CheckpointStore {
17
- save(checkpoint: Checkpoint): void;
18
- load(runId: string, taskId: string): Checkpoint | null;
19
- delete(runId: string, taskId: string): void;
20
- list(runId: string): Checkpoint[];
21
- hasCheckpoint(runId: string, taskId: string): boolean;
19
+ save(checkpoint: Checkpoint): void;
20
+ load(runId: string, taskId: string): Checkpoint | null;
21
+ delete(runId: string, taskId: string): void;
22
+ list(runId: string): Checkpoint[];
23
+ hasCheckpoint(runId: string, taskId: string): boolean;
22
24
  }
23
25
 
24
26
  interface CheckpointEntry {
25
- checkpoints: Record<string, Checkpoint>;
27
+ checkpoints: Record<string, Checkpoint>;
26
28
  }
27
29
 
28
30
  /**
@@ -30,222 +32,302 @@ interface CheckpointEntry {
30
32
  * Saves checkpoints as JSON files in .crew/state/runs/<runId>/checkpoints/
31
33
  */
32
34
  export class FileCheckpointStore implements CheckpointStore {
33
- private readonly stateRoot: string;
34
-
35
- constructor(stateRoot: string) {
36
- this.stateRoot = stateRoot;
37
- }
38
-
39
- private checkpointDir(): string {
40
- return path.join(this.stateRoot, "checkpoints");
41
- }
42
-
43
- private checkpointPath(taskId: string): string {
44
- return path.join(this.checkpointDir(), `${taskId}.json`);
45
- }
46
-
47
- private ensureDir(): void {
48
- const dir = this.checkpointDir();
49
- if (!fs.existsSync(dir)) {
50
- fs.mkdirSync(dir, { recursive: true });
51
- }
52
- }
53
-
54
- save(checkpoint: Checkpoint): void {
55
- // Validate taskId to prevent path traversal: the taskId is used to
56
- // build a file path under this.checkpointDir(). Without validation, a
57
- // malicious or buggy taskId like "../../../etc/passwd" could escape
58
- // the checkpoints directory.
59
- assertSafePathId("taskId", checkpoint.taskId);
60
- this.ensureDir();
61
- const p = this.checkpointPath(checkpoint.taskId);
62
- fs.writeFileSync(p, JSON.stringify(checkpoint, null, 2), "utf-8");
63
- }
64
-
65
- load(runId: string, taskId: string): Checkpoint | null {
66
- assertSafePathId("taskId", taskId);
67
- const p = this.checkpointPath(taskId);
68
- if (!fs.existsSync(p)) return null;
69
-
70
- try {
71
- const data = JSON.parse(fs.readFileSync(p, "utf-8")) as Checkpoint;
72
- // Verify it's for the correct run
73
- if (data.runId !== runId) return null;
74
- return data;
75
- } catch {
76
- return null;
77
- }
78
- }
79
-
80
- delete(runId: string, taskId: string): void {
81
- assertSafePathId("taskId", taskId);
82
- const p = this.checkpointPath(taskId);
83
- if (fs.existsSync(p)) {
84
- try {
85
- const data = JSON.parse(fs.readFileSync(p, "utf-8")) as Checkpoint;
86
- if (data.runId === runId) {
87
- fs.unlinkSync(p);
88
- }
89
- } catch {
90
- // File existed but couldn't read — delete it anyway
91
- try {
92
- fs.unlinkSync(p);
93
- } catch { /* ignore */ }
94
- }
95
- }
96
- }
97
-
98
- list(runId: string): Checkpoint[] {
99
- const dir = this.checkpointDir();
100
- if (!fs.existsSync(dir)) return [];
101
-
102
- return fs.readdirSync(dir)
103
- .filter((f) => f.endsWith(".json"))
104
- .map((f) => {
105
- try {
106
- return JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8")) as Checkpoint;
107
- } catch {
108
- return null;
109
- }
110
- })
111
- .filter((c): c is Checkpoint => c !== null && c.runId === runId);
112
- }
113
-
114
- hasCheckpoint(runId: string, taskId: string): boolean {
115
- return this.load(runId, taskId) !== null;
116
- }
35
+ private readonly stateRoot: string;
36
+
37
+ constructor(stateRoot: string) {
38
+ this.stateRoot = stateRoot;
39
+ }
40
+
41
+ private checkpointDir(): string {
42
+ return path.join(this.stateRoot, "checkpoints");
43
+ }
44
+
45
+ private checkpointPath(taskId: string): string {
46
+ return path.join(this.checkpointDir(), `${taskId}.json`);
47
+ }
48
+
49
+ private ensureDir(): void {
50
+ const dir = this.checkpointDir();
51
+ if (!fs.existsSync(dir)) {
52
+ fs.mkdirSync(dir, { recursive: true });
53
+ }
54
+ }
55
+
56
+ save(checkpoint: Checkpoint): void {
57
+ // Validate taskId to prevent path traversal: the taskId is used to
58
+ // build a file path under this.checkpointDir(). Without validation, a
59
+ // malicious or buggy taskId like "../../../etc/passwd" could escape
60
+ // the checkpoints directory.
61
+ assertSafePathId("taskId", checkpoint.taskId);
62
+ this.ensureDir();
63
+ const p = this.checkpointPath(checkpoint.taskId);
64
+ // Atomic write: write to temp file first, then rename, then fsync parent.
65
+ // This guarantees either the old file or the new file, never a partial
66
+ // write, even on network filesystems or certain journal modes.
67
+ const tmp = path.join(this.checkpointDir(), ".tmp.checkpoint");
68
+ fs.writeFileSync(tmp, JSON.stringify(checkpoint, null, 2), "utf-8");
69
+ fs.renameSync(tmp, p);
70
+ // fsync parent directory to ensure the rename is durable
71
+ const dirFd = fs.openSync(this.checkpointDir(), "r");
72
+ try {
73
+ fs.fsyncSync(dirFd);
74
+ } catch {
75
+ // EPERM on Windows: opening a directory and fsync-ing it is not supported
76
+ // on all Windows configurations. Best-effort — the rename is still atomic.
77
+ } finally {
78
+ fs.closeSync(dirFd);
79
+ }
80
+ }
81
+
82
+ load(runId: string, taskId: string): Checkpoint | null {
83
+ assertSafePathId("taskId", taskId);
84
+ const p = this.checkpointPath(taskId);
85
+ if (!fs.existsSync(p)) return null;
86
+
87
+ try {
88
+ const data = JSON.parse(fs.readFileSync(p, "utf-8")) as Checkpoint;
89
+ // Verify it's for the correct run
90
+ if (data.runId !== runId) return null;
91
+ return data;
92
+ } catch {
93
+ // File existed but JSON was corrupt — log and rename for later inspection
94
+ logInternalError("checkpoint-load", new Error("JSON parse failed"), `file=${p}`);
95
+ try {
96
+ fs.renameSync(p, `${p}.corrupt.${Date.now()}`);
97
+ } catch {
98
+ // Best effort — ignore rename failure
99
+ }
100
+ return null;
101
+ }
102
+ }
103
+
104
+ delete(runId: string, taskId: string): void {
105
+ assertSafePathId("taskId", taskId);
106
+ const p = this.checkpointPath(taskId);
107
+ if (fs.existsSync(p)) {
108
+ try {
109
+ const data = JSON.parse(
110
+ fs.readFileSync(p, "utf-8"),
111
+ ) as Checkpoint;
112
+ if (data.runId === runId) {
113
+ fs.unlinkSync(p);
114
+ }
115
+ } catch {
116
+ // File existed but couldn't read — delete it anyway
117
+ try {
118
+ fs.unlinkSync(p);
119
+ } catch {
120
+ /* ignore */
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ list(runId: string): Checkpoint[] {
127
+ const dir = this.checkpointDir();
128
+ if (!fs.existsSync(dir)) return [];
129
+
130
+ return fs
131
+ .readdirSync(dir)
132
+ .filter((f) => f.endsWith(".json"))
133
+ .map((f) => {
134
+ try {
135
+ return JSON.parse(
136
+ fs.readFileSync(path.join(dir, f), "utf-8"),
137
+ ) as Checkpoint;
138
+ } catch {
139
+ return null;
140
+ }
141
+ })
142
+ .filter((c): c is Checkpoint => c !== null && c.runId === runId);
143
+ }
144
+
145
+ hasCheckpoint(runId: string, taskId: string): boolean {
146
+ return this.load(runId, taskId) !== null;
147
+ }
117
148
  }
118
149
 
150
+ const MAX_STORES = 100;
119
151
  const _stores = new Map<string, FileCheckpointStore>();
120
152
 
121
153
  /**
122
154
  * Get checkpoint store for a run's state root.
155
+ * Uses LRU eviction when the store exceeds MAX_STORES entries.
123
156
  */
124
157
  export function getCheckpointStore(stateRoot: string): CheckpointStore {
125
- if (!_stores.has(stateRoot)) {
126
- _stores.set(stateRoot, new FileCheckpointStore(stateRoot));
127
- }
128
- return _stores.get(stateRoot)!;
158
+ if (!_stores.has(stateRoot)) {
159
+ if (_stores.size >= MAX_STORES) {
160
+ // Evict the oldest entry (first in insertion order)
161
+ const oldestKey = _stores.keys().next().value;
162
+ if (oldestKey !== undefined) {
163
+ _stores.delete(oldestKey);
164
+ }
165
+ }
166
+ _stores.set(stateRoot, new FileCheckpointStore(stateRoot));
167
+ }
168
+ return _stores.get(stateRoot)!;
129
169
  }
130
170
 
131
171
  /**
132
172
  * Clear all checkpoint stores (for testing).
133
173
  */
134
174
  export function clearCheckpointStores(): void {
135
- _stores.clear();
175
+ _stores.clear();
136
176
  }
137
177
 
138
178
  /**
139
179
  * Save a checkpoint during agent execution.
140
180
  */
141
181
  export function saveCheckpoint(
142
- runId: string,
143
- taskId: string,
144
- step: number,
145
- context: string,
146
- progress: string,
147
- agentId: string,
148
- agentModel?: string,
182
+ runId: string,
183
+ taskId: string,
184
+ step: number,
185
+ context: string,
186
+ progress: string,
187
+ agentId: string,
188
+ agentModel?: string,
189
+ cwd?: string,
149
190
  ): void {
150
- // Validate both runId and taskId to prevent path traversal: these are
151
- // used to build the file path under .crew/state/runs/<runId>/checkpoints/<taskId>.json.
152
- assertSafePathId("runId", runId);
153
- assertSafePathId("taskId", taskId);
154
- const checkpoint: Checkpoint = {
155
- runId,
156
- taskId,
157
- step,
158
- context,
159
- progress,
160
- savedAt: Date.now(),
161
- agentId,
162
- agentModel,
163
- };
164
-
165
- // State root is parent of checkpoints dir
166
- const stateRoot = path.join(process.cwd(), ".crew/state/runs", runId);
167
- const store = getCheckpointStore(stateRoot);
168
- store.save(checkpoint);
191
+ // Validate both runId and taskId to prevent path traversal: these are
192
+ // used to build the file path under <crewRoot>/state/runs/<runId>/checkpoints/<taskId>.json.
193
+ assertSafePathId("runId", runId);
194
+ assertSafePathId("taskId", taskId);
195
+ const checkpoint: Checkpoint = {
196
+ runId,
197
+ taskId,
198
+ step,
199
+ context,
200
+ progress,
201
+ savedAt: Date.now(),
202
+ agentId,
203
+ agentModel,
204
+ };
205
+
206
+ // State root is parent of checkpoints dir. Use projectCrewRoot() so the
207
+ // path lands in .pi/teams/state/runs/ for .pi-based projects (issue #29).
208
+ const stateRoot = path.join(
209
+ projectCrewRoot(cwd ?? process.cwd()),
210
+ "state",
211
+ "runs",
212
+ runId,
213
+ );
214
+ const store = getCheckpointStore(stateRoot);
215
+ store.save(checkpoint);
169
216
  }
170
217
 
171
218
  /**
172
219
  * Load a checkpoint for resuming.
173
220
  */
174
- export function loadCheckpoint(runId: string, taskId: string): Checkpoint | null {
175
- assertSafePathId("runId", runId);
176
- assertSafePathId("taskId", taskId);
177
- const stateRoot = path.join(process.cwd(), ".crew/state/runs", runId);
178
- const store = getCheckpointStore(stateRoot);
179
- return store.load(runId, taskId);
221
+ export function loadCheckpoint(
222
+ runId: string,
223
+ taskId: string,
224
+ cwd?: string,
225
+ ): Checkpoint | null {
226
+ assertSafePathId("runId", runId);
227
+ assertSafePathId("taskId", taskId);
228
+ const stateRoot = path.join(
229
+ projectCrewRoot(cwd ?? process.cwd()),
230
+ "state",
231
+ "runs",
232
+ runId,
233
+ );
234
+ const store = getCheckpointStore(stateRoot);
235
+ return store.load(runId, taskId);
180
236
  }
181
237
 
182
238
  /**
183
239
  * Delete a checkpoint after successful completion.
184
240
  */
185
- export function clearCheckpoint(runId: string, taskId: string): void {
186
- assertSafePathId("runId", runId);
187
- assertSafePathId("taskId", taskId);
188
- const stateRoot = path.join(process.cwd(), ".crew/state/runs", runId);
189
- const store = getCheckpointStore(stateRoot);
190
- store.delete(runId, taskId);
241
+ export function clearCheckpoint(
242
+ runId: string,
243
+ taskId: string,
244
+ cwd?: string,
245
+ ): void {
246
+ assertSafePathId("runId", runId);
247
+ assertSafePathId("taskId", taskId);
248
+ const stateRoot = path.join(
249
+ projectCrewRoot(cwd ?? process.cwd()),
250
+ "state",
251
+ "runs",
252
+ runId,
253
+ );
254
+ const store = getCheckpointStore(stateRoot);
255
+ store.delete(runId, taskId);
191
256
  }
192
257
 
193
258
  /**
194
259
  * Check if a checkpoint exists for a task.
195
260
  */
196
- export function hasCheckpoint(runId: string, taskId: string): boolean {
197
- assertSafePathId("runId", runId);
198
- assertSafePathId("taskId", taskId);
199
- const stateRoot = path.join(process.cwd(), ".crew/state/runs", runId);
200
- const store = getCheckpointStore(stateRoot);
201
- return store.hasCheckpoint(runId, taskId);
261
+ export function hasCheckpoint(
262
+ runId: string,
263
+ taskId: string,
264
+ cwd?: string,
265
+ ): boolean {
266
+ assertSafePathId("runId", runId);
267
+ assertSafePathId("taskId", taskId);
268
+ const stateRoot = path.join(
269
+ projectCrewRoot(cwd ?? process.cwd()),
270
+ "state",
271
+ "runs",
272
+ runId,
273
+ );
274
+ const store = getCheckpointStore(stateRoot);
275
+ return store.hasCheckpoint(runId, taskId);
202
276
  }
203
277
 
204
278
  /**
205
279
  * List all checkpoints for a run.
206
280
  */
207
- export function listCheckpoints(runId: string): Checkpoint[] {
208
- assertSafePathId("runId", runId);
209
- const stateRoot = path.join(process.cwd(), ".crew/state/runs", runId);
210
- const store = getCheckpointStore(stateRoot);
211
- return store.list(runId);
281
+ export function listCheckpoints(runId: string, cwd?: string): Checkpoint[] {
282
+ assertSafePathId("runId", runId);
283
+ const stateRoot = path.join(
284
+ projectCrewRoot(cwd ?? process.cwd()),
285
+ "state",
286
+ "runs",
287
+ runId,
288
+ );
289
+ const store = getCheckpointStore(stateRoot);
290
+ return store.list(runId);
212
291
  }
213
292
 
214
293
  /**
215
294
  * Format a checkpoint for display.
216
295
  */
217
296
  export function formatCheckpoint(checkpoint: Checkpoint): string {
218
- return [
219
- `## Checkpoint: ${checkpoint.taskId}`,
220
- "",
221
- `**Agent:** ${checkpoint.agentId}`,
222
- checkpoint.agentModel ? `**Model:** ${checkpoint.agentModel}` : "",
223
- "",
224
- `**Progress:** ${checkpoint.progress}`,
225
- "",
226
- `**Step:** ${checkpoint.step}`,
227
- `**Saved:** ${new Date(checkpoint.savedAt).toISOString()}`,
228
- "",
229
- `**Context:** ${checkpoint.context.slice(0, 300)}${checkpoint.context.length > 300 ? "..." : ""}`,
230
- ].filter(Boolean).join("\n");
297
+ return [
298
+ `## Checkpoint: ${checkpoint.taskId}`,
299
+ "",
300
+ `**Agent:** ${checkpoint.agentId}`,
301
+ checkpoint.agentModel ? `**Model:** ${checkpoint.agentModel}` : "",
302
+ "",
303
+ `**Progress:** ${checkpoint.progress}`,
304
+ "",
305
+ `**Step:** ${checkpoint.step}`,
306
+ `**Saved:** ${new Date(checkpoint.savedAt).toISOString()}`,
307
+ "",
308
+ `**Context:** ${checkpoint.context.slice(0, 300)}${checkpoint.context.length > 300 ? "..." : ""}`,
309
+ ]
310
+ .filter(Boolean)
311
+ .join("\n");
231
312
  }
232
313
 
233
314
  /**
234
315
  * Format all checkpoints for a run.
235
316
  */
236
- export function formatAllCheckpoints(runId: string): string {
237
- const checkpoints = listCheckpoints(runId);
238
- if (checkpoints.length === 0) {
239
- return `No checkpoints found for run ${runId}`;
240
- }
241
-
242
- return [
243
- `# Checkpoints: ${runId}`,
244
- "",
245
- ...checkpoints.map((cp, i) =>
246
- `${i + 1}. **${cp.taskId}** ${cp.progress} (${new Date(cp.savedAt).toLocaleString()})`,
247
- ),
248
- "",
249
- `Use \`team action='resume' runId=${runId} taskId=<taskId>\` to resume.`,
250
- ].join("\n");
317
+ export function formatAllCheckpoints(runId: string, cwd?: string): string {
318
+ const checkpoints = listCheckpoints(runId, cwd);
319
+ if (checkpoints.length === 0) {
320
+ return `No checkpoints found for run ${runId}`;
321
+ }
322
+
323
+ return [
324
+ `# Checkpoints: ${runId}`,
325
+ "",
326
+ ...checkpoints.map(
327
+ (cp, i) =>
328
+ `${i + 1}. **${cp.taskId}** — ${cp.progress} (${new Date(cp.savedAt).toLocaleString()})`,
329
+ ),
330
+ "",
331
+ `Use \`team action='resume' runId=${runId} taskId=<taskId>\` to resume.`,
332
+ ].join("\n");
251
333
  }