pi-crew 0.1.45 → 0.1.49

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 (178) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +5 -5
  3. package/agents/analyst.md +11 -11
  4. package/agents/critic.md +11 -11
  5. package/agents/executor.md +11 -11
  6. package/agents/explorer.md +11 -11
  7. package/agents/planner.md +11 -11
  8. package/agents/reviewer.md +11 -11
  9. package/agents/security-reviewer.md +11 -11
  10. package/agents/test-engineer.md +11 -11
  11. package/agents/verifier.md +11 -11
  12. package/agents/writer.md +11 -11
  13. package/docs/next-upgrade-roadmap.md +808 -0
  14. package/docs/research/AGENT-EXECUTION-ARCHITECTURE.md +261 -0
  15. package/docs/research/AGENT-LIFECYCLE-COMPARISON.md +111 -0
  16. package/docs/research/AUDIT_OH_MY_PI.md +261 -0
  17. package/docs/research/AUDIT_PI_CREW.md +457 -0
  18. package/docs/research/CAVEMAN-DEEP-RESEARCH.md +281 -0
  19. package/docs/research/COMPARISON_OH_MY_PI_VS_PI_CREW.md +264 -0
  20. package/docs/research/DEEP-RESEARCH-PI-POWERBAR.md +343 -0
  21. package/docs/research/DEEP_RESEARCH_SUBAGENT_ARCHITECTURE.md +480 -0
  22. package/docs/research/GAP_CLOSURE_IMPLEMENTATION_PLAN.md +354 -0
  23. package/docs/research/IMPLEMENTATION_PLAN.md +385 -0
  24. package/docs/research/LIVE-SESSION-PRODUCTION-READY-PLAN.md +502 -0
  25. package/docs/research/OH-MY-PI-DEEP-RESEARCH-v14.7.6.md +266 -0
  26. package/docs/research/REMAINING-GAPS-PLAN.md +363 -0
  27. package/docs/research/SESSION-SUMMARY-2026-05-08.md +146 -0
  28. package/docs/research/UI-RESPONSIVENESS-AUDIT.md +173 -0
  29. package/docs/research-awesome-agent-skills-distillation.md +100 -0
  30. package/docs/research-oh-my-pi-distillation.md +369 -0
  31. package/docs/source-runtime-refactor-map.md +24 -0
  32. package/docs/usage.md +3 -3
  33. package/install.mjs +52 -8
  34. package/package.json +99 -98
  35. package/schema.json +10 -1
  36. package/skills/async-worker-recovery/SKILL.md +42 -0
  37. package/skills/context-artifact-hygiene/SKILL.md +52 -0
  38. package/skills/delegation-patterns/SKILL.md +54 -0
  39. package/skills/mailbox-interactive/SKILL.md +40 -0
  40. package/skills/model-routing-context/SKILL.md +39 -0
  41. package/skills/multi-perspective-review/SKILL.md +58 -0
  42. package/skills/observability-reliability/SKILL.md +41 -0
  43. package/skills/orchestration/SKILL.md +157 -0
  44. package/skills/ownership-session-security/SKILL.md +41 -0
  45. package/skills/pi-extension-lifecycle/SKILL.md +39 -0
  46. package/skills/requirements-to-task-packet/SKILL.md +63 -0
  47. package/skills/resource-discovery-config/SKILL.md +41 -0
  48. package/skills/runtime-state-reader/SKILL.md +44 -0
  49. package/skills/secure-agent-orchestration-review/SKILL.md +45 -0
  50. package/skills/state-mutation-locking/SKILL.md +42 -0
  51. package/skills/systematic-debugging/SKILL.md +67 -0
  52. package/skills/ui-render-performance/SKILL.md +39 -0
  53. package/skills/verification-before-done/SKILL.md +57 -0
  54. package/skills/worktree-isolation/SKILL.md +39 -0
  55. package/src/agents/agent-config.ts +6 -0
  56. package/src/agents/agent-search.ts +98 -0
  57. package/src/agents/agent-serializer.ts +38 -34
  58. package/src/agents/discover-agents.ts +29 -15
  59. package/src/config/config.ts +72 -24
  60. package/src/config/defaults.ts +25 -0
  61. package/src/extension/autonomous-policy.ts +26 -33
  62. package/src/extension/help.ts +1 -0
  63. package/src/extension/management.ts +5 -0
  64. package/src/extension/project-init.ts +62 -2
  65. package/src/extension/register.ts +69 -22
  66. package/src/extension/registration/commands.ts +64 -25
  67. package/src/extension/registration/compaction-guard.ts +1 -1
  68. package/src/extension/registration/subagent-helpers.ts +8 -0
  69. package/src/extension/registration/subagent-tools.ts +149 -148
  70. package/src/extension/registration/team-tool.ts +14 -10
  71. package/src/extension/run-index.ts +35 -21
  72. package/src/extension/run-maintenance.ts +30 -5
  73. package/src/extension/team-tool/api.ts +47 -9
  74. package/src/extension/team-tool/cancel.ts +109 -5
  75. package/src/extension/team-tool/context.ts +8 -0
  76. package/src/extension/team-tool/intent-policy.ts +42 -0
  77. package/src/extension/team-tool/lifecycle-actions.ts +120 -79
  78. package/src/extension/team-tool/parallel-dispatch.ts +156 -0
  79. package/src/extension/team-tool/respond.ts +46 -18
  80. package/src/extension/team-tool/run.ts +55 -12
  81. package/src/extension/team-tool/status.ts +13 -2
  82. package/src/extension/team-tool-types.ts +3 -0
  83. package/src/extension/team-tool.ts +45 -14
  84. package/src/hooks/registry.ts +61 -0
  85. package/src/hooks/types.ts +41 -0
  86. package/src/observability/event-to-metric.ts +8 -1
  87. package/src/runtime/agent-control.ts +169 -63
  88. package/src/runtime/async-runner.ts +3 -1
  89. package/src/runtime/background-runner.ts +78 -53
  90. package/src/runtime/cancellation-token.ts +89 -0
  91. package/src/runtime/cancellation.ts +61 -0
  92. package/src/runtime/capability-inventory.ts +116 -0
  93. package/src/runtime/child-pi.ts +458 -444
  94. package/src/runtime/code-summary.ts +247 -0
  95. package/src/runtime/crash-recovery.ts +182 -0
  96. package/src/runtime/crew-agent-records.ts +70 -10
  97. package/src/runtime/crew-agent-runtime.ts +1 -0
  98. package/src/runtime/custom-tools/irc-tool.ts +201 -0
  99. package/src/runtime/custom-tools/submit-result-tool.ts +90 -0
  100. package/src/runtime/deadletter.ts +1 -0
  101. package/src/runtime/delivery-coordinator.ts +48 -25
  102. package/src/runtime/effectiveness.ts +81 -0
  103. package/src/runtime/event-stream-bridge.ts +90 -0
  104. package/src/runtime/live-agent-control.ts +2 -1
  105. package/src/runtime/live-agent-manager.ts +179 -85
  106. package/src/runtime/live-control-realtime.ts +1 -1
  107. package/src/runtime/live-extension-bridge.ts +150 -0
  108. package/src/runtime/live-irc.ts +92 -0
  109. package/src/runtime/live-session-health.ts +100 -0
  110. package/src/runtime/live-session-runtime.ts +599 -305
  111. package/src/runtime/manifest-cache.ts +17 -2
  112. package/src/runtime/mcp-proxy.ts +113 -0
  113. package/src/runtime/model-fallback.ts +6 -4
  114. package/src/runtime/notebook-helpers.ts +90 -0
  115. package/src/runtime/orphan-sentinel.ts +7 -0
  116. package/src/runtime/output-validator.ts +187 -0
  117. package/src/runtime/parallel-utils.ts +57 -0
  118. package/src/runtime/parent-guard.ts +80 -0
  119. package/src/runtime/pi-args.ts +18 -3
  120. package/src/runtime/process-status.ts +5 -1
  121. package/src/runtime/prose-compressor.ts +164 -0
  122. package/src/runtime/result-extractor.ts +121 -0
  123. package/src/runtime/retry-executor.ts +81 -64
  124. package/src/runtime/runtime-resolver.ts +23 -10
  125. package/src/runtime/semaphore.ts +131 -0
  126. package/src/runtime/sensitive-paths.ts +92 -0
  127. package/src/runtime/skill-instructions.ts +222 -0
  128. package/src/runtime/stale-reconciler.ts +4 -14
  129. package/src/runtime/stream-preview.ts +177 -0
  130. package/src/runtime/subagent-manager.ts +6 -2
  131. package/src/runtime/subprocess-tool-registry.ts +67 -0
  132. package/src/runtime/task-output-context.ts +177 -127
  133. package/src/runtime/task-runner/capabilities.ts +78 -0
  134. package/src/runtime/task-runner/live-executor.ts +107 -101
  135. package/src/runtime/task-runner/prompt-builder.ts +72 -8
  136. package/src/runtime/task-runner/prompt-pipeline.ts +64 -0
  137. package/src/runtime/task-runner/run-projection.ts +104 -0
  138. package/src/runtime/task-runner.ts +115 -5
  139. package/src/runtime/team-runner.ts +134 -19
  140. package/src/runtime/workspace-tree.ts +298 -0
  141. package/src/runtime/yield-handler.ts +189 -0
  142. package/src/schema/config-schema.ts +7 -0
  143. package/src/schema/team-tool-schema.ts +14 -4
  144. package/src/skills/discover-skills.ts +67 -0
  145. package/src/state/active-run-registry.ts +167 -0
  146. package/src/state/artifact-store.ts +4 -1
  147. package/src/state/atomic-write.ts +50 -1
  148. package/src/state/blob-store.ts +117 -0
  149. package/src/state/contracts.ts +2 -1
  150. package/src/state/event-log-rotation.ts +158 -0
  151. package/src/state/event-log.ts +52 -2
  152. package/src/state/mailbox.ts +129 -9
  153. package/src/state/state-store.ts +32 -5
  154. package/src/state/types.ts +64 -2
  155. package/src/teams/team-config.ts +1 -0
  156. package/src/ui/agent-management-overlay.ts +144 -0
  157. package/src/ui/crew-widget.ts +15 -5
  158. package/src/ui/dashboard-panes/cancellation-pane.ts +43 -0
  159. package/src/ui/dashboard-panes/capability-pane.ts +60 -0
  160. package/src/ui/dashboard-panes/mailbox-pane.ts +35 -11
  161. package/src/ui/dashboard-panes/progress-pane.ts +2 -0
  162. package/src/ui/live-run-sidebar.ts +4 -0
  163. package/src/ui/powerbar-publisher.ts +77 -15
  164. package/src/ui/render-coalescer.ts +51 -0
  165. package/src/ui/run-dashboard.ts +4 -0
  166. package/src/ui/run-event-bus.ts +209 -0
  167. package/src/ui/run-snapshot-cache.ts +78 -18
  168. package/src/ui/snapshot-types.ts +10 -0
  169. package/src/ui/transcript-entries.ts +258 -0
  170. package/src/utils/ids.ts +5 -0
  171. package/src/utils/incremental-reader.ts +104 -0
  172. package/src/utils/paths.ts +4 -2
  173. package/src/utils/scan-cache.ts +137 -0
  174. package/src/utils/sse-parser.ts +134 -0
  175. package/src/utils/task-name-generator.ts +337 -0
  176. package/src/utils/visual.ts +33 -2
  177. package/src/workflows/workflow-config.ts +1 -0
  178. package/src/worktree/cleanup.ts +2 -1
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Sensitive file detection for worker constraints.
3
+ *
4
+ * Inspired by caveman's compress.py — prevents workers from reading
5
+ * or compressing files that contain secrets, credentials, or PII.
6
+ *
7
+ * Workers should refuse operations on matching paths. This is enforced
8
+ * in the worker prompt and validated here for defense-in-depth.
9
+ */
10
+
11
+ import * as path from "node:path";
12
+
13
+ /** Basenames that almost certainly hold secrets or PII */
14
+ const SENSITIVE_BASENAMES = /\.(?:env|pem|key|p12|pfx|crt|cer|jks|keystore|asc|gpg)(?:\..+)?$/i;
15
+ const SENSITIVE_EXACT = /^(?:\.env|\.netrc|\.npmrc|\.pypirc|credentials|secrets?|passwords?|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?|authorized_keys|known_hosts)$/i;
16
+
17
+ /** Path components that indicate sensitive directories */
18
+ const SENSITIVE_DIRS = new Set([".ssh", ".aws", ".gnupg", ".kube", ".docker", ".config/gcloud"]);
19
+
20
+ /** Name tokens that suggest sensitive content */
21
+ const SENSITIVE_TOKENS = ["secret", "credential", "password", "passwd", "apikey", "accesskey", "token", "privatekey"];
22
+
23
+ /**
24
+ * Check if a file path looks like it contains sensitive data.
25
+ * Returns true if the path should be refused for worker operations.
26
+ */
27
+ export function isSensitivePath(filePath: string): boolean {
28
+ const resolved = path.resolve(filePath);
29
+ const basename = path.basename(resolved);
30
+ const lower = basename.toLowerCase();
31
+
32
+ // Check exact sensitive filenames
33
+ if (SENSITIVE_EXACT.test(basename)) return true;
34
+
35
+ // Check sensitive extensions
36
+ if (SENSITIVE_BASENAMES.test(basename)) return true;
37
+
38
+ // Check path components
39
+ const parts = resolved.split(/[/\\]/).map((p) => p.toLowerCase());
40
+ for (const dir of SENSITIVE_DIRS) {
41
+ const dirParts = dir.split("/");
42
+ for (let i = 0; i <= parts.length - dirParts.length; i++) {
43
+ const slice = parts.slice(i, i + dirParts.length);
44
+ if (slice.join("/") === dir) return true;
45
+ }
46
+ }
47
+
48
+ // Check name tokens with word-boundary awareness to reduce false positives.
49
+ // Strategy: split filename on separators to get "words", then check if
50
+ // any token matches. For substring matching in the normalized form,
51
+ // we require the token to end at a segment boundary or string end.
52
+ // This matches 'secret', 'secrets' but NOT 'secretary'.
53
+ const words = lower.split(/[_\-\s.]+/).filter(Boolean);
54
+ const normalized = lower.replace(/[_\-\s.]/g, "");
55
+ for (const token of SENSITIVE_TOKENS) {
56
+ // Check individual words — exact match or token is prefix and word is <= token+2 chars
57
+ for (const word of words) {
58
+ if (word === token) return true;
59
+ // 'secrets' starts with 'secret' and is only 1 char longer → match
60
+ // 'secretary' starts with 'secret' but is 4 chars longer → no match
61
+ if (word.startsWith(token) && word.length <= token.length + 2) return true;
62
+ }
63
+ // Check fully-normalized form for compound tokens like 'api-key' → 'apikey'
64
+ // The token must appear as a complete segment (not a partial substring).
65
+ // After the token, the remaining chars must be a complete word (extension).
66
+ const idx = normalized.indexOf(token);
67
+ if (idx !== -1) {
68
+ const after = idx + token.length;
69
+ if (after === normalized.length) return true;
70
+ // Check if remaining chars after token correspond to a known word segment
71
+ const remaining = normalized.slice(after);
72
+ if (words.some((w) => remaining === w || remaining.startsWith(w))) return true;
73
+ }
74
+ }
75
+
76
+ return false;
77
+ }
78
+
79
+ /**
80
+ * Build a worker prompt constraint block listing forbidden paths.
81
+ * This goes into the worker system prompt to prevent accidental reads.
82
+ */
83
+ export function buildSensitivePathConstraint(): string {
84
+ return [
85
+ "## Security Constraints",
86
+ "NEVER read, compress, or include content from:",
87
+ "- Files matching: .env*, *.pem, *.key, *.p12, credentials*, secrets*, passwords*, id_rsa*",
88
+ "- Directories: .ssh/, .aws/, .gnupg/, .kube/, .docker/",
89
+ "- Files with names containing: secret, credential, password, apikey, token, privatekey",
90
+ "If asked to read such a file, refuse and explain the security risk.",
91
+ ].join("\n");
92
+ }
@@ -0,0 +1,222 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { AgentConfig } from "../agents/agent-config.ts";
5
+ import type { TeamRole } from "../teams/team-config.ts";
6
+ import type { WorkflowStep } from "../workflows/workflow-config.ts";
7
+ import { isSafePathId, resolveContainedPath, resolveRealContainedPath } from "../utils/safe-paths.ts";
8
+
9
+ const PACKAGE_SKILLS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "skills");
10
+ const MAX_SKILL_CHARS = 1500;
11
+ const MAX_TOTAL_CHARS = 6000;
12
+ const MAX_SKILL_NAME_CHARS = 80;
13
+ const MAX_SELECTED_SKILLS = 32;
14
+ const SKILL_CACHE_MAX_ENTRIES = 128;
15
+
16
+ const DEFAULT_ROLE_SKILLS: Record<string, string[]> = {
17
+ explorer: ["read-only-explorer", "context-artifact-hygiene"],
18
+ analyst: ["read-only-explorer", "requirements-to-task-packet"],
19
+ planner: ["delegation-patterns", "requirements-to-task-packet"],
20
+ critic: ["read-only-explorer", "multi-perspective-review"],
21
+ executor: ["state-mutation-locking", "safe-bash", "verification-before-done"],
22
+ reviewer: ["read-only-explorer", "multi-perspective-review"],
23
+ "security-reviewer": ["secure-agent-orchestration-review", "ownership-session-security"],
24
+ "test-engineer": ["verification-before-done", "safe-bash"],
25
+ verifier: ["verification-before-done", "runtime-state-reader"],
26
+ writer: ["context-artifact-hygiene", "verify-evidence"],
27
+ };
28
+
29
+ export interface ResolveTaskSkillsInput {
30
+ role: string;
31
+ agent?: Pick<AgentConfig, "skills">;
32
+ teamRole?: Pick<TeamRole, "skills">;
33
+ step?: Pick<WorkflowStep, "skills">;
34
+ override?: string[] | false;
35
+ }
36
+
37
+ export interface RenderSkillInstructionsInput extends ResolveTaskSkillsInput {
38
+ cwd: string;
39
+ }
40
+
41
+ function isValidSkillName(name: string): boolean {
42
+ return name.length > 0 && name.length <= MAX_SKILL_NAME_CHARS && isSafePathId(name);
43
+ }
44
+
45
+ function sanitizeSkillName(name: string): string {
46
+ return name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, MAX_SKILL_NAME_CHARS) || "invalid";
47
+ }
48
+
49
+ function unique(items: string[]): string[] {
50
+ const seen = new Set<string>();
51
+ const result: string[] = [];
52
+ for (const item of items.map((entry) => entry.trim()).filter(Boolean)) {
53
+ if (!isValidSkillName(item)) continue;
54
+ if (seen.has(item)) continue;
55
+ seen.add(item);
56
+ result.push(item);
57
+ }
58
+ return result;
59
+ }
60
+
61
+ export function normalizeSkillOverride(value: string | string[] | boolean | undefined): string[] | false | undefined {
62
+ if (value === false) return false;
63
+ if (typeof value === "string") return value.split(",").map((entry) => entry.trim()).filter(Boolean);
64
+ if (value === true) return undefined;
65
+ if (Array.isArray(value)) return value.map((entry) => entry.trim()).filter(Boolean);
66
+ return undefined;
67
+ }
68
+
69
+ export function defaultSkillsForRole(role: string): string[] {
70
+ return DEFAULT_ROLE_SKILLS[role] ?? [];
71
+ }
72
+
73
+ function collectTaskSkillNames(input: ResolveTaskSkillsInput): string[] {
74
+ if (input.override === false) return [];
75
+ const roleDefaultsDisabled = input.teamRole?.skills === false || input.step?.skills === false;
76
+ const names = roleDefaultsDisabled ? [] : defaultSkillsForRole(input.role);
77
+ if (input.agent?.skills?.length) names.push(...input.agent.skills);
78
+ if (Array.isArray(input.teamRole?.skills)) names.push(...input.teamRole.skills);
79
+ if (Array.isArray(input.step?.skills)) names.push(...input.step.skills);
80
+ if (Array.isArray(input.override)) names.push(...input.override);
81
+ return unique(names);
82
+ }
83
+
84
+ export function resolveTaskSkillNames(input: ResolveTaskSkillsInput): string[] {
85
+ return collectTaskSkillNames(input).slice(0, MAX_SELECTED_SKILLS);
86
+ }
87
+
88
+ function candidateSkillDirs(cwd: string): Array<{ root: string; source: "project" | "package" }> {
89
+ return [
90
+ { root: path.resolve(cwd, "skills"), source: "project" },
91
+ { root: PACKAGE_SKILLS_DIR, source: "package" },
92
+ ];
93
+ }
94
+
95
+ interface CachedSkillMarkdown {
96
+ path: string;
97
+ source: "project" | "package";
98
+ content: string;
99
+ mtimeMs: number;
100
+ size: number;
101
+ }
102
+
103
+ const skillReadCache = new Map<string, CachedSkillMarkdown>();
104
+
105
+ function rememberSkill(key: string, value: CachedSkillMarkdown): CachedSkillMarkdown {
106
+ if (skillReadCache.has(key)) skillReadCache.delete(key);
107
+ skillReadCache.set(key, value);
108
+ while (skillReadCache.size > SKILL_CACHE_MAX_ENTRIES) {
109
+ const oldest = skillReadCache.keys().next().value;
110
+ if (!oldest) break;
111
+ skillReadCache.delete(oldest);
112
+ }
113
+ return value;
114
+ }
115
+
116
+ export function clearSkillInstructionCache(): void {
117
+ skillReadCache.clear();
118
+ }
119
+
120
+ function cachedSkillFresh(value: CachedSkillMarkdown): boolean {
121
+ try {
122
+ const stat = fs.statSync(value.path);
123
+ return stat.mtimeMs === value.mtimeMs && stat.size === value.size;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ function readSkillMarkdown(cwd: string, name: string): { path: string; source: "project" | "package"; content: string } | undefined {
130
+ if (!isValidSkillName(name)) return undefined;
131
+ const cacheKey = `${path.resolve(cwd)}:${name}`;
132
+ const cached = skillReadCache.get(cacheKey);
133
+ if (cached && cachedSkillFresh(cached)) return cached;
134
+ if (cached) skillReadCache.delete(cacheKey);
135
+ for (const entry of candidateSkillDirs(cwd)) {
136
+ try {
137
+ const relative = path.join(name, "SKILL.md");
138
+ const contained = resolveContainedPath(entry.root, relative);
139
+ if (!fs.existsSync(contained)) continue;
140
+ if (fs.lstatSync(contained).isSymbolicLink()) continue;
141
+ const filePath = resolveRealContainedPath(entry.root, relative);
142
+ const stat = fs.statSync(filePath);
143
+ return rememberSkill(cacheKey, { path: filePath, source: entry.source, content: fs.readFileSync(filePath, "utf-8"), mtimeMs: stat.mtimeMs, size: stat.size });
144
+ } catch {
145
+ continue;
146
+ }
147
+ }
148
+ return undefined;
149
+ }
150
+
151
+ function frontmatterDescription(content: string): string | undefined {
152
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
153
+ if (!match) return undefined;
154
+ const line = match[1].split(/\r?\n/).find((entry) => entry.startsWith("description:"));
155
+ return line?.slice("description:".length).trim();
156
+ }
157
+
158
+ function stripFrontmatter(content: string): string {
159
+ return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "").trim();
160
+ }
161
+
162
+ function compactSkillContent(content: string): string {
163
+ const body = stripFrontmatter(content);
164
+ if (body.length <= MAX_SKILL_CHARS) return body;
165
+ const preferred = body.split(/\r?\n## Verification\r?\n/)[0]?.trim() ?? body;
166
+ const truncated = preferred.length > MAX_SKILL_CHARS ? preferred.slice(0, MAX_SKILL_CHARS - 40).trimEnd() : preferred;
167
+ return `${truncated}\n\n[skill instructions truncated]`;
168
+ }
169
+
170
+ export interface RenderedSkillInstructions {
171
+ names: string[];
172
+ paths: string[];
173
+ block: string;
174
+ }
175
+
176
+ export function renderSkillInstructions(input: RenderSkillInstructionsInput): RenderedSkillInstructions {
177
+ const allNames = collectTaskSkillNames(input);
178
+ const names = allNames.slice(0, MAX_SELECTED_SKILLS);
179
+ const overflowCount = Math.max(0, allNames.length - names.length);
180
+ if (names.length === 0) return { names, paths: [], block: "" };
181
+ const sections: string[] = [];
182
+ const skillPaths: string[] = [];
183
+ let total = 0;
184
+ let omittedCount = overflowCount;
185
+ const pushSection = (section: string): boolean => {
186
+ if (total + section.length > MAX_TOTAL_CHARS) return false;
187
+ sections.push(section);
188
+ total += section.length;
189
+ return true;
190
+ };
191
+ for (const name of names) {
192
+ const safeName = sanitizeSkillName(name);
193
+ const loaded = readSkillMarkdown(input.cwd, name);
194
+ if (!loaded) {
195
+ const missing = `## ${safeName}\n\nSkill '${safeName}' was selected but no SKILL.md file was found. Continue with the task packet and report this missing skill.`;
196
+ if (!pushSection(missing)) omittedCount += 1;
197
+ continue;
198
+ }
199
+ skillPaths.push(path.dirname(loaded.path));
200
+ const description = frontmatterDescription(loaded.content);
201
+ const source = loaded.source === "project" ? `project:skills/${safeName}` : `package:skills/${safeName}`;
202
+ const header = [`## ${safeName}`, description ? `Description: ${description}` : undefined, `Source: ${source}`].filter(Boolean).join("\n");
203
+ const section = `${header}\n\n${compactSkillContent(loaded.content)}`;
204
+ if (!pushSection(section)) omittedCount += 1;
205
+ }
206
+ if (omittedCount > 0) {
207
+ const summary = `## Omitted skills\n\n[omitted ${omittedCount} selected skill(s): skill instruction budget exceeded]`;
208
+ if (!pushSection(summary) && sections.length > 0) {
209
+ sections[sections.length - 1] = summary;
210
+ }
211
+ }
212
+ return {
213
+ names,
214
+ paths: [...new Set(skillPaths)],
215
+ block: [
216
+ "# Applicable Skills",
217
+ "The following skills were selected for this worker. Follow them when they match the current task. If a selected skill conflicts with the explicit task packet, project AGENTS.md, or user request, follow the stricter/higher-priority instruction and report the conflict.",
218
+ "",
219
+ sections.join("\n\n---\n\n"),
220
+ ].join("\n"),
221
+ };
222
+ }
@@ -1,9 +1,5 @@
1
- import * as fs from "node:fs";
2
- import * as path from "node:path";
3
1
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
4
2
  import { checkProcessLiveness } from "./process-status.ts";
5
- import { logInternalError } from "../utils/internal-error.ts";
6
- import { writeAtomicJson } from "../utils/atomic-write.ts";
7
3
 
8
4
  /**
9
5
  * Result of reconciling a single stale run.
@@ -16,6 +12,8 @@ export interface ReconcileResult {
16
12
  repaired: boolean;
17
13
  /** Human-readable detail */
18
14
  detail: string;
15
+ /** Repaired task state, returned to a locked caller for persistence. */
16
+ repairedTasks?: TeamTaskState[];
19
17
  }
20
18
 
21
19
  const STALE_ALIVE_PID_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -106,16 +104,6 @@ function repairStaleRun(
106
104
  return task;
107
105
  });
