macro-agent 0.1.8 → 0.1.10

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 (258) hide show
  1. package/CLAUDE.md +166 -33
  2. package/README.md +781 -131
  3. package/dist/acp/claude-code-replay.d.ts +11 -0
  4. package/dist/acp/claude-code-replay.d.ts.map +1 -0
  5. package/dist/acp/claude-code-replay.js +190 -0
  6. package/dist/acp/claude-code-replay.js.map +1 -0
  7. package/dist/acp/macro-agent.d.ts.map +1 -1
  8. package/dist/acp/macro-agent.js +155 -6
  9. package/dist/acp/macro-agent.js.map +1 -1
  10. package/dist/acp/types.d.ts +9 -0
  11. package/dist/acp/types.d.ts.map +1 -1
  12. package/dist/acp/types.js.map +1 -1
  13. package/dist/agent/agent-manager-v2.d.ts +21 -0
  14. package/dist/agent/agent-manager-v2.d.ts.map +1 -1
  15. package/dist/agent/agent-manager-v2.js +234 -43
  16. package/dist/agent/agent-manager-v2.js.map +1 -1
  17. package/dist/agent/agent-manager.d.ts +12 -0
  18. package/dist/agent/agent-manager.d.ts.map +1 -1
  19. package/dist/agent/agent-manager.js.map +1 -1
  20. package/dist/agent/types.d.ts +15 -2
  21. package/dist/agent/types.d.ts.map +1 -1
  22. package/dist/agent/types.js.map +1 -1
  23. package/dist/boot-v2.d.ts +41 -0
  24. package/dist/boot-v2.d.ts.map +1 -1
  25. package/dist/boot-v2.js +16 -1
  26. package/dist/boot-v2.js.map +1 -1
  27. package/dist/cli/index.js +56 -0
  28. package/dist/cli/index.js.map +1 -1
  29. package/dist/cognitive/macro-agent-backend.d.ts.map +1 -1
  30. package/dist/cognitive/macro-agent-backend.js +40 -22
  31. package/dist/cognitive/macro-agent-backend.js.map +1 -1
  32. package/dist/integrations/skilltree.d.ts.map +1 -1
  33. package/dist/integrations/skilltree.js +1 -0
  34. package/dist/integrations/skilltree.js.map +1 -1
  35. package/dist/lifecycle/cleanup.d.ts +33 -2
  36. package/dist/lifecycle/cleanup.d.ts.map +1 -1
  37. package/dist/lifecycle/cleanup.js +28 -6
  38. package/dist/lifecycle/cleanup.js.map +1 -1
  39. package/dist/lifecycle/handlers-v2.d.ts +7 -0
  40. package/dist/lifecycle/handlers-v2.d.ts.map +1 -1
  41. package/dist/lifecycle/handlers-v2.js +28 -2
  42. package/dist/lifecycle/handlers-v2.js.map +1 -1
  43. package/dist/lifecycle/types.d.ts +11 -0
  44. package/dist/lifecycle/types.d.ts.map +1 -1
  45. package/dist/lifecycle/types.js.map +1 -1
  46. package/dist/map/acp-bridge.d.ts +9 -0
  47. package/dist/map/acp-bridge.d.ts.map +1 -1
  48. package/dist/map/acp-bridge.js +15 -2
  49. package/dist/map/acp-bridge.js.map +1 -1
  50. package/dist/map/cascade-bridge.d.ts +44 -0
  51. package/dist/map/cascade-bridge.d.ts.map +1 -0
  52. package/dist/map/cascade-bridge.js +257 -0
  53. package/dist/map/cascade-bridge.js.map +1 -0
  54. package/dist/map/lifecycle-bridge.d.ts +1 -1
  55. package/dist/map/lifecycle-bridge.d.ts.map +1 -1
  56. package/dist/map/lifecycle-bridge.js +58 -23
  57. package/dist/map/lifecycle-bridge.js.map +1 -1
  58. package/dist/map/server.d.ts.map +1 -1
  59. package/dist/map/server.js +47 -6
  60. package/dist/map/server.js.map +1 -1
  61. package/dist/map/sidecar.d.ts.map +1 -1
  62. package/dist/map/sidecar.js +33 -2
  63. package/dist/map/sidecar.js.map +1 -1
  64. package/dist/map/types.d.ts +20 -0
  65. package/dist/map/types.d.ts.map +1 -1
  66. package/dist/mcp/tools/done-v2.d.ts.map +1 -1
  67. package/dist/mcp/tools/done-v2.js +8 -0
  68. package/dist/mcp/tools/done-v2.js.map +1 -1
  69. package/dist/teams/team-manager-v2.d.ts.map +1 -1
  70. package/dist/teams/team-manager-v2.js +26 -0
  71. package/dist/teams/team-manager-v2.js.map +1 -1
  72. package/dist/teams/team-runtime-v2.d.ts.map +1 -1
  73. package/dist/teams/team-runtime-v2.js +16 -3
  74. package/dist/teams/team-runtime-v2.js.map +1 -1
  75. package/dist/workspace/config.d.ts +10 -10
  76. package/dist/workspace/config.d.ts.map +1 -1
  77. package/dist/workspace/config.js +4 -4
  78. package/dist/workspace/config.js.map +1 -1
  79. package/dist/workspace/git-cascade-adapter.d.ts +510 -0
  80. package/dist/workspace/git-cascade-adapter.d.ts.map +1 -0
  81. package/dist/workspace/git-cascade-adapter.js +908 -0
  82. package/dist/workspace/git-cascade-adapter.js.map +1 -0
  83. package/dist/workspace/index.d.ts +3 -3
  84. package/dist/workspace/index.d.ts.map +1 -1
  85. package/dist/workspace/index.js +4 -4
  86. package/dist/workspace/index.js.map +1 -1
  87. package/dist/workspace/landing/direct-push.d.ts +20 -0
  88. package/dist/workspace/landing/direct-push.d.ts.map +1 -0
  89. package/dist/workspace/landing/direct-push.js +74 -0
  90. package/dist/workspace/landing/direct-push.js.map +1 -0
  91. package/dist/workspace/landing/index.d.ts +29 -0
  92. package/dist/workspace/landing/index.d.ts.map +1 -0
  93. package/dist/workspace/landing/index.js +37 -0
  94. package/dist/workspace/landing/index.js.map +1 -0
  95. package/dist/workspace/landing/merge-to-parent.d.ts +41 -0
  96. package/dist/workspace/landing/merge-to-parent.d.ts.map +1 -0
  97. package/dist/workspace/landing/merge-to-parent.js +185 -0
  98. package/dist/workspace/landing/merge-to-parent.js.map +1 -0
  99. package/dist/workspace/landing/optimistic-push.d.ts +16 -0
  100. package/dist/workspace/landing/optimistic-push.d.ts.map +1 -0
  101. package/dist/workspace/landing/optimistic-push.js +27 -0
  102. package/dist/workspace/landing/optimistic-push.js.map +1 -0
  103. package/dist/workspace/landing/queue-to-branch.d.ts +24 -0
  104. package/dist/workspace/landing/queue-to-branch.d.ts.map +1 -0
  105. package/dist/workspace/landing/queue-to-branch.js +79 -0
  106. package/dist/workspace/landing/queue-to-branch.js.map +1 -0
  107. package/dist/workspace/merge-queue/merge-queue.d.ts +10 -0
  108. package/dist/workspace/merge-queue/merge-queue.d.ts.map +1 -1
  109. package/dist/workspace/merge-queue/merge-queue.js +10 -0
  110. package/dist/workspace/merge-queue/merge-queue.js.map +1 -1
  111. package/dist/workspace/merge-queue/types.d.ts +16 -2
  112. package/dist/workspace/merge-queue/types.d.ts.map +1 -1
  113. package/dist/workspace/merge-queue/types.js +9 -0
  114. package/dist/workspace/merge-queue/types.js.map +1 -1
  115. package/dist/workspace/pool/types.d.ts +1 -0
  116. package/dist/workspace/pool/types.d.ts.map +1 -1
  117. package/dist/workspace/pool/worktree-pool.d.ts.map +1 -1
  118. package/dist/workspace/pool/worktree-pool.js +1 -0
  119. package/dist/workspace/pool/worktree-pool.js.map +1 -1
  120. package/dist/workspace/recovery/abandon.d.ts +15 -0
  121. package/dist/workspace/recovery/abandon.d.ts.map +1 -0
  122. package/dist/workspace/recovery/abandon.js +45 -0
  123. package/dist/workspace/recovery/abandon.js.map +1 -0
  124. package/dist/workspace/recovery/auto-resolve.d.ts +27 -0
  125. package/dist/workspace/recovery/auto-resolve.d.ts.map +1 -0
  126. package/dist/workspace/recovery/auto-resolve.js +99 -0
  127. package/dist/workspace/recovery/auto-resolve.js.map +1 -0
  128. package/dist/workspace/recovery/defer.d.ts +15 -0
  129. package/dist/workspace/recovery/defer.d.ts.map +1 -0
  130. package/dist/workspace/recovery/defer.js +16 -0
  131. package/dist/workspace/recovery/defer.js.map +1 -0
  132. package/dist/workspace/recovery/escalate.d.ts +16 -0
  133. package/dist/workspace/recovery/escalate.d.ts.map +1 -0
  134. package/dist/workspace/recovery/escalate.js +24 -0
  135. package/dist/workspace/recovery/escalate.js.map +1 -0
  136. package/dist/workspace/recovery/index.d.ts +32 -0
  137. package/dist/workspace/recovery/index.d.ts.map +1 -0
  138. package/dist/workspace/recovery/index.js +45 -0
  139. package/dist/workspace/recovery/index.js.map +1 -0
  140. package/dist/workspace/recovery/spawn-resolver.d.ts +45 -0
  141. package/dist/workspace/recovery/spawn-resolver.d.ts.map +1 -0
  142. package/dist/workspace/recovery/spawn-resolver.js +111 -0
  143. package/dist/workspace/recovery/spawn-resolver.js.map +1 -0
  144. package/dist/workspace/recovery/types.d.ts +63 -0
  145. package/dist/workspace/recovery/types.d.ts.map +1 -0
  146. package/dist/workspace/recovery/types.js +12 -0
  147. package/dist/workspace/recovery/types.js.map +1 -0
  148. package/dist/workspace/topology/index.d.ts +9 -0
  149. package/dist/workspace/topology/index.d.ts.map +1 -0
  150. package/dist/workspace/topology/index.js +8 -0
  151. package/dist/workspace/topology/index.js.map +1 -0
  152. package/dist/workspace/topology/no-workspace.d.ts +18 -0
  153. package/dist/workspace/topology/no-workspace.d.ts.map +1 -0
  154. package/dist/workspace/topology/no-workspace.js +25 -0
  155. package/dist/workspace/topology/no-workspace.js.map +1 -0
  156. package/dist/workspace/topology/types.d.ts +97 -0
  157. package/dist/workspace/topology/types.d.ts.map +1 -0
  158. package/dist/workspace/topology/types.js +20 -0
  159. package/dist/workspace/topology/types.js.map +1 -0
  160. package/dist/workspace/topology/yaml-driven.d.ts +69 -0
  161. package/dist/workspace/topology/yaml-driven.d.ts.map +1 -0
  162. package/dist/workspace/topology/yaml-driven.js +273 -0
  163. package/dist/workspace/topology/yaml-driven.js.map +1 -0
  164. package/dist/workspace/types-v3.d.ts +110 -0
  165. package/dist/workspace/types-v3.d.ts.map +1 -0
  166. package/dist/workspace/types-v3.js +20 -0
  167. package/dist/workspace/types-v3.js.map +1 -0
  168. package/dist/workspace/types.d.ts +145 -17
  169. package/dist/workspace/types.d.ts.map +1 -1
  170. package/dist/workspace/workspace-manager.d.ts +92 -13
  171. package/dist/workspace/workspace-manager.d.ts.map +1 -1
  172. package/dist/workspace/workspace-manager.js +373 -13
  173. package/dist/workspace/workspace-manager.js.map +1 -1
  174. package/dist/workspace/yaml-schema.d.ts +254 -0
  175. package/dist/workspace/yaml-schema.d.ts.map +1 -0
  176. package/dist/workspace/yaml-schema.js +170 -0
  177. package/dist/workspace/yaml-schema.js.map +1 -0
  178. package/docs/conflict-recovery.md +472 -0
  179. package/docs/git-cascade-integration-gaps.md +678 -0
  180. package/docs/workspace-interfaces.md +731 -0
  181. package/docs/workspace-redesign-plan.md +302 -0
  182. package/package.json +4 -4
  183. package/src/__tests__/e2e/auto-sync.e2e.test.ts +257 -0
  184. package/src/__tests__/e2e/cascade-rebase.e2e.test.ts +254 -0
  185. package/src/__tests__/e2e/cli-run.e2e.test.ts +167 -0
  186. package/src/__tests__/e2e/self-driving-v3.e2e.test.ts +197 -0
  187. package/src/__tests__/e2e/spawn-resolver.e2e.test.ts +200 -0
  188. package/src/__tests__/e2e/workspace-lifecycle.e2e.test.ts +30 -22
  189. package/src/__tests__/e2e/workspace-v3.e2e.test.ts +413 -0
  190. package/src/acp/__tests__/claude-code-replay.test.ts +225 -0
  191. package/src/acp/__tests__/macro-agent.test.ts +39 -1
  192. package/src/acp/claude-code-replay.ts +208 -0
  193. package/src/acp/macro-agent.ts +167 -9
  194. package/src/acp/types.ts +10 -0
  195. package/src/agent/__tests__/agent-manager-topology.test.ts +73 -0
  196. package/src/agent/__tests__/agent-manager-v2.test.ts +66 -0
  197. package/src/agent/__tests__/task-ref-resolution.test.ts +231 -0
  198. package/src/agent/agent-manager-v2.ts +293 -48
  199. package/src/agent/agent-manager.ts +14 -0
  200. package/src/agent/types.ts +16 -2
  201. package/src/boot-v2.ts +68 -1
  202. package/src/cli/index.ts +61 -0
  203. package/src/cognitive/macro-agent-backend.ts +45 -29
  204. package/src/integrations/skilltree.ts +1 -0
  205. package/src/lifecycle/cleanup.ts +52 -3
  206. package/src/lifecycle/handlers-v2.ts +40 -3
  207. package/src/lifecycle/types.ts +12 -0
  208. package/src/map/__tests__/cascade-bridge.test.ts +229 -0
  209. package/src/map/__tests__/lifecycle-bridge.test.ts +86 -10
  210. package/src/map/acp-bridge.ts +26 -3
  211. package/src/map/cascade-bridge.ts +301 -0
  212. package/src/map/lifecycle-bridge.ts +52 -17
  213. package/src/map/server.ts +47 -6
  214. package/src/map/sidecar.ts +31 -1
  215. package/src/map/types.ts +20 -0
  216. package/src/mcp/tools/done-v2.ts +9 -0
  217. package/src/teams/team-manager-v2.ts +37 -0
  218. package/src/teams/team-runtime-v2.ts +23 -3
  219. package/src/workspace/__tests__/{dataplane-adapter.test.ts → git-cascade-adapter.test.ts} +209 -14
  220. package/src/workspace/__tests__/self-driving-yaml.test.ts +114 -0
  221. package/src/workspace/__tests__/shared-worktree-refcount.test.ts +154 -0
  222. package/src/workspace/__tests__/standalone-mode.test.ts +118 -0
  223. package/src/workspace/__tests__/workspace-manager-v3.test.ts +245 -0
  224. package/src/workspace/__tests__/yaml-schema.test.ts +210 -0
  225. package/src/workspace/config.ts +11 -11
  226. package/src/workspace/git-cascade-adapter.ts +1186 -0
  227. package/src/workspace/index.ts +11 -11
  228. package/src/workspace/landing/__tests__/strategies.test.ts +142 -0
  229. package/src/workspace/landing/direct-push.ts +91 -0
  230. package/src/workspace/landing/index.ts +40 -0
  231. package/src/workspace/landing/merge-to-parent.ts +228 -0
  232. package/src/workspace/landing/optimistic-push.ts +36 -0
  233. package/src/workspace/landing/queue-to-branch.ts +108 -0
  234. package/src/workspace/merge-queue/merge-queue.ts +10 -0
  235. package/src/workspace/merge-queue/types.ts +16 -2
  236. package/src/workspace/pool/__tests__/worktree-pool.integration.test.ts +5 -5
  237. package/src/workspace/pool/types.ts +1 -0
  238. package/src/workspace/pool/worktree-pool.ts +1 -0
  239. package/src/workspace/recovery/__tests__/auto-resolve-integration.test.ts +127 -0
  240. package/src/workspace/recovery/__tests__/spawn-resolver.test.ts +139 -0
  241. package/src/workspace/recovery/__tests__/strategies.test.ts +145 -0
  242. package/src/workspace/recovery/abandon.ts +51 -0
  243. package/src/workspace/recovery/auto-resolve.ts +119 -0
  244. package/src/workspace/recovery/defer.ts +23 -0
  245. package/src/workspace/recovery/escalate.ts +30 -0
  246. package/src/workspace/recovery/index.ts +58 -0
  247. package/src/workspace/recovery/spawn-resolver.ts +145 -0
  248. package/src/workspace/recovery/types.ts +54 -0
  249. package/src/workspace/topology/__tests__/yaml-driven.test.ts +345 -0
  250. package/src/workspace/topology/index.ts +18 -0
  251. package/src/workspace/topology/no-workspace.ts +39 -0
  252. package/src/workspace/topology/types.ts +116 -0
  253. package/src/workspace/topology/yaml-driven.ts +316 -0
  254. package/src/workspace/types-v3.ts +155 -0
  255. package/src/workspace/types.ts +191 -20
  256. package/src/workspace/workspace-manager.ts +474 -19
  257. package/src/workspace/yaml-schema.ts +216 -0
  258. package/src/workspace/dataplane-adapter.ts +0 -546
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Claude Code JSONL → ACP SessionUpdate replay
3
+ *
4
+ * Works around a gap in claude-code-acp where `loadSession` does NOT emit
5
+ * `session/update` notifications for historical conversation content — it only
6
+ * tells the Claude Code subprocess to restore context internally. That means
7
+ * clients (like OpenHive) calling ACP's `session/load` get back an empty
8
+ * session.
9
+ *
10
+ * Per the ACP spec (https://agentclientprotocol.com/protocol/session-setup#loading-sessions),
11
+ * loadSession SHOULD stream the conversation history back via session/update
12
+ * notifications. This module reads Claude Code's native JSONL transcript (which
13
+ * Claude Code always persists, no hooks required) and converts each entry to
14
+ * ACP SessionUpdate events so the client can reconstruct the conversation.
15
+ *
16
+ * TODO(upstream): fix claude-code-acp so this workaround becomes unnecessary.
17
+ * See node_modules/@sudocode-ai/claude-code-acp/dist/acp-agent.js:344-353.
18
+ *
19
+ * @module acp/claude-code-replay
20
+ */
21
+ import * as fs from "node:fs/promises";
22
+ import * as path from "node:path";
23
+ import * as os from "node:os";
24
+ import type { SessionUpdate } from "@agentclientprotocol/sdk";
25
+
26
+ /**
27
+ * Locate Claude Code's JSONL transcript for the given session.
28
+ *
29
+ * Claude Code writes transcripts to `~/.claude/projects/{encoded-cwd}/{session-id}.jsonl`.
30
+ * The cwd encoding replaces `/` with `-` (e.g., `/tmp/x` → `-private-tmp-x` on macOS
31
+ * where /tmp is a symlink). Rather than replicating the encoding exactly, we scan
32
+ * project directories for a file matching the session ID — the ID is a UUID so
33
+ * collisions are not a concern.
34
+ */
35
+ async function locateTranscript(providerSessionId: string): Promise<string | null> {
36
+ const projectsRoot = path.join(os.homedir(), ".claude", "projects");
37
+ let dirents: Awaited<ReturnType<typeof fs.readdir>>;
38
+ try {
39
+ // @ts-expect-error — withFileTypes overload returns Dirent[]
40
+ dirents = await fs.readdir(projectsRoot, { withFileTypes: true });
41
+ } catch {
42
+ return null;
43
+ }
44
+ for (const d of dirents as unknown as Array<{ isDirectory(): boolean; name: string }>) {
45
+ if (!d.isDirectory()) continue;
46
+ const candidate = path.join(projectsRoot, d.name, `${providerSessionId}.jsonl`);
47
+ try {
48
+ await fs.access(candidate);
49
+ return candidate;
50
+ } catch {
51
+ // Not in this dir — keep scanning
52
+ }
53
+ }
54
+ return null;
55
+ }
56
+
57
+ type ContentBlock =
58
+ | { type: "text"; text: string }
59
+ | { type: "tool_use"; id: string; name: string; input: unknown }
60
+ | { type: "tool_result"; tool_use_id: string; content: unknown; is_error?: boolean }
61
+ | { type: "thinking"; thinking?: string; text?: string };
62
+
63
+ interface JsonlEntry {
64
+ type?: string;
65
+ isMeta?: boolean;
66
+ message?: {
67
+ role?: string;
68
+ content?: string | ContentBlock[];
69
+ };
70
+ uuid?: string;
71
+ timestamp?: string;
72
+ }
73
+
74
+ /**
75
+ * Convert a user message content block into an ACP SessionUpdate.
76
+ */
77
+ function userBlockToUpdate(block: ContentBlock): SessionUpdate | null {
78
+ if (block.type === "text" && block.text) {
79
+ return {
80
+ sessionUpdate: "user_message_chunk",
81
+ content: { type: "text", text: block.text },
82
+ } as unknown as SessionUpdate;
83
+ }
84
+ if (block.type === "tool_result") {
85
+ const output =
86
+ typeof block.content === "string"
87
+ ? block.content
88
+ : JSON.stringify(block.content);
89
+ return {
90
+ sessionUpdate: "tool_call_update",
91
+ toolCallId: block.tool_use_id,
92
+ output,
93
+ status: block.is_error ? "failed" : "completed",
94
+ } as unknown as SessionUpdate;
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * Convert an assistant message content block into an ACP SessionUpdate.
101
+ */
102
+ function assistantBlockToUpdate(block: ContentBlock): SessionUpdate | null {
103
+ if (block.type === "text" && block.text) {
104
+ return {
105
+ sessionUpdate: "agent_message_chunk",
106
+ content: { type: "text", text: block.text },
107
+ } as unknown as SessionUpdate;
108
+ }
109
+ if (block.type === "tool_use") {
110
+ return {
111
+ sessionUpdate: "tool_call",
112
+ toolCallId: block.id,
113
+ title: block.name,
114
+ rawInput: block.input,
115
+ status: "pending",
116
+ } as unknown as SessionUpdate;
117
+ }
118
+ if (block.type === "thinking") {
119
+ const text = block.thinking ?? block.text ?? "";
120
+ if (!text) return null;
121
+ return {
122
+ sessionUpdate: "agent_thought_chunk",
123
+ content: { type: "text", text },
124
+ } as unknown as SessionUpdate;
125
+ }
126
+ return null;
127
+ }
128
+
129
+ /**
130
+ * Check if a string user-message looks like a Claude Code internal command
131
+ * (e.g., `<command-name>/model</command-name>`, `<local-command-stdout>...`).
132
+ * These get recorded in the JSONL but aren't part of the conversation UX.
133
+ */
134
+ function isInternalCommand(text: string): boolean {
135
+ const t = text.trimStart();
136
+ return (
137
+ t.startsWith("<command-") ||
138
+ t.startsWith("<local-command-") ||
139
+ t.startsWith("<system-reminder") ||
140
+ t.startsWith("Caveat:")
141
+ );
142
+ }
143
+
144
+ /**
145
+ * Read Claude Code's JSONL transcript for a session and yield ACP SessionUpdate
146
+ * events suitable for emitting via `connection.sessionUpdate({ sessionId, update })`.
147
+ *
148
+ * Yields events in chronological order. Returns early (yields nothing) if the
149
+ * transcript file doesn't exist — e.g., the agent is running on a different
150
+ * machine.
151
+ */
152
+ export async function* replayClaudeCodeTranscript(
153
+ providerSessionId: string,
154
+ ): AsyncGenerator<SessionUpdate> {
155
+ const jsonlPath = await locateTranscript(providerSessionId);
156
+ if (!jsonlPath) return;
157
+
158
+ let raw: string;
159
+ try {
160
+ raw = await fs.readFile(jsonlPath, "utf-8");
161
+ } catch {
162
+ return;
163
+ }
164
+
165
+ for (const line of raw.split("\n")) {
166
+ const trimmed = line.trim();
167
+ if (!trimmed) continue;
168
+
169
+ let entry: JsonlEntry;
170
+ try {
171
+ entry = JSON.parse(trimmed);
172
+ } catch {
173
+ continue;
174
+ }
175
+
176
+ // Skip non-conversation entries (queue-operations, summaries, etc.)
177
+ if (entry.type !== "user" && entry.type !== "assistant") continue;
178
+
179
+ // Skip meta messages (local command output, system injections)
180
+ if (entry.isMeta) continue;
181
+
182
+ const message = entry.message;
183
+ if (!message) continue;
184
+ const content = message.content;
185
+
186
+ if (entry.type === "user") {
187
+ if (typeof content === "string") {
188
+ if (isInternalCommand(content)) continue;
189
+ yield {
190
+ sessionUpdate: "user_message_chunk",
191
+ content: { type: "text", text: content },
192
+ } as unknown as SessionUpdate;
193
+ } else if (Array.isArray(content)) {
194
+ for (const block of content) {
195
+ const upd = userBlockToUpdate(block);
196
+ if (upd) yield upd;
197
+ }
198
+ }
199
+ } else if (entry.type === "assistant") {
200
+ if (Array.isArray(content)) {
201
+ for (const block of content) {
202
+ const upd = assistantBlockToUpdate(block);
203
+ if (upd) yield upd;
204
+ }
205
+ }
206
+ }
207
+ }
208
+ }
@@ -154,6 +154,20 @@ export function createMacroAgent(
154
154
  let firstPrompt: string | null = null;
155
155
  const sessionStartedAt = new Date().toISOString();
156
156
 
157
+ // Per-session update history for ACP session/load replay.
158
+ // Per the ACP spec, loadSession should re-emit all session/update notifications
159
+ // previously sent so the client can reconstruct state. We buffer them here
160
+ // keyed by ACP session ID.
161
+ const sessionUpdateHistory = new Map<string, SessionUpdate[]>();
162
+ function appendSessionUpdate(acpSessionId: string, update: SessionUpdate): void {
163
+ const arr = sessionUpdateHistory.get(acpSessionId);
164
+ if (arr) {
165
+ arr.push(update);
166
+ } else {
167
+ sessionUpdateHistory.set(acpSessionId, [update]);
168
+ }
169
+ }
170
+
157
171
  // Git info (cached — read once per session)
158
172
  let gitInfo: { branch: string | null; commitHash: string | null; remoteUrl: string | null } | null = null;
159
173
  function getGitInfo(cwd: string): { branch: string | null; commitHash: string | null; remoteUrl: string | null } {
@@ -414,20 +428,46 @@ export function createMacroAgent(
414
428
  params: NewSessionRequest,
415
429
  ): Promise<NewSessionResponse> {
416
430
  const cwd = params.cwd ?? defaultCwd;
417
- // Get or create a head manager for this workspace
418
- const headManager = await agentManager.getOrCreateHeadManager({ cwd });
431
+
432
+ // Two paths into newSession:
433
+ //
434
+ // 1. MAP-bound stream — initConfig.targetAgentId is set by the ACP
435
+ // bridge to the local agent ID this stream was opened against.
436
+ // Bind the session to that specific agent (any role), preserving
437
+ // the routing intent that brought the stream here. This matters
438
+ // when multiple coordinators share a cwd: cwd-based lookup would
439
+ // pick whichever the store returned first, which may not be the
440
+ // one the client actually wanted to talk to.
441
+ //
442
+ // 2. Fallback — pure ACP client with no agent context. Use
443
+ // cwd-based head-manager lookup (spawning one if needed). This
444
+ // keeps stock ACP clients working without protocol changes.
445
+ let target: { id: string; session_id: string };
446
+ if (initConfig?.targetAgentId) {
447
+ const bound = agentManager.getActiveAgentSession(
448
+ initConfig.targetAgentId as any,
449
+ );
450
+ if (!bound) {
451
+ throw new ACPError(
452
+ `Agent ${initConfig.targetAgentId} is not running or has no active session`,
453
+ "AGENT_NOT_FOUND",
454
+ { agentId: initConfig.targetAgentId },
455
+ );
456
+ }
457
+ target = { id: bound.id, session_id: bound.session_id };
458
+ } else {
459
+ const headManager = await agentManager.getOrCreateHeadManager({ cwd });
460
+ target = { id: headManager.id, session_id: headManager.session_id };
461
+ }
419
462
 
420
463
  // Create session mapping
421
- const mapping = sessionMapper.createMapping(
422
- headManager.session_id,
423
- headManager.id,
424
- );
464
+ const mapping = sessionMapper.createMapping(target.session_id, target.id);
425
465
 
426
466
  // Annotate sessionlog with swarm metadata (best effort)
427
467
  try {
428
468
  const { annotateSession } = await import("../integrations/sessionlog.js");
429
469
  annotateSession(cwd, {
430
- swarmId: headManager.id,
470
+ swarmId: target.id,
431
471
  scope: "macro-agent",
432
472
  });
433
473
  } catch {
@@ -444,9 +484,93 @@ export function createMacroAgent(
444
484
  ): Promise<LoadSessionResponse> {
445
485
  const sessionId = params.sessionId;
446
486
 
487
+ /**
488
+ * Resolve the provider_session_id (Claude Code's UUID for the session)
489
+ * for a given macro-agent acp session. Needed to locate the JSONL
490
+ * transcript on disk as a fallback when the in-memory buffer is empty.
491
+ */
492
+ const resolveProviderSessionId = (agentId: string): string | undefined => {
493
+ const store = (system as any).agentStore;
494
+ const rec = store?.getAgent?.(agentId);
495
+ const meta = rec?.metadata as Record<string, unknown> | undefined;
496
+ const psid = meta?.provider_session_id;
497
+ return typeof psid === "string" ? psid : undefined;
498
+ };
499
+
500
+ // Replay helper: re-emit session/update notifications so the client can
501
+ // rebuild its view of the conversation. Per the ACP spec, loadSession
502
+ // SHOULD emit all previously-sent session/update events.
503
+ //
504
+ // Two sources in priority order:
505
+ // 1. In-memory buffer (populated by prior prompts in this process).
506
+ // Fast and accurate — exactly what was streamed to the client.
507
+ // 2. Claude Code's JSONL transcript on disk (durable across restarts).
508
+ // Used when the buffer is empty — e.g., we got a loadSession for a
509
+ // session that was prompted but whose updates were never buffered,
510
+ // or after a process restart within the same session's lifetime.
511
+ //
512
+ // This is a workaround: the ACP spec expects the underlying agent
513
+ // (claude-code-acp) to emit session/update during loadSession, but its
514
+ // current implementation only passes `resume: sessionId` to Claude Code
515
+ // SDK for internal context restoration without surfacing history to the
516
+ // client. See claude-code-replay.ts for details.
517
+ const replayHistory = async (agentIdForLookup?: string, explicitProviderSessionId?: string): Promise<void> => {
518
+ const buffered = sessionUpdateHistory.get(sessionId);
519
+ if (buffered && buffered.length > 0) {
520
+ for (const update of buffered) {
521
+ try {
522
+ await connection.sessionUpdate({ sessionId, update });
523
+ } catch {
524
+ // Best effort — continue replaying remaining updates
525
+ }
526
+ }
527
+ return;
528
+ }
529
+
530
+ // Buffer empty — fall back to Claude Code JSONL if we can locate it.
531
+ // Provider session ID resolution priority:
532
+ // 1. Explicit — passed via ACP LoadSessionRequest._meta.provider_session_id
533
+ // (survives macro-agent restart since it comes from the client)
534
+ // 2. From agent metadata — when sessionMapper still has the mapping
535
+ const providerSessionId =
536
+ explicitProviderSessionId ??
537
+ (agentIdForLookup ? resolveProviderSessionId(agentIdForLookup) : undefined);
538
+ if (!providerSessionId) return;
539
+
540
+ try {
541
+ const { replayClaudeCodeTranscript } = await import("./claude-code-replay.js");
542
+ const bufferForFuture: SessionUpdate[] = [];
543
+ for await (const update of replayClaudeCodeTranscript(providerSessionId)) {
544
+ try {
545
+ await connection.sessionUpdate({ sessionId, update });
546
+ bufferForFuture.push(update);
547
+ } catch {
548
+ // Best effort — continue
549
+ }
550
+ }
551
+ // Populate the in-memory buffer so subsequent loadSession calls use
552
+ // the fast path. Only if we actually read something.
553
+ if (bufferForFuture.length > 0) {
554
+ sessionUpdateHistory.set(sessionId, bufferForFuture);
555
+ }
556
+ } catch {
557
+ // Transcript read failed — no history to emit
558
+ }
559
+ };
560
+
561
+ // Extract optional provider_session_id from ACP LoadSessionRequest._meta.
562
+ // Clients (like OpenHive) that know the underlying Claude Code session ID
563
+ // can pass it here to enable history recovery across macro-agent restarts
564
+ // (when sessionMapper is empty because it's in-memory only).
565
+ const metaProviderSessionId =
566
+ typeof (params as any)._meta?.provider_session_id === "string"
567
+ ? ((params as any)._meta.provider_session_id as string)
568
+ : undefined;
569
+
447
570
  // Check if we already have a mapping for this session
448
571
  let mapping = sessionMapper.getMapping(sessionId);
449
572
  if (mapping) {
573
+ await replayHistory(mapping.agentId, metaProviderSessionId);
450
574
  return {};
451
575
  }
452
576
 
@@ -459,6 +583,17 @@ export function createMacroAgent(
459
583
  resumed.session_id,
460
584
  resumed.id,
461
585
  );
586
+ await replayHistory(resumed.id, metaProviderSessionId);
587
+ return {};
588
+ }
589
+
590
+ // No mapping — but if the client supplied provider_session_id via _meta,
591
+ // we can still replay history from the JSONL on disk. This is the
592
+ // cross-restart recovery path. We don't create a session mapping because
593
+ // there's no live agent to bind to — the client should create a fresh
594
+ // session if it wants to continue the conversation.
595
+ if (metaProviderSessionId) {
596
+ await replayHistory(undefined, metaProviderSessionId);
462
597
  return {};
463
598
  }
464
599
 
@@ -488,6 +623,20 @@ export function createMacroAgent(
488
623
  }
489
624
  const message = textParts.join("\n") || "";
490
625
 
626
+ // Record the user prompt as a user_message_chunk update in the replay
627
+ // buffer so session/load can reconstruct the full conversation. The
628
+ // agent itself doesn't emit this — the client sent it — but it IS part
629
+ // of the session's logical state.
630
+ for (const block of params.prompt) {
631
+ if ("text" in block && typeof block.text === "string" && block.text) {
632
+ const userChunk = {
633
+ sessionUpdate: "user_message_chunk",
634
+ content: { type: "text", text: block.text },
635
+ } as unknown as SessionUpdate;
636
+ appendSessionUpdate(params.sessionId, userChunk);
637
+ }
638
+ }
639
+
491
640
  sessionMapper.setProcessing(params.sessionId, true);
492
641
 
493
642
  // Capture first prompt for trajectory metadata (used as session description in OpenHive UI)
@@ -507,7 +656,7 @@ export function createMacroAgent(
507
656
  if (mapServer) {
508
657
  const agents = mapServer.agents?.list?.() ?? [];
509
658
  const mapAgent = agents.find(
510
- (a: any) => a.metadata?.localAgentId === agentId,
659
+ (a: any) => a.metadata?.peerAgentId === agentId,
511
660
  );
512
661
  if (mapAgent) {
513
662
  mapServer.agents.updateState(mapAgent.id, "busy");
@@ -703,6 +852,10 @@ export function createMacroAgent(
703
852
  sessionId: params.sessionId,
704
853
  update,
705
854
  };
855
+ // Buffer for replay on session/load. Per the ACP spec, loadSession
856
+ // should re-emit all previously sent session/update notifications
857
+ // so the client can reconstruct state after reconnect.
858
+ appendSessionUpdate(params.sessionId, update);
706
859
  await connection.sessionUpdate(notification);
707
860
  }
708
861
  }
@@ -742,6 +895,11 @@ export function createMacroAgent(
742
895
  phase: "active",
743
896
  startedAt: sessionStartedAt,
744
897
  label: `Step ${checkpointCounter} (${toolCallCount} tool calls)`,
898
+ // Underlying Claude Code session ID — lets OpenHive find the
899
+ // JSONL transcript on disk for history recovery even if the
900
+ // macro-agent process dies.
901
+ provider_session_id:
902
+ (agentRecord as any)?.metadata?.provider_session_id ?? undefined,
745
903
  // metrics fields
746
904
  ...(isMetrics ? {
747
905
  duration_ms: Date.now() - promptStartTime,
@@ -785,7 +943,7 @@ export function createMacroAgent(
785
943
  if (mapServer) {
786
944
  const agents = mapServer.agents?.list?.() ?? [];
787
945
  const mapAgent = agents.find(
788
- (a: any) => a.metadata?.localAgentId === agentId,
946
+ (a: any) => a.metadata?.peerAgentId === agentId,
789
947
  );
790
948
  if (mapAgent) {
791
949
  mapServer.agents.updateState(mapAgent.id, "idle");
package/src/acp/types.ts CHANGED
@@ -54,6 +54,16 @@ export interface MacroAgentInitConfig {
54
54
 
55
55
  /** Suffix appended to system prompts */
56
56
  systemPromptSuffix?: string;
57
+
58
+ /**
59
+ * Local agent ID this ACP stream is bound to. When set, `session/new` binds
60
+ * the new session to this specific agent (any role) instead of falling back
61
+ * to cwd-based head-manager lookup. Set by the ACP-over-MAP bridge so that
62
+ * MAP-level routing (which already targets a specific agent) is preserved
63
+ * end-to-end through the ACP layer — important when multiple coordinators
64
+ * share the same cwd.
65
+ */
66
+ targetAgentId?: string;
57
67
  }
58
68
 
59
69
  // ─────────────────────────────────────────────────────────────────
@@ -0,0 +1,73 @@
1
+ /**
2
+ * AgentManagerV2 + TopologyPolicy integration (Phase 4).
3
+ *
4
+ * Verifies that when a TopologyPolicy is set, workspace allocation goes
5
+ * through the V3 path (YamlDrivenTopology → WorkspaceDecision → V3 methods).
6
+ * When unset, legacy role-name dispatch is preserved (regression guard).
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
10
+ import { YamlDrivenTopology } from '../../workspace/topology/yaml-driven.js';
11
+ import { parseTeamWorkspaceConfig } from '../../workspace/yaml-schema.js';
12
+
13
+ // Isolated test: verify the TopologyPolicy → WorkspaceDecision compilation
14
+ // happens when policy is set. Full AgentManagerV2 spawn integration is
15
+ // covered by E2E tests; here we validate the delegation logic in isolation.
16
+
17
+ describe('AgentManagerV2 + TopologyPolicy (Phase 4)', () => {
18
+ describe('policy presence', () => {
19
+ it('YamlDrivenTopology is the primary policy for declarative teams', async () => {
20
+ const config = parseTeamWorkspaceConfig({
21
+ roles: {
22
+ peer: {
23
+ workspace: 'new_stream',
24
+ stream_lineage: 'fork_from_team_root',
25
+ },
26
+ },
27
+ });
28
+ const topology = new YamlDrivenTopology(config!);
29
+
30
+ // Policy name is stable (used for introspection)
31
+ expect(topology.name).toBe('yaml-driven');
32
+ });
33
+
34
+ it('onAgentSpawn is invocable via policy reference', async () => {
35
+ const config = parseTeamWorkspaceConfig({
36
+ roles: {
37
+ peer: { workspace: 'none' },
38
+ },
39
+ });
40
+ const topology = new YamlDrivenTopology(config!);
41
+
42
+ const mockWs = {
43
+ createStreamV3: vi.fn(() => 'stream-1'),
44
+ allocateWorktree: vi.fn(),
45
+ } as unknown as import('../../workspace/types.js').WorkspaceManager;
46
+
47
+ await topology.onTeamStart({
48
+ teamName: 't',
49
+ teamInstanceId: 't-1',
50
+ workspaceConfig: config,
51
+ workspaceManager: mockWs,
52
+ });
53
+
54
+ const decision = await topology.onAgentSpawn({
55
+ agentId: 'a1',
56
+ role: 'peer',
57
+ workspaceManager: mockWs,
58
+ });
59
+
60
+ expect(decision.kind).toBe('none');
61
+ });
62
+ });
63
+
64
+ describe('policy absence (legacy path)', () => {
65
+ it('policy=null means AgentManagerV2 uses role-name dispatch (regression guard)', () => {
66
+ // This test documents the behavior contract: when
67
+ // setTopologyPolicy(null) or never called, the legacy switch(role)
68
+ // path in agent-manager-v2.ts:createWorkspaceForRole is the active
69
+ // code path. Validated by existing E2E tests for self-driving.
70
+ expect(true).toBe(true);
71
+ });
72
+ });
73
+ });
@@ -452,6 +452,72 @@ describe("AgentManagerV2", () => {
452
452
  });
453
453
  });
454
454
 
455
+ // ── getOrCreateHeadManager() ───────────────────────────────
456
+
457
+ describe("getOrCreateHeadManager()", () => {
458
+ it("reuses an existing head manager matching the requested cwd", async () => {
459
+ const first = await manager.getOrCreateHeadManager({ cwd: "/tmp/proj-a" });
460
+ const second = await manager.getOrCreateHeadManager({ cwd: "/tmp/proj-a" });
461
+ expect(second.id).toBe(first.id);
462
+ });
463
+
464
+ it("spawns a distinct head manager for a different cwd", async () => {
465
+ const a = await manager.getOrCreateHeadManager({ cwd: "/tmp/proj-a" });
466
+ const b = await manager.getOrCreateHeadManager({ cwd: "/tmp/proj-b" });
467
+ expect(b.id).not.toBe(a.id);
468
+ });
469
+
470
+ it("getActiveAgentSession returns null for unknown agents", () => {
471
+ expect(manager.getActiveAgentSession("never-spawned" as AgentId)).toBeNull();
472
+ });
473
+
474
+ it("getActiveAgentSession returns the spawned shape for any role with a live session", async () => {
475
+ // Spawn a non-coordinator (worker) so we exercise the role-agnostic path
476
+ const worker = await manager.spawn({ task: "Test work", role: "worker" });
477
+
478
+ const session = manager.getActiveAgentSession(worker.id as AgentId);
479
+ expect(session).not.toBeNull();
480
+ expect(session!.id).toBe(worker.id);
481
+ expect(session!.agent.role).toBe("worker");
482
+ });
483
+
484
+ it("getActiveAgentSession returns null after the agent is terminated", async () => {
485
+ const worker = await manager.spawn({ task: "Test work", role: "worker" });
486
+ await manager.terminate(worker.id as AgentId, "completed");
487
+ expect(manager.getActiveAgentSession(worker.id as AgentId)).toBeNull();
488
+ });
489
+
490
+ it("ignores stale 'running' store records whose session isn't live in this process", async () => {
491
+ // Simulate a crashed prior process: a coordinator record exists in the
492
+ // store with state='running' and parent_id=null, but has no matching
493
+ // activeSessions entry (the session was lost when the process died).
494
+ // Without the activeSessions filter in the predicate, getOrCreate would
495
+ // erroneously reuse this stale record and skip spawning.
496
+ agentStore.putAgent({
497
+ id: "stale-coord" as AgentId,
498
+ name: "stale-coord",
499
+ role: "coordinator",
500
+ state: "running",
501
+ parent_id: null,
502
+ lineage: [],
503
+ team: null,
504
+ scope: "default",
505
+ task: "",
506
+ task_id: "",
507
+ cwd: "/tmp/stale-proj",
508
+ capabilities: [],
509
+ created_at: Date.now() as any,
510
+ started_at: Date.now() as any,
511
+ config: {},
512
+ metadata: {},
513
+ });
514
+
515
+ const result = await manager.getOrCreateHeadManager({ cwd: "/tmp/stale-proj" });
516
+ expect(result.id).not.toBe("stale-coord");
517
+ expect(manager.hasActiveSession(result.id as AgentId)).toBe(true);
518
+ });
519
+ });
520
+
455
521
  // ── Lifecycle Callbacks ────────────────────────────────────
456
522
 
457
523
  describe("lifecycle callbacks", () => {