pi-crew 0.5.25 → 0.6.1

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 (81) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +13 -11
  3. package/docs/patterns/command-agent-skill.md +71 -0
  4. package/package.json +1 -1
  5. package/skills/council/SKILL.md +163 -0
  6. package/src/agents/agent-config.ts +4 -1
  7. package/src/agents/discover-agents.ts +1 -0
  8. package/src/benchmark/feedback-loop.ts +4 -2
  9. package/src/extension/cross-extension-rpc.ts +48 -0
  10. package/src/extension/registration/commands.ts +2 -1
  11. package/src/extension/registration/subagent-tools.ts +2 -0
  12. package/src/extension/registration/team-tool.ts +2 -0
  13. package/src/extension/registration/viewers.ts +1 -0
  14. package/src/extension/run-export.ts +16 -1
  15. package/src/extension/run-import.ts +16 -0
  16. package/src/extension/team-tool/anchor.ts +5 -1
  17. package/src/extension/team-tool/api.ts +9 -4
  18. package/src/extension/team-tool/config-patch.ts +15 -1
  19. package/src/extension/team-tool.ts +2 -1
  20. package/src/hooks/registry.ts +9 -1
  21. package/src/hooks/types.ts +14 -0
  22. package/src/i18n.ts +15 -2
  23. package/src/observability/exporters/otlp-exporter.ts +73 -0
  24. package/src/runtime/adaptive-plan.ts +24 -0
  25. package/src/runtime/agent-control.ts +6 -3
  26. package/src/runtime/async-runner.ts +58 -3
  27. package/src/runtime/background-runner.ts +1 -1
  28. package/src/runtime/chain-parser.ts +192 -0
  29. package/src/runtime/chain-runner.ts +58 -0
  30. package/src/runtime/child-pi.ts +1 -1
  31. package/src/runtime/crew-agent-records.ts +4 -3
  32. package/src/runtime/cross-extension-rpc.ts +34 -8
  33. package/src/runtime/diagnostic-export.ts +3 -4
  34. package/src/runtime/dynamic-script-runner.ts +7 -7
  35. package/src/runtime/foreground-watchdog.ts +2 -2
  36. package/src/runtime/intercom-bridge.ts +178 -0
  37. package/src/runtime/live-agent-manager.ts +6 -3
  38. package/src/runtime/live-irc.ts +4 -2
  39. package/src/runtime/parallel-utils.ts +2 -1
  40. package/src/runtime/plan-templates.ts +200 -0
  41. package/src/runtime/post-checks.ts +10 -3
  42. package/src/runtime/run-drift.ts +220 -0
  43. package/src/runtime/sandbox.ts +26 -20
  44. package/src/runtime/semaphore.ts +2 -1
  45. package/src/runtime/settings-store.ts +14 -2
  46. package/src/runtime/skill-effectiveness.ts +4 -2
  47. package/src/runtime/skill-instructions.ts +4 -1
  48. package/src/runtime/subagent-manager.ts +20 -2
  49. package/src/runtime/subprocess-tool-registry.ts +2 -2
  50. package/src/runtime/task-graph.ts +79 -0
  51. package/src/runtime/task-id.ts +148 -0
  52. package/src/runtime/task-packet.ts +13 -1
  53. package/src/runtime/task-runner/context-retrieval.ts +172 -0
  54. package/src/runtime/task-runner.ts +39 -1
  55. package/src/runtime/team-runner.ts +7 -0
  56. package/src/runtime/usage-tracker.ts +4 -2
  57. package/src/runtime/verification-gates.ts +36 -9
  58. package/src/state/contracts.ts +2 -1
  59. package/src/state/event-log.ts +16 -5
  60. package/src/state/hook-instinct-bridge.ts +2 -1
  61. package/src/state/locks.ts +9 -2
  62. package/src/state/memory-store.ts +244 -0
  63. package/src/state/observation-store.ts +177 -0
  64. package/src/state/state-store.ts +4 -2
  65. package/src/state/task-claims.ts +9 -2
  66. package/src/tools/safe-bash.ts +69 -20
  67. package/src/types/new-api-types.ts +10 -5
  68. package/src/ui/keybinding-map.ts +2 -1
  69. package/src/ui/run-action-dispatcher.ts +2 -1
  70. package/src/ui/status-colors.ts +2 -1
  71. package/src/ui/syntax-highlight.ts +2 -1
  72. package/src/ui/tool-render.ts +13 -3
  73. package/src/utils/fingerprint.ts +183 -0
  74. package/src/utils/fs-watch.ts +4 -2
  75. package/src/utils/gh-protocol.ts +2 -1
  76. package/src/utils/safe-paths.ts +6 -0
  77. package/src/workflows/discover-workflows.ts +5 -1
  78. package/src/workflows/intermediate-store.ts +173 -0
  79. package/src/workflows/workflow-config.ts +8 -0
  80. package/src/worktree/cleanup.ts +8 -5
  81. package/src/worktree/worktree-manager.ts +1 -1