108
106
 
109
- // Write repaired tasks atomically
110
- const tasksPath = manifest.tasksPath;
111
- if (tasksPath) {
112
- try {
113
- writeAtomicJson(tasksPath, repairedTasks);
114
- } catch (error) {
115
- logInternalError("stale-reconciler.repair-tasks", error, `runId=${manifest.runId}`);
116
- }
117
- }
118
-
119
107
  return repairedTasks;
120
108
  }
121
109
 
@@ -167,6 +155,7 @@ export function reconcileStaleRun(
167
155
  verdict: "no_status",
168
156
  repaired: true,
169
157
  detail: `No PID; stale ${Math.round((now - updatedAt) / 3600_000)}h; repaired ${repaired.filter((t) => t.status === "cancelled").length} tasks`,
158
+ repairedTasks: repaired,
170
159
  };
171
160
  }
172
161
  return {
@@ -195,5 +184,6 @@ export function reconcileStaleRun(
195
184
  verdict: pidStatus.alive ? "pid_alive_stale" : "pid_dead",
196
185
  repaired: true,
197
186
  detail: `PID ${pid}: ${pidStatus.detail}; ${staleness.reason}; repaired ${repaired.filter((t) => t.status === "cancelled").length} tasks`,
187
+ repairedTasks: repaired,
198
188
  };
199
189
  }
