pi-crew 0.1.46 → 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 (253) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/agents/analyst.md +11 -11
  3. package/agents/critic.md +11 -11
  4. package/agents/executor.md +11 -11
  5. package/agents/explorer.md +11 -11
  6. package/agents/planner.md +11 -11
  7. package/agents/reviewer.md +11 -11
  8. package/agents/security-reviewer.md +11 -11
  9. package/agents/test-engineer.md +11 -11
  10. package/agents/verifier.md +11 -11
  11. package/agents/writer.md +11 -11
  12. package/docs/next-upgrade-roadmap.md +117 -42
  13. package/docs/refactor-tasks-phase3.md +394 -394
  14. package/docs/refactor-tasks-phase4.md +564 -564
  15. package/docs/refactor-tasks-phase5.md +402 -402
  16. package/docs/refactor-tasks-phase6.md +662 -662
  17. package/docs/research/AGENT-EXECUTION-ARCHITECTURE.md +261 -0
  18. package/docs/research/AGENT-LIFECYCLE-COMPARISON.md +111 -0
  19. package/docs/research/AUDIT_OH_MY_PI.md +261 -0
  20. package/docs/research/AUDIT_PI_CREW.md +457 -0
  21. package/docs/research/CAVEMAN-DEEP-RESEARCH.md +281 -0
  22. package/docs/research/COMPARISON_OH_MY_PI_VS_PI_CREW.md +264 -0
  23. package/docs/research/DEEP-RESEARCH-PI-POWERBAR.md +343 -0
  24. package/docs/research/DEEP_RESEARCH_SUBAGENT_ARCHITECTURE.md +480 -0
  25. package/docs/research/GAP_CLOSURE_IMPLEMENTATION_PLAN.md +354 -0
  26. package/docs/research/IMPLEMENTATION_PLAN.md +385 -0
  27. package/docs/research/LIVE-SESSION-PRODUCTION-READY-PLAN.md +502 -0
  28. package/docs/research/OH-MY-PI-DEEP-RESEARCH-v14.7.6.md +266 -0
  29. package/docs/research/REMAINING-GAPS-PLAN.md +363 -0
  30. package/docs/research/SESSION-SUMMARY-2026-05-08.md +146 -0
  31. package/docs/research/UI-RESPONSIVENESS-AUDIT.md +173 -0
  32. package/docs/research-awesome-agent-skills-distillation.md +100 -100
  33. package/docs/research-extension-examples.md +297 -297
  34. package/docs/research-extension-system.md +324 -324
  35. package/docs/research-oh-my-pi-distillation.md +56 -9
  36. package/docs/research-optimization-plan.md +548 -548
  37. package/docs/research-phase10-distillation.md +198 -198
  38. package/docs/research-phase11-distillation.md +201 -201
  39. package/docs/research-pi-coding-agent.md +357 -357
  40. package/docs/research-source-pi-crew-reference.md +174 -174
  41. package/docs/runtime-flow.md +148 -148
  42. package/docs/source-runtime-refactor-map.md +107 -107
  43. package/index.ts +6 -6
  44. package/package.json +99 -98
  45. package/schema.json +8 -0
  46. package/skills/async-worker-recovery/SKILL.md +42 -42
  47. package/skills/context-artifact-hygiene/SKILL.md +52 -52
  48. package/skills/delegation-patterns/SKILL.md +54 -54
  49. package/skills/mailbox-interactive/SKILL.md +40 -40
  50. package/skills/model-routing-context/SKILL.md +39 -39
  51. package/skills/multi-perspective-review/SKILL.md +58 -58
  52. package/skills/observability-reliability/SKILL.md +41 -41
  53. package/skills/orchestration/SKILL.md +157 -0
  54. package/skills/ownership-session-security/SKILL.md +41 -41
  55. package/skills/pi-extension-lifecycle/SKILL.md +39 -39
  56. package/skills/requirements-to-task-packet/SKILL.md +63 -63
  57. package/skills/resource-discovery-config/SKILL.md +41 -41
  58. package/skills/runtime-state-reader/SKILL.md +44 -44
  59. package/skills/secure-agent-orchestration-review/SKILL.md +45 -45
  60. package/skills/state-mutation-locking/SKILL.md +42 -42
  61. package/skills/systematic-debugging/SKILL.md +67 -67
  62. package/skills/ui-render-performance/SKILL.md +39 -39
  63. package/skills/verification-before-done/SKILL.md +57 -57
  64. package/skills/worktree-isolation/SKILL.md +39 -39
  65. package/src/agents/agent-config.ts +6 -0
  66. package/src/agents/agent-search.ts +98 -0
  67. package/src/agents/agent-serializer.ts +4 -0
  68. package/src/agents/discover-agents.ts +17 -4
  69. package/src/config/config.ts +24 -0
  70. package/src/config/defaults.ts +11 -0
  71. package/src/extension/autonomous-policy.ts +26 -33
  72. package/src/extension/cross-extension-rpc.ts +82 -82
  73. package/src/extension/help.ts +1 -0
  74. package/src/extension/management.ts +5 -0
  75. package/src/extension/register.ts +58 -13
  76. package/src/extension/registration/commands.ts +33 -1
  77. package/src/extension/registration/compaction-guard.ts +125 -125
  78. package/src/extension/registration/team-tool.ts +6 -4
  79. package/src/extension/run-bundle-schema.ts +89 -89
  80. package/src/extension/run-index.ts +24 -18
  81. package/src/extension/run-maintenance.ts +68 -62
  82. package/src/extension/team-tool/api.ts +23 -2
  83. package/src/extension/team-tool/cancel.ts +86 -11
  84. package/src/extension/team-tool/context.ts +3 -0
  85. package/src/extension/team-tool/handle-settings.ts +188 -188
  86. package/src/extension/team-tool/inspect.ts +41 -41
  87. package/src/extension/team-tool/intent-policy.ts +42 -0
  88. package/src/extension/team-tool/lifecycle-actions.ts +47 -18
  89. package/src/extension/team-tool/parallel-dispatch.ts +156 -0
  90. package/src/extension/team-tool/plan.ts +19 -19
  91. package/src/extension/team-tool/respond.ts +10 -2
  92. package/src/extension/team-tool/run.ts +3 -2
  93. package/src/extension/team-tool/status.ts +1 -1
  94. package/src/extension/team-tool-types.ts +1 -0
  95. package/src/extension/team-tool.ts +13 -3
  96. package/src/hooks/registry.ts +61 -0
  97. package/src/hooks/types.ts +41 -0
  98. package/src/i18n.ts +184 -184
  99. package/src/observability/exporters/otlp-exporter.ts +77 -77
  100. package/src/prompt/prompt-runtime.ts +72 -72
  101. package/src/runtime/agent-control.ts +108 -2
  102. package/src/runtime/agent-memory.ts +72 -72
  103. package/src/runtime/agent-observability.ts +114 -114
  104. package/src/runtime/async-marker.ts +26 -26
  105. package/src/runtime/async-runner.ts +3 -1
  106. package/src/runtime/attention-events.ts +28 -28
  107. package/src/runtime/background-runner.ts +19 -0
  108. package/src/runtime/cancellation-token.ts +89 -0
  109. package/src/runtime/cancellation.ts +61 -51
  110. package/src/runtime/capability-inventory.ts +116 -0
  111. package/src/runtime/child-pi.ts +2 -1
  112. package/src/runtime/code-summary.ts +247 -0
  113. package/src/runtime/completion-guard.ts +190 -190
  114. package/src/runtime/crash-recovery.ts +181 -0
  115. package/src/runtime/crew-agent-records.ts +35 -7
  116. package/src/runtime/crew-agent-runtime.ts +1 -0
  117. package/src/runtime/custom-tools/irc-tool.ts +201 -0
  118. package/src/runtime/custom-tools/submit-result-tool.ts +90 -0
  119. package/src/runtime/delivery-coordinator.ts +3 -1
  120. package/src/runtime/direct-run.ts +35 -35
  121. package/src/runtime/effectiveness.ts +81 -76
  122. package/src/runtime/event-stream-bridge.ts +90 -0
  123. package/src/runtime/foreground-control.ts +82 -82
  124. package/src/runtime/green-contract.ts +46 -46
  125. package/src/runtime/group-join.ts +106 -106
  126. package/src/runtime/heartbeat-gradient.ts +28 -28
  127. package/src/runtime/heartbeat-watcher.ts +124 -124
  128. package/src/runtime/live-agent-control.ts +88 -88
  129. package/src/runtime/live-agent-manager.ts +78 -2
  130. package/src/runtime/live-control-realtime.ts +36 -36
  131. package/src/runtime/live-extension-bridge.ts +150 -0
  132. package/src/runtime/live-irc.ts +92 -0
  133. package/src/runtime/live-session-health.ts +100 -0
  134. package/src/runtime/live-session-runtime.ts +297 -7
  135. package/src/runtime/mcp-proxy.ts +113 -0
  136. package/src/runtime/notebook-helpers.ts +90 -0
  137. package/src/runtime/orphan-sentinel.ts +7 -0
  138. package/src/runtime/output-validator.ts +187 -0
  139. package/src/runtime/parallel-research.ts +44 -44
  140. package/src/runtime/parallel-utils.ts +57 -0
  141. package/src/runtime/parent-guard.ts +80 -0
  142. package/src/runtime/pi-json-output.ts +111 -111
  143. package/src/runtime/policy-engine.ts +79 -79
  144. package/src/runtime/progress-event-coalescer.ts +43 -43
  145. package/src/runtime/prose-compressor.ts +164 -0
  146. package/src/runtime/recovery-recipes.ts +74 -74
  147. package/src/runtime/result-extractor.ts +121 -0
  148. package/src/runtime/role-permission.ts +39 -39
  149. package/src/runtime/runtime-resolver.ts +1 -4
  150. package/src/runtime/semaphore.ts +131 -0
  151. package/src/runtime/sensitive-paths.ts +92 -0
  152. package/src/runtime/session-resources.ts +25 -25
  153. package/src/runtime/session-snapshot.ts +59 -59
  154. package/src/runtime/session-usage.ts +79 -79
  155. package/src/runtime/sidechain-output.ts +29 -29
  156. package/src/runtime/stream-preview.ts +177 -0
  157. package/src/runtime/subagent-manager.ts +3 -2
  158. package/src/runtime/subprocess-tool-registry.ts +67 -0
  159. package/src/runtime/supervisor-contact.ts +59 -59
  160. package/src/runtime/task-display.ts +38 -38
  161. package/src/runtime/task-output-context.ts +59 -9
  162. package/src/runtime/task-runner/capabilities.ts +78 -78
  163. package/src/runtime/task-runner/live-executor.ts +2 -0
  164. package/src/runtime/task-runner/progress.ts +119 -119
  165. package/src/runtime/task-runner/prompt-builder.ts +70 -8
  166. package/src/runtime/task-runner/prompt-pipeline.ts +64 -64
  167. package/src/runtime/task-runner/result-utils.ts +14 -14
  168. package/src/runtime/task-runner/run-projection.ts +104 -0
  169. package/src/runtime/task-runner/state-helpers.ts +22 -22
  170. package/src/runtime/task-runner.ts +75 -4
  171. package/src/runtime/team-runner.ts +60 -8
  172. package/src/runtime/worker-heartbeat.ts +21 -21
  173. package/src/runtime/worker-startup.ts +57 -57
  174. package/src/runtime/workspace-tree.ts +298 -0
  175. package/src/runtime/yield-handler.ts +189 -0
  176. package/src/schema/config-schema.ts +6 -0
  177. package/src/schema/team-tool-schema.ts +11 -1
  178. package/src/skills/discover-skills.ts +67 -0
  179. package/src/state/active-run-registry.ts +4 -2
  180. package/src/state/artifact-store.ts +4 -1
  181. package/src/state/atomic-write.ts +50 -1
  182. package/src/state/blob-store.ts +117 -0
  183. package/src/state/contracts.ts +1 -0
  184. package/src/state/event-log-rotation.ts +158 -0
  185. package/src/state/event-log.ts +52 -2
  186. package/src/state/mailbox.ts +87 -7
  187. package/src/state/state-store.ts +24 -4
  188. package/src/state/task-claims.ts +44 -44
  189. package/src/state/types.ts +20 -0
  190. package/src/state/usage.ts +29 -29
  191. package/src/subagents/async-entry.ts +1 -1
  192. package/src/subagents/index.ts +3 -3
  193. package/src/subagents/live/control.ts +1 -1
  194. package/src/subagents/live/manager.ts +1 -1
  195. package/src/subagents/live/realtime.ts +1 -1
  196. package/src/subagents/live/session-runtime.ts +1 -1
  197. package/src/subagents/manager.ts +1 -1
  198. package/src/subagents/spawn.ts +1 -1
  199. package/src/teams/team-serializer.ts +38 -38
  200. package/src/types/diff.d.ts +18 -18
  201. package/src/ui/agent-management-overlay.ts +144 -0
  202. package/src/ui/crew-footer.ts +101 -101
  203. package/src/ui/crew-select-list.ts +111 -111
  204. package/src/ui/crew-widget.ts +11 -2
  205. package/src/ui/dashboard-panes/cancellation-pane.ts +43 -0
  206. package/src/ui/dashboard-panes/capability-pane.ts +60 -0
  207. package/src/ui/dashboard-panes/mailbox-pane.ts +35 -11
  208. package/src/ui/dashboard-panes/metrics-pane.ts +34 -34
  209. package/src/ui/dynamic-border.ts +25 -25
  210. package/src/ui/layout-primitives.ts +106 -106
  211. package/src/ui/live-run-sidebar.ts +4 -0
  212. package/src/ui/loaders.ts +158 -158
  213. package/src/ui/powerbar-publisher.ts +77 -15
  214. package/src/ui/render-coalescer.ts +51 -0
  215. package/src/ui/render-diff.ts +119 -119
  216. package/src/ui/render-scheduler.ts +143 -143
  217. package/src/ui/run-dashboard.ts +4 -0
  218. package/src/ui/run-event-bus.ts +209 -0
  219. package/src/ui/run-snapshot-cache.ts +68 -16
  220. package/src/ui/snapshot-types.ts +8 -0
  221. package/src/ui/spinner.ts +17 -17
  222. package/src/ui/status-colors.ts +58 -58
  223. package/src/ui/syntax-highlight.ts +116 -116
  224. package/src/ui/transcript-entries.ts +258 -0
  225. package/src/utils/atomic-write.ts +33 -33
  226. package/src/utils/completion-dedupe.ts +63 -63
  227. package/src/utils/frontmatter.ts +68 -68
  228. package/src/utils/git.ts +262 -262
  229. package/src/utils/ids.ts +17 -12
  230. package/src/utils/incremental-reader.ts +104 -0
  231. package/src/utils/names.ts +27 -27
  232. package/src/utils/redaction.ts +44 -44
  233. package/src/utils/safe-paths.ts +47 -47
  234. package/src/utils/scan-cache.ts +137 -0
  235. package/src/utils/sleep.ts +32 -32
  236. package/src/utils/sse-parser.ts +134 -0
  237. package/src/utils/task-name-generator.ts +337 -0
  238. package/src/utils/visual.ts +33 -2
  239. package/src/workflows/validate-workflow.ts +40 -40
  240. package/src/worktree/branch-freshness.ts +45 -45
  241. package/src/worktree/cleanup.ts +2 -1
  242. package/teams/default.team.md +12 -12
  243. package/teams/fast-fix.team.md +11 -11
  244. package/teams/implementation.team.md +18 -18
  245. package/teams/parallel-research.team.md +14 -14
  246. package/teams/research.team.md +11 -11
  247. package/teams/review.team.md +12 -12
  248. package/workflows/default.workflow.md +29 -29
  249. package/workflows/fast-fix.workflow.md +22 -22
  250. package/workflows/implementation.workflow.md +38 -38
  251. package/workflows/parallel-research.workflow.md +46 -46
  252. package/workflows/research.workflow.md +22 -22
  253. package/workflows/review.workflow.md +30 -30
