tmux-ide 1.2.1 → 2.0.0

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 (205) hide show
  1. package/bin/cli.js +4 -216
  2. package/bin/cli.ts +536 -0
  3. package/package.json +33 -14
  4. package/src/__tests__/support.ts +186 -0
  5. package/src/{attach.js → attach.ts} +7 -4
  6. package/src/cli.test.ts +314 -0
  7. package/src/command-center/discovery.test.ts +265 -0
  8. package/src/command-center/discovery.ts +296 -0
  9. package/src/command-center/index.ts +31 -0
  10. package/src/command-center/schemas.ts +57 -0
  11. package/src/command-center/server.test.ts +818 -0
  12. package/src/command-center/server.ts +1426 -0
  13. package/src/config-cli.test.ts +86 -0
  14. package/src/config.test.ts +205 -0
  15. package/src/config.ts +313 -0
  16. package/src/detect.test.ts +142 -0
  17. package/src/{detect.js → detect.ts} +31 -15
  18. package/src/dispatch.ts +102 -0
  19. package/src/doctor.test.ts +119 -0
  20. package/src/doctor.ts +167 -0
  21. package/src/init.test.ts +101 -0
  22. package/src/init.ts +144 -0
  23. package/src/inspect.test.ts +67 -0
  24. package/src/{inspect.js → inspect.ts} +95 -10
  25. package/src/integration.test.ts.skip +276 -0
  26. package/src/js-yaml.d.ts +10 -0
  27. package/src/launch.test.ts +222 -0
  28. package/src/launch.ts +494 -0
  29. package/src/lib/auth/auth-service.test.ts +178 -0
  30. package/src/lib/auth/auth-service.ts +227 -0
  31. package/src/lib/auth/middleware.test.ts +119 -0
  32. package/src/lib/auth/middleware.ts +56 -0
  33. package/src/lib/auth/types.ts +13 -0
  34. package/src/lib/authorship.test.ts +242 -0
  35. package/src/lib/authorship.ts +280 -0
  36. package/src/lib/cast/recorder.test.ts +127 -0
  37. package/src/lib/cast/recorder.ts +148 -0
  38. package/src/lib/daemon-watchdog.ts +102 -0
  39. package/src/lib/daemon.ts +417 -0
  40. package/src/lib/dot-path.test.ts +66 -0
  41. package/src/lib/dot-path.ts +17 -0
  42. package/src/lib/errors.ts +44 -0
  43. package/src/lib/event-log.test.ts +189 -0
  44. package/src/lib/event-log.ts +237 -0
  45. package/src/lib/github-pr.ts +182 -0
  46. package/src/lib/hq/client.test.ts +154 -0
  47. package/src/lib/hq/client.ts +142 -0
  48. package/src/lib/hq/mdns.test.ts +54 -0
  49. package/src/lib/hq/mdns.ts +88 -0
  50. package/src/lib/hq/registry.test.ts +237 -0
  51. package/src/lib/hq/registry.ts +145 -0
  52. package/src/lib/hq/types.ts +44 -0
  53. package/src/lib/ipc/socket-protocol.test.ts +126 -0
  54. package/src/lib/ipc/socket-protocol.ts +324 -0
  55. package/src/lib/launch-plan.test.ts +119 -0
  56. package/src/lib/launch-plan.ts +90 -0
  57. package/src/lib/log.test.ts +39 -0
  58. package/src/lib/log.ts +71 -0
  59. package/src/lib/metrics.test.ts +213 -0
  60. package/src/lib/metrics.ts +345 -0
  61. package/src/lib/orchestrator.test.ts +2358 -0
  62. package/src/lib/orchestrator.ts +1792 -0
  63. package/src/lib/{output.js → output.ts} +18 -10
  64. package/src/lib/plan-store.test.ts +148 -0
  65. package/src/lib/plan-store.ts +131 -0
  66. package/src/lib/research.test.ts +208 -0
  67. package/src/lib/research.ts +475 -0
  68. package/src/lib/round-trip.test.ts +341 -0
  69. package/src/lib/session-monitor.test.ts +152 -0
  70. package/src/lib/session-monitor.ts +282 -0
  71. package/src/lib/session-options.test.ts +146 -0
  72. package/src/lib/{session-options.js → session-options.ts} +11 -6
  73. package/src/lib/shell.test.ts +20 -0
  74. package/src/lib/shell.ts +8 -0
  75. package/src/lib/sizes.test.ts +73 -0
  76. package/src/lib/{sizes.js → sizes.ts} +4 -4
  77. package/src/lib/skill-registry.test.ts +134 -0
  78. package/src/lib/skill-registry.ts +86 -0
  79. package/src/lib/slugify.test.ts +35 -0
  80. package/src/lib/slugify.ts +10 -0
  81. package/src/lib/task-store.ts +352 -0
  82. package/src/lib/tmux.test.ts +500 -0
  83. package/src/lib/tmux.ts +372 -0
  84. package/src/lib/token-tracker.test.ts +102 -0
  85. package/src/lib/token-tracker.ts +70 -0
  86. package/src/lib/tunnels/cloudflare.test.ts +73 -0
  87. package/src/lib/tunnels/cloudflare.ts +254 -0
  88. package/src/lib/tunnels/manager.test.ts +63 -0
  89. package/src/lib/tunnels/manager.ts +132 -0
  90. package/src/lib/tunnels/ngrok.test.ts +94 -0
  91. package/src/lib/tunnels/ngrok.ts +261 -0
  92. package/src/lib/tunnels/tailscale.test.ts +103 -0
  93. package/src/lib/tunnels/tailscale.ts +1048 -0
  94. package/src/lib/tunnels/types.ts +44 -0
  95. package/src/lib/validation.test.ts +289 -0
  96. package/src/lib/validation.ts +123 -0
  97. package/src/lib/webhook.ts +51 -0
  98. package/src/lib/workflow-store.ts +209 -0
  99. package/src/lib/ws-v3/protocol.test.ts +127 -0
  100. package/src/lib/ws-v3/protocol.ts +160 -0
  101. package/src/lib/yaml-io.test.ts +125 -0
  102. package/src/lib/{yaml-io.js → yaml-io.ts} +5 -4
  103. package/src/ls.test.ts +76 -0
  104. package/src/{ls.js → ls.ts} +4 -4
  105. package/src/metrics-cli.ts +177 -0
  106. package/src/notify.ts +85 -0
  107. package/src/orchestrator-status.ts +228 -0
  108. package/src/plan.ts +117 -0
  109. package/src/postinstall.test.ts +79 -0
  110. package/src/remote.test.ts +115 -0
  111. package/src/remote.ts +148 -0
  112. package/src/restart.ts +24 -0
  113. package/src/schemas/domain.ts +422 -0
  114. package/src/schemas/ide-config.ts +148 -0
  115. package/src/schemas/index.ts +53 -0
  116. package/src/send.test.ts +70 -0
  117. package/src/send.ts +181 -0
  118. package/src/skill.test.ts +114 -0
  119. package/src/skill.ts +138 -0
  120. package/src/status.ts +66 -0
  121. package/src/stop.test.ts +176 -0
  122. package/src/stop.ts +47 -0
  123. package/src/task.test.ts +1466 -0
  124. package/src/task.ts +1143 -0
  125. package/src/tunnel.test.ts +80 -0
  126. package/src/tunnel.ts +133 -0
  127. package/src/types.ts +15 -0
  128. package/src/ui/index.ts +32 -0
  129. package/src/ui/terminal/index.ts +9 -0
  130. package/src/ui/types.ts +91 -0
  131. package/src/ui/web/base.css +80 -0
  132. package/src/ui/web/components/Box.tsx +59 -0
  133. package/src/ui/web/components/Input.tsx +32 -0
  134. package/src/ui/web/components/ScrollBox.tsx +60 -0
  135. package/src/ui/web/components/Text.tsx +28 -0
  136. package/src/ui/web/hooks.ts +106 -0
  137. package/src/ui/web/index.ts +27 -0
  138. package/src/ui/web/render.ts +77 -0
  139. package/src/ui/web/utils/color.test.ts +93 -0
  140. package/src/ui/web/utils/color.ts +27 -0
  141. package/src/validate.test.ts +385 -0
  142. package/src/validate.ts +217 -0
  143. package/src/widgets/changes/index.tsx +576 -0
  144. package/src/widgets/config/index.tsx +440 -0
  145. package/src/widgets/costs/index.tsx +216 -0
  146. package/src/widgets/explorer/breadcrumbs.tsx +77 -0
  147. package/src/widgets/explorer/footer.tsx +20 -0
  148. package/src/widgets/explorer/header.tsx +23 -0
  149. package/src/widgets/explorer/index.tsx +408 -0
  150. package/src/widgets/explorer/tree-model.test.ts +214 -0
  151. package/src/widgets/explorer/tree-model.ts +103 -0
  152. package/src/widgets/explorer/tree.tsx +156 -0
  153. package/src/widgets/lib/config-model.ts +116 -0
  154. package/src/widgets/lib/files.test.ts +103 -0
  155. package/src/widgets/lib/files.ts +88 -0
  156. package/src/widgets/lib/git.test.ts +151 -0
  157. package/src/widgets/lib/git.ts +88 -0
  158. package/src/widgets/lib/pane-comms.test.ts +178 -0
  159. package/src/widgets/lib/pane-comms.ts +192 -0
  160. package/src/widgets/lib/theme.ts +151 -0
  161. package/src/widgets/lib/watcher.ts +50 -0
  162. package/src/widgets/mission-control/activity-feed.tsx +59 -0
  163. package/src/widgets/mission-control/agent-panel.tsx +132 -0
  164. package/src/widgets/mission-control/command-bar.tsx +63 -0
  165. package/src/widgets/mission-control/index.tsx +751 -0
  166. package/src/widgets/mission-control/task-panel.tsx +238 -0
  167. package/src/widgets/preview/index.tsx +415 -0
  168. package/src/widgets/resolve.ts +40 -0
  169. package/src/widgets/setup/agent-naming.tsx +112 -0
  170. package/src/widgets/setup/config-tree.tsx +238 -0
  171. package/src/widgets/setup/detect-panel.tsx +72 -0
  172. package/src/widgets/setup/field-editor.tsx +265 -0
  173. package/src/widgets/setup/footer.tsx +107 -0
  174. package/src/widgets/setup/index.tsx +340 -0
  175. package/src/widgets/setup/layout-picker.tsx +96 -0
  176. package/src/widgets/setup/orchestrator-panel.tsx +200 -0
  177. package/src/widgets/setup/review-panel.tsx +140 -0
  178. package/src/widgets/setup/setup-model.test.ts +303 -0
  179. package/src/widgets/setup/setup-model.ts +188 -0
  180. package/src/widgets/tasks/index.tsx +129 -0
  181. package/src/widgets/tasks/task-detail.tsx +309 -0
  182. package/src/widgets/tasks/task-form.tsx +193 -0
  183. package/src/widgets/tasks/task-list.tsx +205 -0
  184. package/src/widgets/tasks/task-model.test.ts +473 -0
  185. package/src/widgets/tasks/task-model.ts +157 -0
  186. package/templates/AGENTS.md +20 -0
  187. package/templates/missions.yml +41 -0
  188. package/templates/skills/backend.md +30 -0
  189. package/templates/skills/frontend.md +30 -0
  190. package/templates/skills/general-worker.md +30 -0
  191. package/templates/skills/researcher.md +69 -0
  192. package/templates/skills/reviewer.md +30 -0
  193. package/src/config.js +0 -341
  194. package/src/doctor.js +0 -91
  195. package/src/init.js +0 -73
  196. package/src/launch.js +0 -245
  197. package/src/lib/dot-path.js +0 -16
  198. package/src/lib/errors.js +0 -35
  199. package/src/lib/launch-plan.js +0 -49
  200. package/src/lib/session-monitor.js +0 -179
  201. package/src/lib/tmux.js +0 -237
  202. package/src/restart.js +0 -16
  203. package/src/status.js +0 -35
  204. package/src/stop.js +0 -25
  205. package/src/validate.js +0 -154