@@ -0,0 +1,177 @@
1
+ // ─── Streaming Preview ───────────────────────────────────────────────────────
2
+ // Captures incremental worker output during task execution for live preview.
3
+ // Used by the UI layer to show partial results before task completion.
4
+
5
+ import type { ParsedPiUsage } from "./pi-json-output.ts";
6
+
7
+ export interface ToolCallPreview {
8
+ toolName: string;
9
+ inputPreview: string;
10
+ startedAt: number;
11
+ }
12
+
13
+ export interface StreamPreview {
14
+ taskId: string;
15
+ runId: string;
16
+ /** Cumulative text output captured so far */
17
+ textBuffer: string;
18
+ /** Current tool call in progress (if any) */
19
+ activeToolCall: ToolCallPreview | null;
20
+ /** Completed tool calls count */
21
+ toolCallCount: number;
22
+ /** Current turn number (incremented per assistant message) */
23
+ turnCount: number;
24
+ /** Token usage snapshot */
25
+ usage: Partial<ParsedPiUsage> | null;
26
+ /** Wall-clock start time */
27
+ startedAt: number;
28
+ /** Last update timestamp */
29
+ lastUpdatedAt: number;
30
+ /** Whether the task has completed */
31
+ finished: boolean;
32
+ }
33
+
34
+ export function createStreamPreview(taskId: string, runId: string): StreamPreview {
35
+ const now = Date.now();
36
+ return {
37
+ taskId,
38
+ runId,
39
+ textBuffer: "",
40
+ activeToolCall: null,
41
+ toolCallCount: 0,
42
+ turnCount: 0,
43
+ usage: null,
44
+ startedAt: now,
45
+ lastUpdatedAt: now,
46
+ finished: false,
47
+ };
48
+ }
49
+
50
+ /** Max text buffer size — drop oldest content when exceeded */
51
+ const MAX_TEXT_BUFFER = 16_384;
52
+ /** Max tool input preview length */
53
+ const MAX_INPUT_PREVIEW = 512;
54
+
55
+ const asRecord = (value: unknown): Record<string, unknown> | undefined =>
56
+ value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
57
+
58
+ function truncateWithEllipsis(text: string, maxLength: number): string {
59
+ if (text.length <= maxLength) return text;
60
+ return text.slice(0, maxLength - 3) + "...";
61
+ }
62
+
63
+ function extractTextFromContent(content: unknown): string {
64
+ if (typeof content === "string") return content;
65
+ if (!Array.isArray(content)) return "";
66
+ const parts: string[] = [];
67
+ for (const part of content) {
68
+ const obj = asRecord(part);
69
+ if (!obj) continue;
70
+ if (obj.type === "text" && typeof obj.text === "string") parts.push(obj.text);
71
+ else if (typeof obj.content === "string") parts.push(obj.content);
72
+ }
73
+ return parts.join("");
74
+ }
75
+
76
+ /**
77
+ * Feed a JSON event from Pi's stdout into the preview, updating it in place.
78
+ * Returns true if the preview was modified.
79
+ */
80
+ export function feedJsonEvent(preview: StreamPreview, event: unknown): boolean {
81
+ const obj = asRecord(event);
82
+ if (!obj) return false;
83
+
84
+ let modified = false;
85
+ preview.lastUpdatedAt = Date.now();
86
+
87
+ // Detect tool calls
88
+ if (obj.type === "tool_call" || obj.type === "tool_use" || obj.type === "toolCall") {
89
+ const toolName = typeof obj.name === "string" ? obj.name : typeof obj.tool === "string" ? obj.tool : "unknown";
90
+ const rawInput = obj.input ?? obj.arguments ?? obj.params ?? "";
91
+ const inputStr = typeof rawInput === "string" ? rawInput : JSON.stringify(rawInput);
92
+ preview.activeToolCall = {
93
+ toolName,
94
+ inputPreview: truncateWithEllipsis(inputStr, MAX_INPUT_PREVIEW),
95
+ startedAt: Date.now(),
96
+ };
97
+ preview.toolCallCount++;
98
+ modified = true;
99
+ }
100
+
101
+ // Detect tool results (clear active tool call)
102
+ if (obj.type === "tool_result" || obj.type === "toolResult" || obj.type === "result") {
103
+ preview.activeToolCall = null;
104
+ modified = true;
105
+ }
106
+
107
+ // Detect assistant text output
108
+ const message = asRecord(obj.message);
109
+ if (message?.role === "assistant" || obj.role === "assistant") {
110
+ preview.turnCount++;
111
+ const text = extractTextFromContent(message?.content ?? obj.content);
112
+ if (text) {
113
+ const appended = preview.textBuffer.length > 0 ? preview.textBuffer + "\n" + text : text;
114
+ preview.textBuffer = appended.length > MAX_TEXT_BUFFER ? appended.slice(appended.length - MAX_TEXT_BUFFER) : appended;
115
+ }
116
+ modified = true;
117
+ }
118
+
119
+ // Detect direct text/final output
120
+ if (typeof obj.text === "string" && obj.text.trim()) {
121
+ const appended = preview.textBuffer.length > 0 ? preview.textBuffer + "\n" + obj.text : obj.text;
122
+ preview.textBuffer = appended.length > MAX_TEXT_BUFFER ? appended.slice(appended.length - MAX_TEXT_BUFFER) : appended;
123
+ modified = true;
124
+ }
125
+
126
+ // Detect usage
127
+ const rawUsage = obj.usage ?? obj.tokenUsage ?? obj.tokens ?? obj.stats;
128
+ if (rawUsage && typeof rawUsage === "object") {
129
+ const u = asRecord(rawUsage);
130
+ if (u) {
131
+ preview.usage = {
132
+ input: typeof u.input === "number" ? u.input : preview.usage?.input,
133
+ output: typeof u.output === "number" ? u.output : preview.usage?.output,
134
+ cacheRead: typeof u.cacheRead === "number" ? u.cacheRead : preview.usage?.cacheRead,
135
+ cacheWrite: typeof u.cacheWrite === "number" ? u.cacheWrite : preview.usage?.cacheWrite,
136
+ cost: typeof u.cost === "number" ? u.cost : preview.usage?.cost,
137
+ turns: typeof u.turns === "number" ? u.turns : preview.usage?.turns,
138
+ };
139
+ modified = true;
140
+ }
141
+ }
142
+
143
+ return modified;
144
+ }
145
+
146
+ /** Mark preview as finished */
147
+ export function finishStreamPreview(preview: StreamPreview): void {
148
+ preview.finished = true;
149
+ preview.activeToolCall = null;
150
+ preview.lastUpdatedAt = Date.now();
151
+ }
152
+
153
+ /** Render a compact one-line status for the preview */
154
+ export function renderPreviewStatus(preview: StreamPreview): string {
155
+ const elapsed = Math.round((Date.now() - preview.startedAt) / 1000);
156
+ const parts: string[] = [];
157
+
158
+ if (preview.finished) {
159
+ parts.push("✓ done");
160
+ } else if (preview.activeToolCall) {
161
+ parts.push(`⚙ ${preview.activeToolCall.toolName}`);
162
+ } else {
163
+ parts.push("⟳ thinking");
164
+ }
165
+
166
+ parts.push(`T${preview.turnCount}`);
167
+ parts.push(`${preview.toolCallCount} tools`);
168
+
169
+ if (preview.usage?.input) {
170
+ const inK = Math.round(preview.usage.input / 1024);
171
+ parts.push(`${inK}k in`);
172
+ }
173
+
174
+ parts.push(`${elapsed}s`);
175
+
176
+ return parts.join(" | ");
177
+ }
@@ -17,6 +17,7 @@ export interface SubagentSpawnOptions {
17
17
  prompt: string;
18
18
  background: boolean;
19
19
  model?: string;
20
+ skill?: string | string[] | false;
20
21
  maxTurns?: number;
21
22
  ownerSessionGeneration?: number;
22
23
  }