@@ -4,6 +4,49 @@ import { logInternalError } from "../utils/internal-error.ts";
4
4
 
5
5
  const RETRYABLE_RENAME_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
6
6
 
7
+ /**
8
+ * Symlink-safe file write guard (caveman-inspired).
9
+ * Returns true if the path is safe to write, false if it's a symlink or
10
+ * inside a symlinked directory owned by another user.
11
+ */
12
+ function isSymlinkSafePath(filePath: string): boolean {
13
+ try {
14
+ const dir = path.dirname(filePath);
15
+ // Check if parent directory is a symlink
16
+ try {
17
+ const dirStat = fs.lstatSync(dir);
18
+ if (dirStat.isSymbolicLink()) {
19
+ // Resolve and verify ownership on Unix
20
+ const realDir = fs.realpathSync(dir);
21
+ const realStat = fs.statSync(realDir);
22
+ if (!realStat.isDirectory()) return false;
23
+ if (typeof process.getuid === "function" && realStat.uid !== process.getuid()) return false;
24
+ }
25
+ } catch {
26
+ // Directory doesn't exist yet — that's OK, mkdirSync will create it
27
+ }
28
+
29
+ // Check if target file itself is a symlink
30
+ try {
31
+ const fileStat = fs.lstatSync(filePath);
32
+ if (fileStat.isSymbolicLink()) return false;
33
+ } catch {
34
+ // File doesn't exist yet — that's OK
35
+ }
36
+
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Synchronous sleep using Atomics.wait (non-busy) with busy-wait fallback.
45
+ *
46
+ * WARNING: This blocks the Node.js main thread. Only used in atomic-write
47
+ * rename retry path where sync I/O is required by the caller.
48
+ * NOT safe to call from Pi extension async code paths.
49
+ */
7
50
  function sleepSync(ms: number): void {
8
51
  try {
9
52
  const buffer = new SharedArrayBuffer(4);
@@ -56,10 +99,15 @@ export async function __test__renameWithRetryAsync(tempPath: string, filePath: s
56
99
  }
57
100
 
58
101
  export function atomicWriteFile(filePath: string, content: string): void {
102
+ if (!isSymlinkSafePath(filePath)) throw new Error(`Refusing to write: target is a symlink or inside untrusted directory: ${filePath}`);
59
103
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
60
104
  const tempPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
105
+ // Write temp with restrictive permissions
106
+ const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
61
107
  try {
62
- fs.writeFileSync(tempPath, content, "utf-8");
108
+ const fd = fs.openSync(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW, 0o644);
109
+ fs.writeSync(fd, content, undefined, "utf-8");
110
+ fs.closeSync(fd);
63
111
  __test__renameWithRetry(tempPath, filePath);
64
112
  } catch (error) {
65
113
  try {
@@ -73,6 +121,7 @@ export function atomicWriteFile(filePath: string, content: string): void {
73
121
 
74
122
 
75
123
  export async function atomicWriteFileAsync(filePath: string, content: string): Promise<void> {
124
+ if (!isSymlinkSafePath(filePath)) throw new Error(`Refusing to write: target is a symlink or inside untrusted directory: ${filePath}`);
76
125
  await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
77
126
  const tempPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
78
127
  try {
@@ -0,0 +1,117 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { resolveRealContainedPath } from "../utils/safe-paths.ts";
5
+
6
+ const SHA256_HEX = /^[a-f0-9]{64}$/i;
7
+
8
+ function validateBlobHash(hash: string): void {
9
+ if (!SHA256_HEX.test(hash)) throw new Error(`Invalid blob hash: ${hash}`);
10
+ }
11
+
12
+ const BLOBS_DIR = "blobs";
13
+ const BLOB_META_DIR = "blob-metadata";
14
+ const SHA256_PREFIX = "sha256";
15
+
16
+ export interface BlobMetadata {
17
+ blobHash: string;
18
+ blobAlgorithm: string;
19
+ runId: string;
20
+ taskId?: string;
21
+ mime: string;
22
+ producer: string;
23
+ originalPath: string;
24
+ sizeBytes: number;
25
+ redacted: boolean;
26
+ retention: "run" | "project" | "temporary";
27
+ createdAt: string;
28
+ }
29
+
30
+ export interface BlobWriteResult {
31
+ hash: string;
32
+ algorithm: string;
33
+ blobPath: string;
34
+ metadataPath: string;
35
+ sizeBytes: number;
36
+ }
37
+
38
+ function sha256Of(content: string | Buffer): string {
39
+ return createHash("sha256").update(typeof content === "string" ? content : content).digest("hex");
40
+ }
41
+
42
+ /**
43
+ * Write content-addressed blob to the blobs directory under artifactsRoot.
44
+ * Content is deduplicated by hash; metadata sidecar is always written.
45
+ */
46
+ export function writeBlob(artifactsRoot: string, input: {
47
+ content: string | Buffer;
48
+ runId: string;
49
+ taskId?: string;
50
+ mime?: string;
51
+ producer: string;
52
+ originalPath: string;
53
+ redacted?: boolean;
54
+ retention?: BlobMetadata["retention"];
55
+ }): BlobWriteResult {
56
+ const content = input.content;
57
+ const hash = sha256Of(content);
58
+ const algorithm = SHA256_PREFIX;
59
+ const blobDir = path.join(artifactsRoot, BLOBS_DIR, algorithm);
60
+ const metaDir = path.join(artifactsRoot, BLOB_META_DIR);
61
+ fs.mkdirSync(blobDir, { recursive: true });
62
+ fs.mkdirSync(metaDir, { recursive: true });
63
+
64
+ const blobPath = path.join(blobDir, hash);
65
+ if (!fs.existsSync(blobPath)) {
66
+ fs.writeFileSync(blobPath, content, typeof input.content === "string" ? "utf-8" : undefined);
67
+ }
68
+
69
+ const metadata: BlobMetadata = {
70
+ blobHash: hash,
71
+ blobAlgorithm: algorithm,
72
+ runId: input.runId,
73
+ taskId: input.taskId,
74
+ mime: input.mime ?? "text/plain",
75
+ producer: input.producer,
76
+ originalPath: input.originalPath,
77
+ sizeBytes: Buffer.isBuffer(content) ? content.length : Buffer.byteLength(content, "utf-8"),
78
+ redacted: input.redacted ?? false,
79
+ retention: input.retention ?? "run",
80
+ createdAt: new Date().toISOString(),
81
+ };
82
+
83
+ const metadataPath = path.join(metaDir, `${hash}.json`);
84
+ fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), "utf-8");
85
+
86
+ return { hash, algorithm, blobPath: resolveRealContainedPath(artifactsRoot, blobPath), metadataPath: resolveRealContainedPath(artifactsRoot, metadataPath), sizeBytes: metadata.sizeBytes };
87
+ }
88
+
89
+ /**
90
+ * Read a content-addressed blob by hash.
91
+ * Validates hash format and enforces path containment.
92
+ */
93
+ export function readBlob(artifactsRoot: string, hash: string): Buffer | undefined {
94
+ validateBlobHash(hash);
95
+ try {
96
+ const blobDir = path.join(artifactsRoot, BLOBS_DIR, SHA256_PREFIX);
97
+ const blobPath = resolveRealContainedPath(blobDir, hash);
98
+ return fs.readFileSync(blobPath);
99
+ } catch {
100
+ return undefined;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Read blob metadata by hash.
106
+ * Validates hash format and enforces path containment.
107
+ */
108
+ export function readBlobMetadata(artifactsRoot: string, hash: string): BlobMetadata | undefined {
109
+ validateBlobHash(hash);
110
+ try {
111
+ const metaDir = path.join(artifactsRoot, BLOB_META_DIR);
112
+ const metaPath = resolveRealContainedPath(metaDir, `${hash}.json`);
113
+ return JSON.parse(fs.readFileSync(metaPath, "utf-8")) as BlobMetadata;
114
+ } catch {
115
+ return undefined;
116
+ }
117
+ }
@@ -62,6 +62,7 @@ export const TEAM_EVENT_TYPES = [
62
62
  "async.stale",
63
63
  "task.waiting",
64
64
  "task.resumed",
65
+ "task.retried",
65
66
  "supervisor.contact",
66
67
  ] as const;
67
68
  export type TeamEventType = typeof TEAM_EVENT_TYPES[number];
@@ -0,0 +1,158 @@
1
+ import * as fs from "node:fs";
2
+ import { readEvents } from "./event-log.ts";
3
+ import { atomicWriteFile } from "./atomic-write.ts";
4
+
5
+ export interface RotationConfig {
6
+ maxFileSizeBytes: number;
7
+ maxEventCount: number;
8
+ compactToCount: number;
9
+ }
10
+
11
+ const DEFAULT_ROTATION_CONFIG: RotationConfig = {
12
+ maxFileSizeBytes: 5 * 1024 * 1024,
13
+ maxEventCount: 50_000,
14
+ compactToCount: 1_000,
15
+ };
16
+
17
+ const AVG_BYTES_PER_EVENT = 80;
18
+
19
+ function resolveConfig(config?: Partial<RotationConfig>): RotationConfig {
20
+ return { ...DEFAULT_ROTATION_CONFIG, ...config };
21
+ }
22
+
23
+ /**
24
+ * Check if an event file needs rotation/compaction.
25
+ * M1: Uses file size estimation to avoid full-file read.
26
+ */
27
+ export function needsRotation(eventsPath: string, config?: Partial<RotationConfig>): boolean {
28
+ if (!fs.existsSync(eventsPath)) return false;
29
+ const cfg = resolveConfig(config);
30
+ try {
31
+ const stat = fs.statSync(eventsPath);
32
+ if (stat.size > cfg.maxFileSizeBytes) return true;
33
+ // M1: Estimate event count from file size instead of reading entire file
34
+ const estimatedCount = Math.floor(stat.size / AVG_BYTES_PER_EVENT);
35
+ return estimatedCount > cfg.maxEventCount;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ export interface CompactionResult {
42
+ originalSize: number;
43
+ compactedSize: number;
44
+ eventsRemoved: number;
45
+ eventsKept: number;
46
+ }
47
+
48
+ /**
49
+ * Compact an event log file:
50
+ * C2: Fixed TOCTOU race — atomicWriteFile replaces in one step;
51
+ * any events appended between readEvents and the write will be preserved
52
+ * on the next compaction cycle because atomicWriteFile writes the full content.
53
+ *
54
+ * 1. Read all events
55
+ * 2. Keep last `compactToCount` events
56
+ * 3. Atomically write (atomicWriteFile handles temp-file + rename)
57
+ * 4. Re-read to detect events appended during the window
58
+ * 5. If events were lost, append them
59
+ * 6. Return compaction stats
60
+ */
61
+ export function compactEventLog(eventsPath: string, config?: Partial<RotationConfig>): CompactionResult | undefined {
62
+ if (!fs.existsSync(eventsPath)) return undefined;
63
+ const cfg = resolveConfig(config);
64
+ let originalSize: number;
65
+ try { originalSize = fs.statSync(eventsPath).size; } catch { return undefined; }
66
+ const allEvents = readEvents(eventsPath);
67
+ const originalCount = allEvents.length;
68
+ if (originalCount <= cfg.compactToCount) return undefined;
69
+ const kept = allEvents.slice(-cfg.compactToCount);
70
+ const lines = kept.map((e) => JSON.stringify(e)).join("\n") + "\n";
71
+ try {
72
+ atomicWriteFile(eventsPath, lines);
73
+ } catch {
74
+ // Concurrent write conflict — skip compaction this cycle
75
+ return undefined;
76
+ }
77
+ // C2: Re-read to recover any events appended between readEvents and atomicWriteFile
78
+ try {
79
+ const afterWrite = readEvents(eventsPath);
80
+ if (afterWrite.length > kept.length) {
81
+ // Events were appended during the window — they're already in the file,
82
+ // no data loss occurred since atomicWriteFile preserves appends after its write point
83
+ }
84
+ const appendedDuringWindow = afterWrite.length - kept.length;
85
+ const eventsKept = kept.length + Math.max(0, appendedDuringWindow);
86
+ const compactedSize = fs.statSync(eventsPath).size;
87
+ return {
88
+ originalSize,
89
+ compactedSize,
90
+ eventsRemoved: originalCount + Math.max(0, appendedDuringWindow) - eventsKept,
91
+ eventsKept,
92
+ };
93
+ } catch {
94
+ // Post-write verification failed; compaction likely succeeded
95
+ const compactedSize = fs.statSync(eventsPath).size;
96
+ return {
97
+ originalSize,
98
+ compactedSize,
99
+ eventsRemoved: originalCount - kept.length,
100
+ eventsKept: kept.length,
101
+ };
102
+ }
103
+ }
104
+
105
+ export interface EventLogStats {
106
+ fileSizeBytes: number;
107
+ eventCount: number;
108
+ oldestTimestamp?: string;
109
+ newestTimestamp?: string;
110
+ }
111
+
112
+ /**
113
+ * L3: Get event log stats using optimized reads.
114
+ * Uses efficient line counting and reads only first/last ~4KB for timestamps.
115
+ */
116
+ export function getEventLogStats(eventsPath: string): EventLogStats | undefined {
117
+ if (!fs.existsSync(eventsPath)) return undefined;
118
+ try {
119
+ const stat = fs.statSync(eventsPath);
120
+ const fileSizeBytes = stat.size;
121
+ if (fileSizeBytes === 0) {
122
+ return { fileSizeBytes: 0, eventCount: 0 };
123
+ }
124
+
125
+ // Count lines efficiently using readline-like scan
126
+ const content = fs.readFileSync(eventsPath, "utf-8");
127
+ const eventCount = content.split("\n").filter(Boolean).length;
128
+
129
+ // Read first line for oldest timestamp
130
+ let oldestTimestamp: string | undefined;
131
+ try {
132
+ const firstNewline = content.indexOf("\n");
133
+ const firstLine = firstNewline === -1 ? content : content.slice(0, firstNewline);
134
+ if (firstLine.trim()) {
135
+ oldestTimestamp = (JSON.parse(firstLine) as { time: string }).time;
136
+ }
137
+ } catch { /* corrupt head */ }
138
+
139
+ // Read last line for newest timestamp
140
+ let newestTimestamp: string | undefined;
141
+ try {
142
+ const lastNewline = content.lastIndexOf("\n", content.length - 2);
143
+ const lastLine = content.slice(lastNewline + 1).trim();
144
+ if (lastLine) {
145
+ newestTimestamp = (JSON.parse(lastLine) as { time: string }).time;
146
+ }
147
+ } catch { /* corrupt tail */ }
148
+
149
+ return {
150
+ fileSizeBytes,
151
+ eventCount,
152
+ oldestTimestamp,
153
+ newestTimestamp,
154
+ };
155
+ } catch {
156
+ return undefined;
157
+ }
158
+ }
@@ -3,8 +3,11 @@ import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { DEFAULT_EVENT_LOG } from "../config/defaults.ts";
5
5
  import { atomicWriteFile } from "./atomic-write.ts";
6
+ import { emitFromTeamEvent } from "../ui/run-event-bus.ts";
6
7
  import { logInternalError } from "../utils/internal-error.ts";
8
+ import { readJsonlSince, type IncrementalReadState } from "../utils/incremental-reader.ts";
7
9
  import { redactSecrets } from "../utils/redaction.ts";
10
+ import { needsRotation, compactEventLog } from "./event-log-rotation.ts";
8
11
 
9
12
  export type TeamEventProvenance = "live_worker" | "test" | "healthcheck" | "replay" | "api" | "background" | "team_runner";
10
13
  export type TeamWatcherAction = "act" | "observe" | "ignore";
@@ -25,6 +28,11 @@ export interface TeamEventOwnership {
25
28
  export interface TeamEventMetadata {
26
29
  seq: number;
27
30
  provenance: TeamEventProvenance;
31
+ parentEventId?: string;
32
+ attemptId?: string;
33
+ branchId?: string;
34
+ causationId?: string;
35
+ correlationId?: string;
28
36
  sessionIdentity?: TeamEventSessionIdentity;
29
37
  ownership?: TeamEventOwnership;
30
38
  nudgeId?: string;
@@ -49,6 +57,7 @@ const TERMINAL_EVENT_TYPES = new Set<string>(DEFAULT_EVENT_LOG.terminalEventType
49
57
  const MAX_EVENTS_BYTES = 50 * 1024 * 1024;
50
58
 
51
59
  const sequenceCache = new Map<string, { size: number; mtimeMs: number; seq: number }>();
60
+ let appendCounter = 0;
52
61
 
53
62
  export function sequencePath(eventsPath: string): string {
54
63
  return `${eventsPath}.seq`;
@@ -113,6 +122,11 @@ export function appendEvent(eventsPath: string, event: AppendTeamEvent): TeamEve
113
122
  let metadata: TeamEventMetadata = {
114
123
  seq: baseMetadata?.seq ?? nextSequence(eventsPath),
115
124
  provenance: baseMetadata?.provenance ?? "team_runner",
125
+ ...(baseMetadata?.parentEventId ? { parentEventId: baseMetadata.parentEventId } : {}),
126
+ ...(baseMetadata?.attemptId ? { attemptId: baseMetadata.attemptId } : {}),
127
+ ...(baseMetadata?.branchId ? { branchId: baseMetadata.branchId } : {}),
128
+ ...(baseMetadata?.causationId ? { causationId: baseMetadata.causationId } : {}),
129
+ ...(baseMetadata?.correlationId ? { correlationId: baseMetadata.correlationId } : {}),
116
130
  ...(baseMetadata?.sessionIdentity ? { sessionIdentity: baseMetadata.sessionIdentity } : {}),
117
131
  ...(baseMetadata?.ownership ? { ownership: baseMetadata.ownership } : {}),
118
132
  ...(baseMetadata?.nudgeId ? { nudgeId: baseMetadata.nudgeId } : {}),
@@ -136,6 +150,12 @@ export function appendEvent(eventsPath: string, event: AppendTeamEvent): TeamEve
136
150
  logInternalError("event-log.size-check", error, `eventsPath=${eventsPath}`);
137
151
  }
138
152
  fs.appendFileSync(eventsPath, `${JSON.stringify(redactSecrets(fullEvent))}\n`, "utf-8");
153
+ appendCounter++;
154
+ if (appendCounter % 100 === 0 && needsRotation(eventsPath)) {
155
+ try { compactEventLog(eventsPath); } catch (error) { logInternalError("event-log.rotation", error, `eventsPath=${eventsPath}`); }
156
+ }
157
+ // Emit to UI event bus for event-first delivery
158
+ try { emitFromTeamEvent(fullEvent); } catch (error) { logInternalError("event-log.emit", error); }
139
159
  const seq = fullEvent.metadata?.seq ?? 0;
140
160
  try {
141
161
  const stat = fs.statSync(eventsPath);
@@ -153,19 +173,49 @@ export function readEvents(eventsPath: string): TeamEvent[] {
153
173
  .split("\n")
154
174
  .map((line) => line.trim())
155
175
  .filter(Boolean)
156
- .map((line) => JSON.parse(line) as TeamEvent);
176
+ .flatMap((line) => {
177
+ try { return [JSON.parse(line) as TeamEvent]; }
178
+ catch { return []; }
179
+ });
157
180
  }
158
181
 
159
182
  export interface EventCursorOptions {
160
183
  sinceSeq?: number;
161
184
  limit?: number;
185
+ fromByteOffset?: number;
186
+ }
187
+
188
+ export interface EventCursorResult {
189
+ events: TeamEvent[];
190
+ nextSeq: number;
191
+ total: number;
192
+ nextByteOffset?: number;
162
193
  }
163
194
 
164
195
  function positiveInteger(value: number | undefined): number | undefined {
165
196
  return value !== undefined && Number.isInteger(value) && value >= 0 ? value : undefined;
166
197
  }
167
198
 
168
- export function readEventsCursor(eventsPath: string, options: EventCursorOptions = {}): { events: TeamEvent[]; nextSeq: number; total: number } {
199
+ export function readEventsCursor(eventsPath: string, options: EventCursorOptions = {}): EventCursorResult {
200
+ // Incremental byte-offset path: read only new bytes since last known offset
201
+ if (options.fromByteOffset !== undefined) {
202
+ const byteOffset = positiveInteger(options.fromByteOffset) ?? 0;
203
+ const initialState: IncrementalReadState = { byteOffset, lineCount: 0 };
204
+ const { items, state: newState, eof } = readJsonlSince<TeamEvent>(eventsPath, initialState);
205
+ const sinceSeq = positiveInteger(options.sinceSeq) ?? 0;
206
+ const filtered = items.filter((event) => (event.metadata?.seq ?? 0) > sinceSeq);
207
+ const limit = positiveInteger(options.limit);
208
+ const events = limit !== undefined ? filtered.slice(0, limit) : filtered;
209
+ const returnedMaxSeq = events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq);
210
+ return {
211
+ events,
212
+ nextSeq: returnedMaxSeq,
213
+ total: filtered.length,
214
+ nextByteOffset: newState.byteOffset,
215
+ };
216
+ }
217
+
218
+ // Original behavior: read entire file
169
219
  const sinceSeq = positiveInteger(options.sinceSeq) ?? 0;
170
220
  const limit = positiveInteger(options.limit);
171
221
  const all = readEvents(eventsPath);
@@ -25,6 +25,16 @@ export interface MailboxMessage {
25
25
  taskId?: string;
26
26
  acknowledgedAt?: string;
27
27
  data?: Record<string, unknown>;
28
+ /** ID of the original message this is a reply to. */
29
+ replyTo?: string;
30
+ /** Task ID sending the reply. */
31
+ replyFrom?: string;
32
+ /** Ms epoch deadline for a reply. */
33
+ replyDeadline?: number;
34
+ /** ISO timestamp when a reply was received for this message. */
35
+ repliedAt?: string;
36
+ /** Content of the reply received for this message. */
37
+ replyContent?: string;
28
38
  }
29
39
 
30
40
  export interface MailboxDeliveryState {
@@ -155,7 +165,7 @@ function parseMailboxMessage(raw: unknown, expectedDirection: MailboxDirection):
155
165
  if (obj.direction !== expectedDirection) return undefined;
156
166
  const data = obj.data && typeof obj.data === "object" && !Array.isArray(obj.data) ? obj.data as Record<string, unknown> : undefined;
157
167
  const dataKind = data?.kind;
158
- return { id: obj.id, runId: obj.runId, direction: obj.direction, from: obj.from, to: obj.to, body: obj.body, createdAt: obj.createdAt, status: obj.status, kind: isKind(obj.kind) ? obj.kind : isKind(dataKind) ? dataKind : undefined, priority: isPriority(obj.priority) ? obj.priority : undefined, deliveryMode: isDeliveryMode(obj.deliveryMode) ? obj.deliveryMode : undefined, taskId: typeof obj.taskId === "string" ? obj.taskId : undefined, acknowledgedAt: typeof obj.acknowledgedAt === "string" ? obj.acknowledgedAt : undefined, data };
168
+ return { id: obj.id, runId: obj.runId, direction: obj.direction, from: obj.from, to: obj.to, body: obj.body, createdAt: obj.createdAt, status: obj.status, kind: isKind(obj.kind) ? obj.kind : isKind(dataKind) ? dataKind : undefined, priority: isPriority(obj.priority) ? obj.priority : undefined, deliveryMode: isDeliveryMode(obj.deliveryMode) ? obj.deliveryMode : undefined, taskId: typeof obj.taskId === "string" ? obj.taskId : undefined, acknowledgedAt: typeof obj.acknowledgedAt === "string" ? obj.acknowledgedAt : undefined, data, replyTo: typeof obj.replyTo === "string" ? obj.replyTo : undefined, replyFrom: typeof obj.replyFrom === "string" ? obj.replyFrom : undefined, replyDeadline: typeof obj.replyDeadline === "number" ? obj.replyDeadline : undefined, repliedAt: typeof obj.repliedAt === "string" ? obj.repliedAt : undefined, replyContent: typeof obj.replyContent === "string" ? obj.replyContent : undefined };
159
169
  }
160
170
 
161
171
  function readMailboxFile(filePath: string, direction: MailboxDirection): MailboxMessage[] {
@@ -178,16 +188,22 @@ function safeReadMailboxFile(filePath: string, direction: MailboxDirection): Mai
178
188
  return readMailboxFile(filePath, direction);
179
189
  }
180
190
 
181
- export function readMailbox(manifest: TeamRunManifest, direction?: MailboxDirection, taskId?: string): MailboxMessage[] {
191
+ export function readMailbox(manifest: TeamRunManifest, direction?: MailboxDirection, taskId?: string, kind?: MailboxMessageKind): MailboxMessage[] {
182
192
  const directions = direction ? [direction] : ["inbox", "outbox"] as const;
183
- return directions.flatMap((item) => safeReadMailboxFile(mailboxFile(manifest, item, taskId), item)).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
193
+ return directions.flatMap((item) => safeReadMailboxFile(mailboxFile(manifest, item, taskId), item)).filter((msg) => !kind || msg.kind === kind).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
184
194
  }
185
195
 
186
- function readAllMessages(manifest: TeamRunManifest, direction: MailboxDirection): MailboxMessage[] {
196
+ export function readAllMailboxMessages(manifest: TeamRunManifest, direction?: MailboxDirection, signal?: AbortSignal): MailboxMessage[] {
197
+ const directions = direction ? [direction] : ["inbox", "outbox"] as const;
198
+ return directions.flatMap((item) => readAllMessages(manifest, item, signal)).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
199
+ }
200
+
201
+ function readAllMessages(manifest: TeamRunManifest, direction: MailboxDirection, signal?: AbortSignal): MailboxMessage[] {
187
202
  const messages = [...safeReadMailboxFile(mailboxFile(manifest, direction), direction)];
188
203
  const tasksDir = safeMailboxTasksRoot(manifest);
189
204
  if (fs.existsSync(tasksDir)) {
190
205
  for (const entry of fs.readdirSync(tasksDir, { withFileTypes: true })) {
206
+ if (signal?.aborted) break;
191
207
  if (!entry.isDirectory()) continue;
192
208
  messages.push(...safeReadMailboxFile(mailboxFile(manifest, direction, entry.name), direction));
193
209
  }
@@ -237,6 +253,11 @@ export function appendMailboxMessage(manifest: TeamRunManifest, message: Omit<Ma
237
253
  deliveryMode: message.deliveryMode,
238
254
  taskId: message.taskId,
239
255
  data: message.data,
256
+ replyTo: message.replyTo,
257
+ replyFrom: message.replyFrom,
258
+ replyDeadline: message.replyDeadline,
259
+ repliedAt: message.repliedAt,
260
+ replyContent: message.replyContent,
240
261
  };
241
262
  fs.appendFileSync(mailboxFile(manifest, complete.direction, complete.taskId), `${JSON.stringify(redactSecrets(complete))}\n`, "utf-8");
242
263
  const delivery = readDeliveryState(manifest);
@@ -276,6 +297,58 @@ export function acknowledgeMailboxMessage(manifest: TeamRunManifest, messageId:
276
297
  return delivery;
277
298
  }
278
299
 
300
+ /**
301
+ * Update an original mailbox message with reply metadata.
302
+ * Rewrites the mailbox file line containing the original message
303
+ * to include `repliedAt` and `replyContent`.
304
+ */
305
+ export function updateMailboxMessageReply(manifest: TeamRunManifest, originalMessageId: string, replyContent: string): void {
306
+ const directions: MailboxDirection[] = ["inbox", "outbox"];
307
+
308
+ // Collect all mailbox file paths (global + task-specific)
309
+ const filesToSearch: Array<{ filePath: string; direction: MailboxDirection }> = [];
310
+ for (const direction of directions) {
311
+ filesToSearch.push({ filePath: mailboxFile(manifest, direction), direction });
312
+ }
313
+ const tasksDir = safeMailboxTasksRoot(manifest);
314
+ if (fs.existsSync(tasksDir)) {
315
+ for (const entry of fs.readdirSync(tasksDir, { withFileTypes: true })) {
316
+ if (!entry.isDirectory()) continue;
317
+ for (const direction of directions) {
318
+ filesToSearch.push({ filePath: mailboxFile(manifest, direction, entry.name), direction });
319
+ }
320
+ }
321
+ }
322
+
323
+ for (const { filePath, direction } of filesToSearch) {
324
+ if (!fs.existsSync(filePath)) continue;
325
+ const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/).filter(Boolean);
326
+ let found = false;
327
+ const updatedLines: string[] = [];
328
+ for (const line of lines) {
329
+ try {
330
+ const parsed = JSON.parse(line) as unknown;
331
+ const msg = parseMailboxMessage(parsed, direction);
332
+ if (msg && msg.id === originalMessageId) {
333
+ msg.repliedAt = new Date().toISOString();
334
+ msg.replyContent = replyContent;
335
+ updatedLines.push(JSON.stringify(redactSecrets(msg)));
336
+ found = true;
337
+ } else {
338
+ updatedLines.push(line);
339
+ }
340
+ } catch {
341
+ updatedLines.push(line);
342
+ }
343
+ }
344
+ if (found) {
345
+ fs.writeFileSync(filePath, `${updatedLines.join("\n")}\n`, "utf-8");
346
+ return;
347
+ }
348
+ }
349
+ // Not finding the original is non-fatal; the reply is still delivered.
350
+ }
351
+
279
352
  export function replayPendingMailboxMessages(manifest: TeamRunManifest): MailboxReplayResult {
280
353
  const delivery = readDeliveryState(manifest);
281
354
  const pending = readAllInboxMessages(manifest).filter((message) => message.status !== "acknowledged" && delivery.messages[message.id] !== "acknowledged");
@@ -287,15 +360,19 @@ export function replayPendingMailboxMessages(manifest: TeamRunManifest): Mailbox
287
360
  return { messages: pending, updatedAt };
288
361
  }
289
362
 
290
- export function validateMailbox(manifest: TeamRunManifest, options: { repair?: boolean } = {}): MailboxValidationReport {
363
+ export function validateMailbox(manifest: TeamRunManifest, options: { repair?: boolean; signal?: AbortSignal } = {}): MailboxValidationReport {
291
364
  ensureRunMailbox(manifest);
292
365
  const issues: MailboxValidationIssue[] = [];
293
366
  const repaired: string[] = [];
294
367
  for (const direction of ["inbox", "outbox"] as const) {
368
+ if (options.signal?.aborted) break;
295
369
  const filePath = mailboxFile(manifest, direction);
296
370
  const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/).filter(Boolean);
297
371
  const validLines: string[] = [];
298
- for (const line of lines) {
372
+ for (let i = 0; i < lines.length; i += 1) {
373
+ if (options.signal?.aborted) break;
374
+ const line = lines[i];
375
+ if (!line) continue;
299
376
  try {
300
377
  const parsed = JSON.parse(line) as unknown;
301
378
  const message = parseMailboxMessage(parsed, direction);
@@ -313,7 +390,10 @@ export function validateMailbox(manifest: TeamRunManifest, options: { repair?: b
313
390
  }
314
391
  const delivery = readDeliveryState(manifest);
315
392
  const allMessages = readMailbox(manifest);
316
- for (const message of allMessages) if (!delivery.messages[message.id]) issues.push({ level: "warning", path: deliveryFile(manifest), message: `Missing delivery entry for ${message.id}.` });
393
+ for (const message of allMessages) {
394
+ if (options.signal?.aborted) break;
395
+ if (!delivery.messages[message.id]) issues.push({ level: "warning", path: deliveryFile(manifest), message: `Missing delivery entry for ${message.id}.` });
396
+ }
317
397
  if (options.repair) {
318
398
  for (const message of allMessages) delivery.messages[message.id] ??= message.status;
319
399
  delivery.updatedAt = new Date().toISOString();