@@ -0,0 +1,189 @@
1
+ import { describe, it, beforeEach, afterEach, expect } from "bun:test";
2
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { appendEvent, readEvents, type OrchestratorEvent } from "./event-log.ts";
6
+
7
+ let tmpDir: string;
8
+
9
+ beforeEach(() => {
10
+ tmpDir = mkdtempSync(join(tmpdir(), "tmux-ide-evlog-test-"));
11
+ });
12
+
13
+ afterEach(() => {
14
+ rmSync(tmpDir, { recursive: true, force: true });
15
+ });
16
+
17
+ describe("appendEvent", () => {
18
+ it("creates .tasks/ and events.log if they don't exist", () => {
19
+ appendEvent(tmpDir, {
20
+ timestamp: "2026-01-01T00:00:00Z",
21
+ type: "dispatch",
22
+ taskId: "001",
23
+ agent: "Agent 1",
24
+ message: "Dispatched task 001 to Agent 1",
25
+ });
26
+
27
+ expect(existsSync(join(tmpDir, ".tasks", "events.log"))).toBeTruthy();
28
+ });
29
+
30
+ it("appends events as JSON lines", () => {
31
+ appendEvent(tmpDir, {
32
+ timestamp: "2026-01-01T00:00:00Z",
33
+ type: "dispatch",
34
+ taskId: "001",
35
+ message: "first",
36
+ });
37
+ appendEvent(tmpDir, {
38
+ timestamp: "2026-01-01T00:01:00Z",
39
+ type: "completion",
40
+ taskId: "001",
41
+ message: "second",
42
+ });
43
+
44
+ const events = readEvents(tmpDir);
45
+ expect(events.length).toBe(2);
46
+ expect(events[0]!.type).toBe("dispatch");
47
+ expect(events[1]!.type).toBe("completion");
48
+ });
49
+
50
+ it("works without optional fields", () => {
51
+ appendEvent(tmpDir, {
52
+ timestamp: "2026-01-01T00:00:00Z",
53
+ type: "error",
54
+ message: "something broke",
55
+ });
56
+
57
+ const events = readEvents(tmpDir);
58
+ expect(events.length).toBe(1);
59
+ expect(events[0]!.taskId).toBe(undefined);
60
+ expect(events[0]!.agent).toBe(undefined);
61
+ });
62
+ });
63
+
64
+ describe("readEvents", () => {
65
+ it("returns empty array when log doesn't exist", () => {
66
+ expect(readEvents(tmpDir)).toEqual([]);
67
+ });
68
+
69
+ it("returns all event types", () => {
70
+ const types: Array<OrchestratorEvent["type"]> = [
71
+ "dispatch",
72
+ "stall",
73
+ "completion",
74
+ "retry",
75
+ "reconcile",
76
+ "error",
77
+ ];
78
+ for (const type of types) {
79
+ appendEvent(tmpDir, { timestamp: "2026-01-01T00:00:00Z", type, message: type });
80
+ }
81
+
82
+ const events = readEvents(tmpDir);
83
+ expect(events.length).toBe(6);
84
+ expect(events.map((e) => e.type)).toEqual(types);
85
+ });
86
+
87
+ it("skips corrupted lines and returns valid events", () => {
88
+ const tasksDir = join(tmpDir, ".tasks");
89
+ mkdirSync(tasksDir, { recursive: true });
90
+ const logPath = join(tasksDir, "events.log");
91
+ const validEvent = JSON.stringify({
92
+ timestamp: "2026-01-01T00:00:00Z",
93
+ type: "dispatch",
94
+ message: "valid event",
95
+ });
96
+ // Write: valid line, corrupted line, another valid line
97
+ writeFileSync(logPath, `${validEvent}\nnot json at all\n${validEvent}\n`);
98
+
99
+ const events = readEvents(tmpDir);
100
+ expect(events.length).toBe(2);
101
+ expect(events[0]!.message).toBe("valid event");
102
+ expect(events[1]!.message).toBe("valid event");
103
+ });
104
+
105
+ it("reads events from both current and rotated log files", () => {
106
+ const tasksDir = join(tmpDir, ".tasks");
107
+ mkdirSync(tasksDir, { recursive: true });
108
+
109
+ const oldEvent = JSON.stringify({
110
+ timestamp: "2026-01-01T00:00:00Z",
111
+ type: "dispatch",
112
+ message: "old event",
113
+ });
114
+ const newEvent = JSON.stringify({
115
+ timestamp: "2026-01-02T00:00:00Z",
116
+ type: "completion",
117
+ message: "new event",
118
+ });
119
+
120
+ writeFileSync(join(tasksDir, "events.log.1"), oldEvent + "\n");
121
+ writeFileSync(join(tasksDir, "events.log"), newEvent + "\n");
122
+
123
+ const events = readEvents(tmpDir);
124
+ expect(events.length).toBe(2);
125
+ expect(events[0]!.message).toBe("old event");
126
+ expect(events[1]!.message).toBe("new event");
127
+ });
128
+ });
129
+
130
+ describe("log rotation", () => {
131
+ it("rotates events.log when it exceeds 1MB", () => {
132
+ const tasksDir = join(tmpDir, ".tasks");
133
+ mkdirSync(tasksDir, { recursive: true });
134
+ const logPath = join(tasksDir, "events.log");
135
+
136
+ // Write a file just over 1MB directly
137
+ const event = JSON.stringify({
138
+ timestamp: "2026-01-01T00:00:00Z",
139
+ type: "dispatch",
140
+ taskId: "001",
141
+ message: "x".repeat(200),
142
+ });
143
+ const lineSize = Buffer.byteLength(event + "\n");
144
+ const linesNeeded = Math.ceil(1048577 / lineSize);
145
+ writeFileSync(logPath, (event + "\n").repeat(linesNeeded));
146
+
147
+ expect(statSync(logPath).size > 1048576).toBeTruthy();
148
+
149
+ // Next appendEvent should trigger rotation
150
+ appendEvent(tmpDir, {
151
+ timestamp: "2026-01-02T00:00:00Z",
152
+ type: "completion",
153
+ taskId: "002",
154
+ message: "trigger rotation",
155
+ });
156
+
157
+ // events.log should now be small (just the new event)
158
+ expect(statSync(logPath).size < 1024).toBeTruthy();
159
+
160
+ // Rotated file should exist with the old content
161
+ const rotatedPath = join(tasksDir, "events.log.1");
162
+ expect(existsSync(rotatedPath)).toBeTruthy();
163
+ expect(statSync(rotatedPath).size > 1048576).toBeTruthy();
164
+
165
+ // readEvents should return events from both files
166
+ const events = readEvents(tmpDir);
167
+ expect(events.length > linesNeeded).toBeTruthy();
168
+ expect(events[events.length - 1]!.message).toBe("trigger rotation");
169
+ });
170
+
171
+ it("does not rotate when file is under 1MB", () => {
172
+ appendEvent(tmpDir, {
173
+ timestamp: "2026-01-01T00:00:00Z",
174
+ type: "dispatch",
175
+ message: "small event",
176
+ });
177
+ appendEvent(tmpDir, {
178
+ timestamp: "2026-01-01T00:01:00Z",
179
+ type: "completion",
180
+ message: "another small event",
181
+ });
182
+
183
+ const rotatedPath = join(tmpDir, ".tasks", "events.log.1");
184
+ expect(!existsSync(rotatedPath)).toBeTruthy();
185
+
186
+ const events = readEvents(tmpDir);
187
+ expect(events.length).toBe(2);
188
+ });
189
+ });
@@ -0,0 +1,237 @@
1
+ import {
2
+ appendFileSync,
3
+ copyFileSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ renameSync,
8
+ statSync,
9
+ unlinkSync,
10
+ } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { StructuredEventSchemaZ } from "../schemas/domain.ts";
13
+ import type { z } from "zod";
14
+ import { fireWebhooks, type WebhookConfig } from "./webhook.ts";
15
+
16
+ export type EventType =
17
+ | "dispatch"
18
+ | "stall"
19
+ | "completion"
20
+ | "retry"
21
+ | "reconcile"
22
+ | "error"
23
+ | "task_created"
24
+ | "status_change"
25
+ | "send"
26
+ | "notify"
27
+ | "milestone_validating"
28
+ | "milestone_complete"
29
+ | "validation_dispatch"
30
+ | "remediation"
31
+ | "validation_failed"
32
+ | "planning"
33
+ | "mission_complete"
34
+ | "discovered_issue"
35
+ | "research_dispatch"
36
+ | "research_finding"
37
+ | "agent_heartbeat"
38
+ | "session_start"
39
+ | "session_end";
40
+
41
+ export interface OrchestratorEvent {
42
+ timestamp: string;
43
+ type: EventType;
44
+ taskId?: string;
45
+ agent?: string;
46
+ message: string;
47
+ }
48
+
49
+ export type StructuredEvent = z.infer<typeof StructuredEventSchemaZ>;
50
+
51
+ let _webhooks: WebhookConfig[] = [];
52
+
53
+ /** Configure webhook URLs for event delivery. Call once at startup. */
54
+ export function setWebhookConfig(webhooks: WebhookConfig[]): void {
55
+ _webhooks = webhooks;
56
+ }
57
+
58
+ /**
59
+ * Generate a human-readable message from a structured event's typed fields.
60
+ */
61
+ export function formatEventMessage(event: StructuredEvent): string {
62
+ switch (event.type) {
63
+ case "dispatch":
64
+ return `Dispatched task ${event.taskId} to ${event.agent}`;
65
+ case "completion": {
66
+ const duration =
67
+ event.durationMs != null ? ` in ${Math.round(event.durationMs / 1000)}s` : "";
68
+ return `Task ${event.taskId} completed by ${event.agent}${duration}`;
69
+ }
70
+ case "stall":
71
+ return `Stall detected: task ${event.taskId} by ${event.agent} (${Math.floor(event.elapsedMs / 60000)}m)`;
72
+ case "retry":
73
+ return `Retry ${event.attempt}/${event.maxRetries} for task ${event.taskId}: ${event.reason}`;
74
+ case "reconcile":
75
+ return `Reconcile task ${event.taskId}: agent "${event.previousAgent}" ${event.action}`;
76
+ case "error": {
77
+ const ctx = [event.taskId && `task ${event.taskId}`, event.agent && `agent ${event.agent}`]
78
+ .filter(Boolean)
79
+ .join(", ");
80
+ const code = event.code ? ` [${event.code}]` : "";
81
+ return ctx ? `Error (${ctx}): ${event.message}${code}` : `Error: ${event.message}${code}`;
82
+ }
83
+ case "task_created":
84
+ return `Task ${event.taskId} created: "${event.title}"`;
85
+ case "status_change":
86
+ return `Task ${event.taskId} status: ${event.from} → ${event.to}`;
87
+ case "send": {
88
+ const preview =
89
+ event.message.length > 50 ? event.message.slice(0, 50) + "..." : event.message;
90
+ return `Sent to ${event.target}: "${preview}"`;
91
+ }
92
+ case "notify": {
93
+ const preview =
94
+ event.message.length > 50 ? event.message.slice(0, 50) + "..." : event.message;
95
+ return `Notified ${event.target}: "${preview}"`;
96
+ }
97
+ case "milestone_validating":
98
+ return event.title && event.milestoneId
99
+ ? `Milestone "${event.title}" (${event.milestoneId}) entered validation`
100
+ : "Milestone entered validation";
101
+ case "milestone_complete":
102
+ return event.title && event.milestoneId
103
+ ? `Milestone "${event.title}" (${event.milestoneId}) completed`
104
+ : "Milestone completed";
105
+ case "validation_dispatch":
106
+ return event.title && event.target
107
+ ? `Dispatched validation for "${event.title}" to ${event.target}`
108
+ : "Dispatched milestone validation";
109
+ case "remediation":
110
+ return event.assertionId
111
+ ? `Created remediation task ${event.taskId} for assertion ${event.assertionId}`
112
+ : `Created remediation task ${event.taskId}`;
113
+ case "validation_failed":
114
+ return event.title && event.failedCount != null
115
+ ? `Milestone "${event.title}" validation failed (${event.failedCount} assertion(s))`
116
+ : "Milestone validation failed";
117
+ case "planning":
118
+ return event.target
119
+ ? `Dispatched mission planning to ${event.target}`
120
+ : "Dispatched mission planning";
121
+ case "mission_complete":
122
+ return event.title ? `Mission "${event.title}" completed` : "Mission completed";
123
+ case "discovered_issue":
124
+ return event.issue
125
+ ? `Created follow-up task ${event.taskId}: ${event.issue}`
126
+ : `Created follow-up task ${event.taskId} for discovered issue`;
127
+ case "research_dispatch":
128
+ return event.target && event.researchType
129
+ ? `Dispatched ${event.researchType} research to ${event.target}`
130
+ : `Dispatched research task ${event.taskId}`;
131
+ case "research_finding":
132
+ return event.summary && event.researchType
133
+ ? `Research finding (${event.researchType}): ${event.summary}`
134
+ : `Recorded research finding from task ${event.taskId}`;
135
+ default:
136
+ return (
137
+ (event as { message?: string }).message ?? `Event: ${(event as { type: string }).type}`
138
+ );
139
+ }
140
+ }
141
+
142
+ const MAX_LOG_SIZE = 1048576; // 1MB
143
+ let _rotating = false;
144
+
145
+ export function appendEvent(dir: string, event: OrchestratorEvent | StructuredEvent): void {
146
+ const tasksDir = join(dir, ".tasks");
147
+ if (!existsSync(tasksDir)) {
148
+ mkdirSync(tasksDir, { recursive: true });
149
+ }
150
+ const logPath = join(tasksDir, "events.log");
151
+
152
+ // Rotate if file exceeds 1MB (atomic: copy to temp, rename temp to .1, remove old)
153
+ // Guard with _rotating flag to prevent concurrent rotation from interleaved calls
154
+ if (!_rotating && existsSync(logPath)) {
155
+ try {
156
+ const size = statSync(logPath).size;
157
+ if (size > MAX_LOG_SIZE) {
158
+ _rotating = true;
159
+ const rotatedPath = logPath + ".1";
160
+ const tmpRotated = rotatedPath + ".tmp";
161
+ copyFileSync(logPath, tmpRotated);
162
+ renameSync(tmpRotated, rotatedPath);
163
+ unlinkSync(logPath);
164
+ }
165
+ } catch {
166
+ // stat/copy/rename failure — continue writing to current file
167
+ } finally {
168
+ _rotating = false;
169
+ }
170
+ }
171
+
172
+ const line = JSON.stringify(event) + "\n";
173
+
174
+ // If it's a structured event (no message field, or error type with typed payload),
175
+ // validate with schema before writing
176
+ if (!("message" in event) || StructuredEventSchemaZ.safeParse(event).success) {
177
+ appendFileSync(logPath, line);
178
+ if (_webhooks.length > 0) fireWebhooks(_webhooks, event);
179
+ return;
180
+ }
181
+
182
+ // Old-format event with free-form message — write as-is
183
+ appendFileSync(logPath, line);
184
+ if (_webhooks.length > 0) fireWebhooks(_webhooks, event);
185
+ }
186
+
187
+ export function readEvents(dir: string): OrchestratorEvent[] {
188
+ const logPath = join(dir, ".tasks", "events.log");
189
+ const rotatedPath = logPath + ".1";
190
+
191
+ // Collect lines from rotated file first (older events), then current
192
+ let lines: string[] = [];
193
+ if (existsSync(rotatedPath)) {
194
+ const raw = readFileSync(rotatedPath, "utf-8").trim();
195
+ if (raw) lines = raw.split("\n");
196
+ }
197
+ if (existsSync(logPath)) {
198
+ const raw = readFileSync(logPath, "utf-8").trim();
199
+ if (raw) lines = lines.concat(raw.split("\n"));
200
+ }
201
+ if (lines.length === 0) return [];
202
+
203
+ return lines
204
+ .map((line) => {
205
+ try {
206
+ const parsed = JSON.parse(line);
207
+
208
+ // Old format: has a message field → return as-is
209
+ if ("message" in parsed && typeof parsed.message === "string") {
210
+ return parsed as OrchestratorEvent;
211
+ }
212
+
213
+ // New structured format: try to parse and generate message
214
+ const result = StructuredEventSchemaZ.safeParse(parsed);
215
+ if (result.success) {
216
+ const structured = result.data;
217
+ return {
218
+ timestamp: structured.timestamp,
219
+ type: structured.type,
220
+ taskId: "taskId" in structured ? structured.taskId : undefined,
221
+ agent: "agent" in structured ? structured.agent : undefined,
222
+ message: formatEventMessage(structured),
223
+ } as OrchestratorEvent;
224
+ }
225
+
226
+ // Fallback: treat as old format if it has at minimum type and timestamp
227
+ if (parsed.type && parsed.timestamp) {
228
+ return { ...parsed, message: parsed.message ?? "" } as OrchestratorEvent;
229
+ }
230
+
231
+ return null;
232
+ } catch {
233
+ return null;
234
+ }
235
+ })
236
+ .filter((e): e is OrchestratorEvent => e !== null);
237
+ }
@@ -0,0 +1,182 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import type { Task, Mission } from "./task-store.ts";
3
+ import type { ProofSchema } from "../types.ts";
4
+
5
+ export interface PrResult {
6
+ url: string;
7
+ number: number;
8
+ }
9
+
10
+ /**
11
+ * Check if the gh CLI is available.
12
+ */
13
+ export function isGhAvailable(): boolean {
14
+ try {
15
+ execFileSync("gh", ["--version"], {
16
+ encoding: "utf-8",
17
+ stdio: ["ignore", "pipe", "ignore"],
18
+ });
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Create a GitHub PR for a completed task.
27
+ * Returns the PR URL and number, or null if creation failed.
28
+ */
29
+ export function createTaskPr(task: Task, cwd: string, baseBranch?: string): PrResult | null {
30
+ // Determine the current branch
31
+ let headBranch: string;
32
+ try {
33
+ headBranch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
34
+ cwd,
35
+ encoding: "utf-8",
36
+ stdio: ["ignore", "pipe", "ignore"],
37
+ }).trim();
38
+ } catch {
39
+ return null;
40
+ }
41
+
42
+ // Build PR body
43
+ const bodyParts: string[] = [];
44
+ if (task.description) bodyParts.push(task.description);
45
+ if (task.proof) {
46
+ bodyParts.push("");
47
+ bodyParts.push("## Proof");
48
+ const proof = task.proof as ProofSchema;
49
+ if (proof.notes) bodyParts.push(proof.notes);
50
+ if (proof.tests) bodyParts.push(`Tests: ${proof.tests.passed}/${proof.tests.total} passed`);
51
+ if (proof.ci)
52
+ bodyParts.push(`CI: ${proof.ci.status}${proof.ci.url ? ` (${proof.ci.url})` : ""}`);
53
+ }
54
+ if (task.tags.length > 0) {
55
+ bodyParts.push("");
56
+ bodyParts.push(`Tags: ${task.tags.join(", ")}`);
57
+ }
58
+
59
+ const title = `Task ${task.id}: ${task.title}`;
60
+ const body = bodyParts.join("\n") || task.title;
61
+
62
+ try {
63
+ // Push the branch first (gh pr create needs it on remote)
64
+ try {
65
+ execFileSync("git", ["push", "-u", "origin", headBranch], {
66
+ cwd,
67
+ encoding: "utf-8",
68
+ stdio: ["ignore", "pipe", "pipe"],
69
+ });
70
+ } catch {
71
+ // Branch may already be pushed, or remote may not exist — continue anyway
72
+ }
73
+
74
+ // Create the PR
75
+ const output = execFileSync(
76
+ "gh",
77
+ [
78
+ "pr",
79
+ "create",
80
+ "--title",
81
+ title,
82
+ "--body",
83
+ body,
84
+ "--head",
85
+ headBranch,
86
+ ...(baseBranch ? ["--base", baseBranch] : []),
87
+ ],
88
+ {
89
+ cwd,
90
+ encoding: "utf-8",
91
+ stdio: ["ignore", "pipe", "pipe"],
92
+ },
93
+ ).trim();
94
+
95
+ // gh pr create outputs the PR URL
96
+ const url = output;
97
+ // Extract PR number from URL (e.g. https://github.com/owner/repo/pull/42)
98
+ const match = url.match(/\/pull\/(\d+)/);
99
+ const number = match ? parseInt(match[1]!, 10) : 0;
100
+
101
+ return { url, number };
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Create a GitHub PR for a completed mission.
109
+ * Uses mission.branch as head; if not set, uses current branch.
110
+ */
111
+ export function createMissionPr(
112
+ mission: Mission,
113
+ cwd: string,
114
+ baseBranch?: string,
115
+ ): PrResult | null {
116
+ let headBranch: string;
117
+ if (mission.branch) {
118
+ headBranch = mission.branch;
119
+ } else {
120
+ try {
121
+ headBranch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
122
+ cwd,
123
+ encoding: "utf-8",
124
+ stdio: ["ignore", "pipe", "ignore"],
125
+ }).trim();
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ const milestonesSummary = mission.milestones
132
+ .sort((a, b) => a.order - b.order)
133
+ .map((m) => `- ${m.id}: ${m.title} [${m.status}]`)
134
+ .join("\n");
135
+
136
+ const title = `Mission: ${mission.title}`;
137
+ const bodyParts: string[] = [];
138
+ if (mission.description) bodyParts.push(mission.description);
139
+ bodyParts.push("");
140
+ bodyParts.push("## Milestones");
141
+ bodyParts.push(milestonesSummary || "No milestones");
142
+ const body = bodyParts.join("\n");
143
+
144
+ try {
145
+ try {
146
+ execFileSync("git", ["push", "-u", "origin", headBranch], {
147
+ cwd,
148
+ encoding: "utf-8",
149
+ stdio: ["ignore", "pipe", "pipe"],
150
+ });
151
+ } catch {
152
+ // Branch may already be pushed
153
+ }
154
+
155
+ const output = execFileSync(
156
+ "gh",
157
+ [
158
+ "pr",
159
+ "create",
160
+ "--title",
161
+ title,
162
+ "--body",
163
+ body,
164
+ "--head",
165
+ headBranch,
166
+ ...(baseBranch ? ["--base", baseBranch] : []),
167
+ ],
168
+ {
169
+ cwd,
170
+ encoding: "utf-8",
171
+ stdio: ["ignore", "pipe", "pipe"],
172
+ },
173
+ ).trim();
174
+
175
+ const url = output;
176
+ const match = url.match(/\/pull\/(\d+)/);
177
+ const number = match ? parseInt(match[1]!, 10) : 0;
178
+ return { url, number };
179
+ } catch {
180
+ return null;
181
+ }
182
+ }