@@ -0,0 +1,244 @@
1
+ /**
2
+ * 4-Tier Memory Consolidation System.
3
+ *
4
+ * Pattern origin: agentmemory — Working → Episodic → Semantic → Procedural.
5
+ * Ebbinghaus decay curve: S(t) = e^(-t/s) where s = strength.
6
+ * Frequently accessed memories strengthen. Tier promotion on access count.
7
+ * Token-budgeted injection for context window management.
8
+ *
9
+ * Tiers:
10
+ * - Working: Current run observations (capacity: 50)
11
+ * - Episodic: Recent run summaries (capacity: 200)
12
+ * - Semantic: Extracted patterns/knowledge (capacity: 1000)
13
+ * - Procedural: Learned skills/methods (capacity: 100)
14
+ */
15
+
16
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
17
+ import { logInternalError } from "../utils/internal-error.ts";
18
+
19
+ // ── Types ────────────────────────────────────────────────────────────────
20
+
21
+ export type MemoryTier = "working" | "episodic" | "semantic" | "procedural";
22
+
23
+ export interface Memory {
24
+ id: string;
25
+ tier: MemoryTier;
26
+ content: string;
27
+ strength: number; // 0.0–1.0
28
+ accessCount: number;
29
+ lastAccessed: number; // epoch ms
30
+ createdAt: number; // epoch ms
31
+ tags: string[];
32
+ sourceRunId?: string;
33
+ }
34
+
35
+ export interface MemoryConfig {
36
+ workingCapacity: number;
37
+ episodicCapacity: number;
38
+ semanticCapacity: number;
39
+ proceduralCapacity: number;
40
+ decayRate: number; // Ebbinghaus parameter (higher = faster decay)
41
+ tokenBudget: number; // max tokens to inject
42
+ }
43
+
44
+ const DEFAULT_CONFIG: MemoryConfig = {
45
+ workingCapacity: 50,
46
+ episodicCapacity: 200,
47
+ semanticCapacity: 1000,
48
+ proceduralCapacity: 100,
49
+ decayRate: 0.001, // slow decay
50
+ tokenBudget: 2000,
51
+ };
52
+
53
+ const TIER_CAPACITIES: Record<MemoryTier, keyof MemoryConfig> = {
54
+ working: "workingCapacity",
55
+ episodic: "episodicCapacity",
56
+ semantic: "semanticCapacity",
57
+ procedural: "proceduralCapacity",
58
+ };
59
+
60
+ // ── Memory Operations ────────────────────────────────────────────────────
61
+
62
+ /**
63
+ * Compute current strength using Ebbinghaus decay.
64
+ * S(t) = strength * e^(-elapsed_ms * decayRate)
65
+ */
66
+ export function computeCurrentStrength(memory: Memory, config = DEFAULT_CONFIG): number {
67
+ const elapsedMs = Date.now() - memory.lastAccessed;
68
+ return memory.strength * Math.exp(-elapsedMs * config.decayRate);
69
+ }
70
+
71
+ /**
72
+ * Access a memory — strengthens it and updates access count.
73
+ * After N accesses, memory may be promoted to a higher tier.
74
+ */
75
+ export function accessMemory(memory: Memory): Memory {
76
+ const newCount = memory.accessCount + 1;
77
+
78
+ // Strengthen: capped at 1.0
79
+ const newStrength = Math.min(1.0, memory.strength + 0.1);
80
+
81
+ // Tier promotion thresholds
82
+ let newTier = memory.tier;
83
+ if (newCount >= 10 && memory.tier === "working") newTier = "episodic";
84
+ if (newCount >= 20 && memory.tier === "episodic") newTier = "semantic";
85
+ if (newCount >= 30 && memory.tier === "semantic") newTier = "procedural";
86
+
87
+ return {
88
+ ...memory,
89
+ strength: newStrength,
90
+ accessCount: newCount,
91
+ lastAccessed: Date.now(),
92
+ tier: newTier,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Create a new working memory.
98
+ */
99
+ export function createMemory(content: string, tags: string[] = [], sourceRunId?: string): Memory {
100
+ return {
101
+ id: `mem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
102
+ tier: "working",
103
+ content,
104
+ strength: 0.5,
105
+ accessCount: 0,
106
+ lastAccessed: Date.now(),
107
+ createdAt: Date.now(),
108
+ tags,
109
+ sourceRunId,
110
+ };
111
+ }
112
+
113
+ // ── Memory Store ─────────────────────────────────────────────────────────
114
+
115
+ export class MemoryStore {
116
+ private memories = new Map<string, Memory>();
117
+ private config: MemoryConfig;
118
+ private storePath?: string;
119
+
120
+ constructor(config: Partial<MemoryConfig> = {}, storePath?: string) {
121
+ this.config = { ...DEFAULT_CONFIG, ...config };
122
+ this.storePath = storePath;
123
+ if (storePath && existsSync(storePath)) {
124
+ this.load();
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Add a memory to the store, enforcing capacity limits.
130
+ */
131
+ add(memory: Memory): void {
132
+ // Evict weakest if at capacity
133
+ const capacity = this.config[TIER_CAPACITIES[memory.tier]];
134
+ const tierMemories = [...this.memories.values()].filter((m) => m.tier === memory.tier);
135
+
136
+ if (tierMemories.length >= capacity) {
137
+ // Evict weakest memory in this tier
138
+ const weakest = tierMemories
139
+ .map((m) => ({ id: m.id, strength: computeCurrentStrength(m, this.config) }))
140
+ .sort((a, b) => a.strength - b.strength)[0];
141
+ if (weakest) this.memories.delete(weakest.id);
142
+ }
143
+
144
+ this.memories.set(memory.id, memory);
145
+ }
146
+
147
+ /**
148
+ * Search memories by tags, returning strongest matches first.
149
+ */
150
+ search(query: string, tags: string[] = [], limit = 10): Memory[] {
151
+ const queryLower = query.toLowerCase();
152
+
153
+ let results = [...this.memories.values()]
154
+ // Apply decay
155
+ .map((m) => ({ ...m, strength: computeCurrentStrength(m, this.config) }))
156
+ // Filter by minimum strength
157
+ .filter((m) => m.strength > 0.1)
158
+ // Score relevance
159
+ .map((m) => {
160
+ let score = m.strength;
161
+ if (m.content.toLowerCase().includes(queryLower)) score += 0.3;
162
+ if (tags.some((t) => m.tags.includes(t))) score += 0.2;
163
+ return { ...m, strength: score };
164
+ })
165
+ .sort((a, b) => b.strength - a.strength)
166
+ .slice(0, limit);
167
+
168
+ // Access side effect: strengthen returned memories
169
+ results = results.map((m) => accessMemory(m));
170
+ for (const m of results) {
171
+ this.memories.set(m.id, m);
172
+ }
173
+
174
+ return results;
175
+ }
176
+
177
+ /**
178
+ * Inject memories into a prompt within a token budget.
179
+ * Returns formatted text block.
180
+ */
181
+ inject(query: string, tags: string[] = []): string {
182
+ const results = this.search(query, tags, 20);
183
+
184
+ if (results.length === 0) return "";
185
+
186
+ // Estimate tokens (4 chars ≈ 1 token)
187
+ const budget = this.config.tokenBudget;
188
+ let usedTokens = 0;
189
+ const selected: Memory[] = [];
190
+
191
+ for (const memory of results) {
192
+ const tokens = Math.ceil(memory.content.length / 4);
193
+ if (usedTokens + tokens > budget) break;
194
+ selected.push(memory);
195
+ usedTokens += tokens;
196
+ }
197
+
198
+ if (selected.length === 0) return "";
199
+
200
+ return "## Relevant Context from Previous Runs\n\n" +
201
+ selected.map((m) => `- [${m.tier}] ${m.content}`).join("\n") +
202
+ "\n";
203
+ }
204
+
205
+ /**
206
+ * Get count of memories per tier.
207
+ */
208
+ get stats(): Record<MemoryTier, number> {
209
+ const counts: Record<MemoryTier, number> = { working: 0, episodic: 0, semantic: 0, procedural: 0 };
210
+ for (const m of this.memories.values()) {
211
+ counts[m.tier]++;
212
+ }
213
+ return counts;
214
+ }
215
+
216
+ /**
217
+ * Persist memories to disk.
218
+ */
219
+ save(): void {
220
+ if (!this.storePath) return;
221
+ const entries = [...this.memories.values()];
222
+ try {
223
+ mkdirSync(path.dirname(this.storePath), { recursive: true });
224
+ writeFileSync(this.storePath, JSON.stringify(entries, null, 2), "utf-8");
225
+ } catch (error) {
226
+ logInternalError("memory-store.save", error, `path=${this.storePath}`);
227
+ }
228
+ }
229
+
230
+ private load(): void {
231
+ if (!this.storePath) return;
232
+ try {
233
+ const data = JSON.parse(readFileSync(this.storePath, "utf-8")) as Memory[];
234
+ for (const m of data) {
235
+ this.memories.set(m.id, m);
236
+ }
237
+ } catch (error) {
238
+ logInternalError("memory-store.load", error, `path=${this.storePath}`);
239
+ }
240
+ }
241
+ }
242
+
243
+ // Need path for mkdirSync in save()
244
+ import path from "node:path";
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Observation capture and compression system.
3
+ *
4
+ * Pattern origin: claude-mem — captures tool usage across sessions,
5
+ * compresses via AI, injects into future sessions.
6
+ *
7
+ * This module provides the observation store and compression logic.
8
+ * Actual capture hooks into the lifecycle events (Pattern 12).
9
+ */
10
+
11
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs";
12
+ import { logInternalError } from "../utils/internal-error.ts";
13
+
14
+ // ── Types ────────────────────────────────────────────────────────────────
15
+
16
+ export interface Observation {
17
+ tool: string;
18
+ input: string;
19
+ output: string;
20
+ filesRead: string[];
21
+ filesModified: string[];
22
+ timestamp: number;
23
+ sessionId: string;
24
+ taskId?: string;
25
+ }
26
+
27
+ export interface CompressedObservation {
28
+ summary: string;
29
+ patterns: string[];
30
+ decisions: string[];
31
+ filesAffected: string[];
32
+ relevanceScore: number;
33
+ timestamp: number;
34
+ sessionId: string;
35
+ }
36
+
37
+ export interface ObservationStoreConfig {
38
+ maxObservations: number;
39
+ maxCompressed: number;
40
+ privacyTags: string[]; // tags to strip before storage
41
+ }
42
+
43
+ const DEFAULT_CONFIG: ObservationStoreConfig = {
44
+ maxObservations: 1000,
45
+ maxCompressed: 200,
46
+ privacyTags: ["<private>", "<secret>", "<credentials>"],
47
+ };
48
+
49
+ // ── Privacy ──────────────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Strip privacy-tagged content from a string.
53
+ */
54
+ export function stripPrivacyTags(content: string, config = DEFAULT_CONFIG): string {
55
+ let result = content;
56
+ for (const tag of config.privacyTags) {
57
+ const openTag = tag;
58
+ const closeTag = tag.replace("<", "</");
59
+ // Remove everything between open and close tags
60
+ const regex = new RegExp(`${escapeRegex(openTag)}[\\s\\S]*?${escapeRegex(closeTag)}`, "gi");
61
+ result = result.replace(regex, "[REDACTED]");
62
+ }
63
+ return result;
64
+ }
65
+
66
+ function escapeRegex(str: string): string {
67
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
68
+ }
69
+
70
+ // ── Observation Store ────────────────────────────────────────────────────
71
+
72
+ export class ObservationStore {
73
+ private observations: Observation[] = [];
74
+ private compressed: CompressedObservation[] = [];
75
+ private config: ObservationStoreConfig;
76
+ private storePath: string;
77
+
78
+ constructor(storePath: string, config: Partial<ObservationStoreConfig> = {}) {
79
+ this.config = { ...DEFAULT_CONFIG, ...config };
80
+ this.storePath = storePath;
81
+ if (existsSync(storePath)) {
82
+ this.load();
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Record a new observation.
88
+ */
89
+ record(observation: Observation): void {
90
+ // Strip privacy
91
+ const sanitized: Observation = {
92
+ ...observation,
93
+ input: stripPrivacyTags(observation.input, this.config),
94
+ output: stripPrivacyTags(observation.output, this.config),
95
+ };
96
+
97
+ this.observations.push(sanitized);
98
+
99
+ // Enforce capacity
100
+ if (this.observations.length > this.config.maxObservations) {
101
+ this.observations = this.observations.slice(-this.config.maxObservations);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Get recent observations.
107
+ */
108
+ getRecent(count = 10): Observation[] {
109
+ return this.observations.slice(-count);
110
+ }
111
+
112
+ /**
113
+ * Store a compressed observation.
114
+ */
115
+ addCompressed(compressed: CompressedObservation): void {
116
+ this.compressed.push(compressed);
117
+
118
+ if (this.compressed.length > this.config.maxCompressed) {
119
+ this.compressed = this.compressed.slice(-this.config.maxCompressed);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Get compressed observations for injection.
125
+ */
126
+ getCompressed(limit = 5): CompressedObservation[] {
127
+ return this.compressed
128
+ .sort((a, b) => b.relevanceScore - a.relevanceScore)
129
+ .slice(0, limit);
130
+ }
131
+
132
+ /**
133
+ * Format compressed observations for prompt injection.
134
+ */
135
+ injectCompressed(limit = 5): string {
136
+ const items = this.getCompressed(limit);
137
+ if (items.length === 0) return "";
138
+
139
+ return "## Observations from Previous Sessions\n\n" +
140
+ items.map((o) =>
141
+ `### ${o.summary}\n` +
142
+ `Patterns: ${o.patterns.join(", ")}\n` +
143
+ `Decisions: ${o.decisions.join(", ")}\n` +
144
+ `Files: ${o.filesAffected.join(", ")}`,
145
+ ).join("\n\n") +
146
+ "\n";
147
+ }
148
+
149
+ /**
150
+ * Persist to disk.
151
+ */
152
+ save(): void {
153
+ try {
154
+ mkdirSync(this.storePath.substring(0, this.storePath.lastIndexOf("/")), { recursive: true });
155
+ writeFileSync(this.storePath, JSON.stringify({
156
+ observations: this.observations,
157
+ compressed: this.compressed,
158
+ }, null, 2), "utf-8");
159
+ } catch (error) {
160
+ logInternalError("observation-store.save", error, `path=${this.storePath}`);
161
+ }
162
+ }
163
+
164
+ get stats(): { observations: number; compressed: number } {
165
+ return { observations: this.observations.length, compressed: this.compressed.length };
166
+ }
167
+
168
+ private load(): void {
169
+ try {
170
+ const data = JSON.parse(readFileSync(this.storePath, "utf-8"));
171
+ if (Array.isArray(data.observations)) this.observations = data.observations;
172
+ if (Array.isArray(data.compressed)) this.compressed = data.compressed;
173
+ } catch (error) {
174
+ logInternalError("observation-store.load", error, `path=${this.storePath}`);
175
+ }
176
+ }
177
+ }
@@ -208,7 +208,8 @@ export function saveRunTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]):
208
208
  * intended use case. Single-update + read-update loops (e.g.
209
209
  * persistSingleTaskUpdate) should keep using saveRunTasks.