@@ -34,6 +35,7 @@ export interface SubagentRecord {
34
35
  error?: string;
35
36
  resultConsumed?: boolean;
36
37
  model?: string;
38
+ skill?: string | string[] | false;
37
39
  background: boolean;
38
40
  ownerSessionGeneration?: number;
39
41
  stuckNotified?: boolean;
@@ -138,6 +140,7 @@ export class SubagentManager {
138
140
  status: options.background && this.runningBackground >= this.maxConcurrent ? "queued" : "running",
139
141
  startedAt: Date.now(),
140
142
  model: options.model,
143
+ skill: options.skill,
141
144
  background: options.background,
142
145
  ownerSessionGeneration: options.ownerSessionGeneration,
143
146
  };
@@ -205,7 +208,7 @@ export class SubagentManager {
205
208
  const record = this.records.get(id);
206
209
  if (!record) return undefined;
207
210
  if (record.status !== "running" && record.status !== "queued") return record;
208
- if (record.promise) await record.promise;
211
+ if (record.promise) await record.promise.catch(() => { /* status already set to error */ });
209
212
  else await new Promise((resolve) => setTimeout(resolve, 100));
210
213
  }
211
214
  }
@@ -232,7 +235,7 @@ export class SubagentManager {
232
235
  if (result.isError) {
233
236
  record.status = "error";
234
237
  record.error = record.result;
235
- return;
238
+ throw new Error(record.error);
236
239
  }
237
240
  if (record.runId) await this.pollRunToTerminal(options.cwd, record);
238
241
  else record.status = "completed";
@@ -243,6 +246,7 @@ export class SubagentManager {
243
246
  }
244
247
  record.status = "error";
245
248
  record.error = error instanceof Error ? error.message : String(error);
249
+ throw error; // H4: Propagate rejection so callers awaiting record.promise see the error
246
250
  } finally {
247
251
  this.cleanupRunSignal(record.id);
248
252
  if (options.background) this.runningBackground = Math.max(0, this.runningBackground - 1);
@@ -0,0 +1,67 @@
1
+ export interface SubprocessToolEvent {
2
+ toolName: string;
3
+ toolCallId: string;
4
+ args?: Record<string, unknown>;
5
+ result?: { content: Array<{ type: string; text?: string }>; details?: unknown };
6
+ isError?: boolean;
7
+ }
8
+
9
+ export interface SubprocessToolHandler<TData = unknown> {
10
+ extractData?: (event: SubprocessToolEvent) => TData | undefined;
11
+ shouldTerminate?: (event: SubprocessToolEvent) => boolean;
12
+ }
13
+
14
+ export interface SubprocessToolRegistry {
15
+ register<T>(toolName: string, handler: SubprocessToolHandler<T>): void;
16
+ getHandler(toolName: string): SubprocessToolHandler | undefined;
17
+ hasHandler(toolName: string): boolean;
18
+ getRegisteredTools(): string[];
19
+ extractAll(event: SubprocessToolEvent): Record<string, unknown>;
20
+ /** H3: Clear all registered handlers (for test isolation). */
21
+ clear(): void;
22
+ }
23
+
24
+ class SubprocessToolRegistryImpl implements SubprocessToolRegistry {
25
+ private readonly handlers = new Map<string, SubprocessToolHandler>();
26
+
27
+ register<T>(toolName: string, handler: SubprocessToolHandler<T>): void {
28
+ this.handlers.set(toolName, handler as SubprocessToolHandler);
29
+ }
30
+
31
+ getHandler(toolName: string): SubprocessToolHandler | undefined {
32
+ return this.handlers.get(toolName);
33
+ }
34
+
35
+ hasHandler(toolName: string): boolean {
36
+ return this.handlers.has(toolName);
37
+ }
38
+
39
+ getRegisteredTools(): string[] {
40
+ return [...this.handlers.keys()];
41
+ }
42
+
43
+ extractAll(event: SubprocessToolEvent): Record<string, unknown> {
44
+ const extracted: Record<string, unknown> = {};
45
+ for (const [toolName, handler] of this.handlers) {
46
+ if (handler.extractData) {
47
+ const data = handler.extractData(event);
48
+ if (data !== undefined) {
49
+ extracted[toolName] = data;
50
+ }
51
+ }
52
+ }
53
+ return extracted;
54
+ }
55
+
56
+ /** H3: Clear all registered handlers (for test isolation). */
57
+ clear(): void {
58
+ this.handlers.clear();
59
+ }
60
+ }
61
+
62
+ export const subprocessToolRegistry: SubprocessToolRegistry = new SubprocessToolRegistryImpl();
63
+
64
+ /** H3: Reset the global singleton registry (for test isolation). */
65
+ export function resetSubprocessToolRegistry(): void {
66
+ subprocessToolRegistry.clear();
67
+ }