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.
- package/CHANGELOG.md +99 -0
- package/README.md +13 -11
- package/docs/patterns/command-agent-skill.md +71 -0
- package/package.json +1 -1
- package/skills/council/SKILL.md +163 -0
- package/src/agents/agent-config.ts +4 -1
- package/src/agents/discover-agents.ts +1 -0
- package/src/benchmark/feedback-loop.ts +4 -2
- package/src/extension/cross-extension-rpc.ts +48 -0
- package/src/extension/registration/commands.ts +2 -1
- package/src/extension/registration/subagent-tools.ts +2 -0
- package/src/extension/registration/team-tool.ts +2 -0
- package/src/extension/registration/viewers.ts +1 -0
- package/src/extension/run-export.ts +16 -1
- package/src/extension/run-import.ts +16 -0
- package/src/extension/team-tool/anchor.ts +5 -1
- package/src/extension/team-tool/api.ts +9 -4
- package/src/extension/team-tool/config-patch.ts +15 -1
- package/src/extension/team-tool.ts +2 -1
- package/src/hooks/registry.ts +9 -1
- package/src/hooks/types.ts +14 -0
- package/src/i18n.ts +15 -2
- package/src/observability/exporters/otlp-exporter.ts +73 -0
- package/src/runtime/adaptive-plan.ts +24 -0
- package/src/runtime/agent-control.ts +6 -3
- package/src/runtime/async-runner.ts +58 -3
- package/src/runtime/background-runner.ts +1 -1
- package/src/runtime/chain-parser.ts +192 -0
- package/src/runtime/chain-runner.ts +58 -0
- package/src/runtime/child-pi.ts +1 -1
- package/src/runtime/crew-agent-records.ts +4 -3
- package/src/runtime/cross-extension-rpc.ts +34 -8
- package/src/runtime/diagnostic-export.ts +3 -4
- package/src/runtime/dynamic-script-runner.ts +7 -7
- package/src/runtime/foreground-watchdog.ts +2 -2
- package/src/runtime/intercom-bridge.ts +178 -0
- package/src/runtime/live-agent-manager.ts +6 -3
- package/src/runtime/live-irc.ts +4 -2
- package/src/runtime/parallel-utils.ts +2 -1
- package/src/runtime/plan-templates.ts +200 -0
- package/src/runtime/post-checks.ts +10 -3
- package/src/runtime/run-drift.ts +220 -0
- package/src/runtime/sandbox.ts +26 -20
- package/src/runtime/semaphore.ts +2 -1
- package/src/runtime/settings-store.ts +14 -2
- package/src/runtime/skill-effectiveness.ts +4 -2
- package/src/runtime/skill-instructions.ts +4 -1
- package/src/runtime/subagent-manager.ts +20 -2
- package/src/runtime/subprocess-tool-registry.ts +2 -2
- package/src/runtime/task-graph.ts +79 -0
- package/src/runtime/task-id.ts +148 -0
- package/src/runtime/task-packet.ts +13 -1
- package/src/runtime/task-runner/context-retrieval.ts +172 -0
- package/src/runtime/task-runner.ts +39 -1
- package/src/runtime/team-runner.ts +7 -0
- package/src/runtime/usage-tracker.ts +4 -2
- package/src/runtime/verification-gates.ts +36 -9
- package/src/state/contracts.ts +2 -1
- package/src/state/event-log.ts +16 -5
- package/src/state/hook-instinct-bridge.ts +2 -1
- package/src/state/locks.ts +9 -2
- package/src/state/memory-store.ts +244 -0
- package/src/state/observation-store.ts +177 -0
- package/src/state/state-store.ts +4 -2
- package/src/state/task-claims.ts +9 -2
- package/src/tools/safe-bash.ts +69 -20
- package/src/types/new-api-types.ts +10 -5
- package/src/ui/keybinding-map.ts +2 -1
- package/src/ui/run-action-dispatcher.ts +2 -1
- package/src/ui/status-colors.ts +2 -1
- package/src/ui/syntax-highlight.ts +2 -1
- package/src/ui/tool-render.ts +13 -3
- package/src/utils/fingerprint.ts +183 -0
- package/src/utils/fs-watch.ts +4 -2
- package/src/utils/gh-protocol.ts +2 -1
- package/src/utils/safe-paths.ts +6 -0
- package/src/workflows/discover-workflows.ts +5 -1
- package/src/workflows/intermediate-store.ts +173 -0
- package/src/workflows/workflow-config.ts +8 -0
- package/src/worktree/cleanup.ts +8 -5
- 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
|
+
}
|
package/src/state/state-store.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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),
|
package/src/state/task-claims.ts
CHANGED
|
@@ -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
|
|
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 {
|
package/src/tools/safe-bash.ts
CHANGED
|
@@ -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
|
|
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
|
|
66
|
-
if (command[p] !== "-")
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
21
|
+
/** @internal */
|
|
22
|
+
function isAgentLifecycleEvent(event: AgentSessionEvent): boolean {
|
|
21
23
|
return event.type === "agent_start" || event.type === "agent_end";
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
|
|
26
|
+
/** @internal */
|
|
27
|
+
function isCompactionEvent(event: AgentSessionEvent): boolean {
|
|
25
28
|
return event.type === "compaction_start" || event.type === "compaction_end";
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
|
|
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
|
-
|
|
36
|
+
/** @internal */
|
|
37
|
+
function isQueueEvent(event: AgentSessionEvent): boolean {
|
|
33
38
|
return event.type === "queue_update";
|
|
34
39
|
}
|
package/src/ui/keybinding-map.ts
CHANGED
|
@@ -21,7 +21,8 @@ export const DASHBOARD_KEYS = {
|
|
|
21
21
|
notification: { dismissAll: ["H"] },
|
|
22
22
|
} as const;
|
|
23
23
|
|
|
24
|
-
|
|
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
|
-
|
|
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;
|
package/src/ui/status-colors.ts
CHANGED
|
@@ -47,7 +47,8 @@ export function iconForStatus(status: RunStatus, options?: { runningGlyph?: stri
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
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
|
-
|
|
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];
|