210
210
  */
211
- export function saveRunTasksCoalesced(manifest: TeamRunManifest, tasks: TeamTaskState[]): void {
211
+ /** @internal */
212
+ function saveRunTasksCoalesced(manifest: TeamRunManifest, tasks: TeamTaskState[]): void {
212
213
  atomicWriteJsonCoalesced(manifest.tasksPath, tasks);
213
214
  invalidateRunCache(manifest.stateRoot);
214
215
  }
@@ -226,7 +227,8 @@ export async function saveRunTasksAsync(manifest: TeamRunManifest, tasks: TeamTa
226
227
  * This is acceptable because crash recovery detects and repairs
227
228
  * inconsistent state on next session start.
228
229
  */
229
- export async function saveManifestAndTasksAtomic(manifest: TeamRunManifest, tasks: TeamTaskState[]): Promise<void> {
230
+ /** @internal */
231
+ async function saveManifestAndTasksAtomic(manifest: TeamRunManifest, tasks: TeamTaskState[]): Promise<void> {
230
232
  await withRunLock(manifest, async () => {
231
233
  await Promise.all([
232
234
  atomicWriteJsonAsync(path.join(manifest.stateRoot, "manifest.json"), manifest),
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import type { TeamTaskState } from "./types.ts";
3
3
 
4
4
  export interface TaskClaimState {
@@ -18,8 +18,15 @@ export function isTaskClaimExpired(claim: TaskClaimState | undefined, now = new
18
18
  return Number.isFinite(parsed) ? parsed <= now.getTime() : true;
19
19
  }
20
20
 
21
+ export function timingSafeTokenMatch(a: string, b: string): boolean {
22
+ const bufA = Buffer.from(String(a));
23
+ const bufB = Buffer.from(String(b));
24
+ if (bufA.length !== bufB.length) return false;
25
+ return timingSafeEqual(bufA, bufB);
26
+ }
27
+
21
28
  export function canUseTaskClaim(task: Pick<TeamTaskState, "claim">, owner: string, token: string, now = new Date()): boolean {
22
- return task.claim?.owner === owner && task.claim.token === token && !isTaskClaimExpired(task.claim, now);
29
+ return task.claim?.owner === owner && timingSafeTokenMatch(task.claim.token, token) && !isTaskClaimExpired(task.claim, now);
23
30
  }
24
31
 
25
32
  export function claimTask<T extends TeamTaskState>(task: T, owner: string, leaseMs?: number, now = new Date()): T {
@@ -39,7 +39,8 @@ const DANGEROUS_PATTERNS = [
39
39
 
40
40
  /**
41
41
  * Linear-time check if command contains a dangerous rm pattern like "rm -rf /" or "rm -rf ~"
42
- * Replaces O(n²) regex backtracking with O(n) string scanning
42
+ * Replaces O(n²) regex backtracking with O(n) string scanning.
43
+ * Expanded to also block: rm -rf /etc/*, rm --recursive --force /, rm -rf ~/.ssh, etc.
43
44
  */
44
45
  function matchesDangerousRm(command: string): boolean {
45
46
  let pos = 0;
@@ -56,32 +57,75 @@ function matchesDangerousRm(command: string): boolean {
56
57
  // Must be followed by whitespace
57
58
  const afterRm = rmIdx + 2;
58
59
  if (afterRm >= len || /\s/.test(command[afterRm])) {
59
- // Found "rm " - now check for -rf flags followed by / or ~
60
+ // Found "rm " - now check for recursive/force flags
60
61
  let p = afterRm + 1;
62
+ let hasR = false;
63
+ let hasF = false;
61
64
  while (p < len) {
62
65
  // Skip whitespace
63
66
  while (p < len && /\s/.test(command[p])) p++;
64
67
  if (p >= len) break;
65
- // Check for flag
66
- if (command[p] !== "-") break;
67
- p++;
68
- let hasR = false, hasF = false;
69
- while (p < len && /[a-zA-Z]/.test(command[p])) {
70
- if (command[p] === "r" || command[p] === "R") hasR = true;
71
- if (command[p] === "f" || command[p] === "F") hasF = true;
68
+ // Check for short flags (-r, -f, -rf, -R, -F, etc.)
69
+ if (command[p] === "-" && p + 1 < len && /[a-zA-Z]/.test(command[p + 1]) && command[p + 1] !== "-") {
72
70
  p++;
71
+ while (p < len && /[a-zA-Z]/.test(command[p])) {
72
+ if (command[p] === "r" || command[p] === "R") hasR = true;
73
+ if (command[p] === "f" || command[p] === "F") hasF = true;
74
+ p++;
75
+ }
76
+ // Skip whitespace after flag
77
+ while (p < len && /\s/.test(command[p])) p++;
78
+ continue;
73
79
  }
74
- if (!hasR && !hasF) break; // Flag must have r or f
75
- // Skip whitespace after flag
76
- while (p < len && /\s/.test(command[p])) p++;
77
- }
78
- // Now check if followed by / or ~ (end or whitespace)
79
- if (p < len && (command[p] === "/" || command[p] === "~")) {
80
- const afterSlash = p + 1;
81
- if (afterSlash >= len || /\s/.test(command[afterSlash]) || command[afterSlash] === ";") {
82
- return true; // Dangerous!
80
+ // Check for long flags (--recursive, --force)
81
+ if (command[p] === "-" && p + 1 < len && command[p + 1] === "-") {
82
+ p += 2;
83
+ const flagStart = p;
84
+ while (p < len && /[a-zA-Z]/.test(command[p])) p++;
85
+ const flagName = command.slice(flagStart, p);
86
+ if (flagName === "recursive") hasR = true;
87
+ if (flagName === "force") hasF = true;
88
+ // Skip whitespace after flag
89
+ while (p < len && /\s/.test(command[p])) p++;
90
+ continue;
83
91
  }
92
+ // Not a flag — stop parsing flags
93
+ break;
94
+ }
95
+ // Must have both -r and -f (or equivalents) to be dangerous
96
+ if (!hasR || !hasF) {
97
+ pos = rmIdx + 1;
98
+ continue;
99
+ }
100
+ // Now check if followed by dangerous targets
101
+ if (p >= len) {
102
+ pos = rmIdx + 1;
103
+ continue;
104
+ }
105
+ // Block: ~ (home directory references)
106
+ const charAtP = command[p];
107
+ if (charAtP === "~") return true; // Home directory reference
108
+ // Block: / (root or dangerous system paths)
109
+ if (charAtP === "/") {
110
+ // Exact root '/' with nothing after
111
+ if (p + 1 >= len || /\s/.test(command[p + 1]) || command[p + 1] === ";") return true;
112
+ // Block dangerous system paths
113
+ const rest = command.slice(p);
114
+ if (/^\/etc[\/\s;]/.test(rest) || rest === "/etc") return true;
115
+ if ((/^\/var\/(?!tmp)/.test(rest)) || rest === "/var") return true;
116
+ if (/^\/usr[\/\s;]/.test(rest) || rest === "/usr") return true;
117
+ if (/^\/boot[\/\s;]/.test(rest) || rest === "/boot") return true;
118
+ if (/^\/sys[\/\s;]/.test(rest) || rest === "/sys") return true;
119
+ if (/^\/proc[\/\s;]/.test(rest) || rest === "/proc") return true;
120
+ if (/^\/dev[\/\s;]/.test(rest) || rest === "/dev") return true;
121
+ if (/^\/root[\/\s;]/.test(rest) || rest === "/root") return true;
122
+ if (/^\/home[\/\s;]/.test(rest) || rest === "/home") return true;
123
+ // /tmp/ and other non-system absolute paths are allowed
84
124
  }
125
+ // Check for sensitive relative paths: .ssh, .gnupg
126
+ const rest = command.slice(p);
127
+ if (/^\.ssh[\/\\\s;]/.test(rest)) return true;
128
+ if (/^\.gnupg[\/\\\s;]/.test(rest)) return true;
85
129
  }
86
130
  pos = rmIdx + 1;
87
131
  }
@@ -172,8 +216,13 @@ export function isDangerous(command: string, options: SafeBashOptions = {}): str
172
216
  }
173
217
  }
174
218
 
175
- // Normalize: remove line continuations, collapse whitespace
176
- const normalized = command.replace(/\\\n/g, " ").replace(/\s+/g, " ").trim();
219
+ // Normalize: strip ANSI escapes and control chars, remove line continuations, collapse whitespace
220
+ const normalized = command
221
+ .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') // strip ANSI escapes
222
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '') // strip control chars
223
+ .replace(/\\\n/g, " ")
224
+ .replace(/\s+/g, " ")
225
+ .trim();
177
226
 
178
227
  // Check allow patterns first (overrides)
179
228
  for (const pattern of allowPatterns) {
@@ -11,24 +11,29 @@ export type {
11
11
  // Using AgentEndEvent and AgentStartEvent instead
12
12
 
13
13
  // Type guards for pi-crew usage
14
- export function isToolEvent(event: AgentSessionEvent): boolean {
14
+ /** @internal */
15
+ function isToolEvent(event: AgentSessionEvent): boolean {
15
16
  return event.type === "tool_execution_start" ||
16
17
  event.type === "tool_execution_update" ||
17
18
  event.type === "tool_execution_end";
18
19
  }
19
20
 
20
- export function isAgentLifecycleEvent(event: AgentSessionEvent): boolean {
21
+ /** @internal */
22
+ function isAgentLifecycleEvent(event: AgentSessionEvent): boolean {
21
23
  return event.type === "agent_start" || event.type === "agent_end";
22
24
  }
23
25
 
24
- export function isCompactionEvent(event: AgentSessionEvent): boolean {
26
+ /** @internal */
27
+ function isCompactionEvent(event: AgentSessionEvent): boolean {
25
28
  return event.type === "compaction_start" || event.type === "compaction_end";
26
29
  }
27
30
 
28
- export function isRetryEvent(event: AgentSessionEvent): boolean {
31
+ /** @internal */
32
+ function isRetryEvent(event: AgentSessionEvent): boolean {
29
33
  return event.type === "auto_retry_start" || event.type === "auto_retry_end";
30
34
  }
31
35
 
32
- export function isQueueEvent(event: AgentSessionEvent): boolean {
36
+ /** @internal */
37
+ function isQueueEvent(event: AgentSessionEvent): boolean {
33
38
  return event.type === "queue_update";
34
39
  }
@@ -21,7 +21,8 @@ export const DASHBOARD_KEYS = {
21
21
  notification: { dismissAll: ["H"] },
22
22
  } as const;
23
23
 
24
- export const KEY_RESERVED = new Set<string>([
24
+ /** @internal */
25
+ const KEY_RESERVED = new Set<string>([
25
26
  ...DASHBOARD_KEYS.close,
26
27
  ...DASHBOARD_KEYS.select,
27
28
  ...Object.values(DASHBOARD_KEYS.root).flat(),
@@ -111,7 +111,8 @@ export async function dispatchDiagnosticExport(ctx: ExtensionContext, runId: str
111
111
  }
112
112
  }
113
113
 
114
- export function defaultNudgeAgentId(ctx: Pick<ExtensionContext, "cwd">, runId: string): string | undefined {
114
+ /** @internal */
115
+ function defaultNudgeAgentId(ctx: Pick<ExtensionContext, "cwd">, runId: string): string | undefined {
115
116
  const loaded = loadRunManifestById(ctx.cwd, runId);
116
117
  if (!loaded) return undefined;
117
118
  return readCrewAgents(loaded.manifest).find((agent) => agent.status === "running" || agent.status === "queued")?.taskId;
@@ -47,7 +47,8 @@ export function iconForStatus(status: RunStatus, options?: { runningGlyph?: stri
47
47
  }
48
48
  }
49
49
 
50
- export function colorForActivity(activityState: string | undefined): CrewThemeColor {
50
+ /** @internal */
51
+ function colorForActivity(activityState: string | undefined): CrewThemeColor {
51
52
  if (activityState === "needs_attention") return "warning";
52
53
  if (activityState === "stale") return "error";
53
54
  return "dim";
@@ -22,7 +22,8 @@ function buildCliTheme(theme: CrewTheme): Record<string, (text: string) => strin
22
22
  };
23
23
  }
24
24
 
25
- export function detectLanguageFromPath(filePath: string): string | undefined {
25
+ /** @internal */
26
+ function detectLanguageFromPath(filePath: string): string | undefined {
26
27
  const ext = filePath.split(".").pop()?.toLowerCase();
27
28
  if (!ext) return undefined;
28
29
  return languageMap[ext];