@made-by-moonlight/athene-core 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (285) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +241 -0
  3. package/dist/activity-events.d.ts +42 -0
  4. package/dist/activity-events.d.ts.map +1 -0
  5. package/dist/activity-events.js +192 -0
  6. package/dist/activity-events.js.map +1 -0
  7. package/dist/activity-log.d.ts +71 -0
  8. package/dist/activity-log.d.ts.map +1 -0
  9. package/dist/activity-log.js +203 -0
  10. package/dist/activity-log.js.map +1 -0
  11. package/dist/activity-signal.d.ts +20 -0
  12. package/dist/activity-signal.d.ts.map +1 -0
  13. package/dist/activity-signal.js +91 -0
  14. package/dist/activity-signal.js.map +1 -0
  15. package/dist/agent-report.d.ts +148 -0
  16. package/dist/agent-report.d.ts.map +1 -0
  17. package/dist/agent-report.js +516 -0
  18. package/dist/agent-report.js.map +1 -0
  19. package/dist/agent-selection.d.ts +31 -0
  20. package/dist/agent-selection.d.ts.map +1 -0
  21. package/dist/agent-selection.js +69 -0
  22. package/dist/agent-selection.js.map +1 -0
  23. package/dist/agent-workspace-hooks.d.ts +74 -0
  24. package/dist/agent-workspace-hooks.d.ts.map +1 -0
  25. package/dist/agent-workspace-hooks.js +988 -0
  26. package/dist/agent-workspace-hooks.js.map +1 -0
  27. package/dist/atomic-write.d.ts +6 -0
  28. package/dist/atomic-write.d.ts.map +1 -0
  29. package/dist/atomic-write.js +49 -0
  30. package/dist/atomic-write.js.map +1 -0
  31. package/dist/cleanup-stack.d.ts +37 -0
  32. package/dist/cleanup-stack.d.ts.map +1 -0
  33. package/dist/cleanup-stack.js +45 -0
  34. package/dist/cleanup-stack.js.map +1 -0
  35. package/dist/code-review-manager.d.ts +118 -0
  36. package/dist/code-review-manager.d.ts.map +1 -0
  37. package/dist/code-review-manager.js +719 -0
  38. package/dist/code-review-manager.js.map +1 -0
  39. package/dist/code-review-store.d.ts +114 -0
  40. package/dist/code-review-store.d.ts.map +1 -0
  41. package/dist/code-review-store.js +346 -0
  42. package/dist/code-review-store.js.map +1 -0
  43. package/dist/config-generator.d.ts +84 -0
  44. package/dist/config-generator.d.ts.map +1 -0
  45. package/dist/config-generator.js +295 -0
  46. package/dist/config-generator.js.map +1 -0
  47. package/dist/config.d.ts +55 -0
  48. package/dist/config.d.ts.map +1 -0
  49. package/dist/config.js +852 -0
  50. package/dist/config.js.map +1 -0
  51. package/dist/daemon-children.d.ts +55 -0
  52. package/dist/daemon-children.d.ts.map +1 -0
  53. package/dist/daemon-children.js +435 -0
  54. package/dist/daemon-children.js.map +1 -0
  55. package/dist/dashboard-notifications.d.ts +42 -0
  56. package/dist/dashboard-notifications.d.ts.map +1 -0
  57. package/dist/dashboard-notifications.js +123 -0
  58. package/dist/dashboard-notifications.js.map +1 -0
  59. package/dist/events-db.d.ts +39 -0
  60. package/dist/events-db.d.ts.map +1 -0
  61. package/dist/events-db.js +185 -0
  62. package/dist/events-db.js.map +1 -0
  63. package/dist/feature-flags.d.ts +2 -0
  64. package/dist/feature-flags.d.ts.map +1 -0
  65. package/dist/feature-flags.js +9 -0
  66. package/dist/feature-flags.js.map +1 -0
  67. package/dist/feedback-tools.d.ts +97 -0
  68. package/dist/feedback-tools.d.ts.map +1 -0
  69. package/dist/feedback-tools.js +161 -0
  70. package/dist/feedback-tools.js.map +1 -0
  71. package/dist/file-lock.d.ts +5 -0
  72. package/dist/file-lock.d.ts.map +1 -0
  73. package/dist/file-lock.js +59 -0
  74. package/dist/file-lock.js.map +1 -0
  75. package/dist/format-automated-comments.d.ts +18 -0
  76. package/dist/format-automated-comments.d.ts.map +1 -0
  77. package/dist/gh-trace.d.ts +57 -0
  78. package/dist/gh-trace.d.ts.map +1 -0
  79. package/dist/gh-trace.js +320 -0
  80. package/dist/gh-trace.js.map +1 -0
  81. package/dist/git-activity.d.ts +10 -0
  82. package/dist/git-activity.d.ts.map +1 -0
  83. package/dist/git-activity.js +30 -0
  84. package/dist/git-activity.js.map +1 -0
  85. package/dist/global-config.d.ts +1085 -0
  86. package/dist/global-config.d.ts.map +1 -0
  87. package/dist/global-config.js +1067 -0
  88. package/dist/global-config.js.map +1 -0
  89. package/dist/index.d.ts +91 -0
  90. package/dist/index.d.ts.map +1 -0
  91. package/dist/index.js +59 -0
  92. package/dist/index.js.map +1 -0
  93. package/dist/key-value.d.ts +7 -0
  94. package/dist/key-value.d.ts.map +1 -0
  95. package/dist/key-value.js +24 -0
  96. package/dist/key-value.js.map +1 -0
  97. package/dist/lifecycle-manager.d.ts +22 -0
  98. package/dist/lifecycle-manager.d.ts.map +1 -0
  99. package/dist/lifecycle-manager.js +2813 -0
  100. package/dist/lifecycle-manager.js.map +1 -0
  101. package/dist/lifecycle-state.d.ts +28 -0
  102. package/dist/lifecycle-state.d.ts.map +1 -0
  103. package/dist/lifecycle-state.js +446 -0
  104. package/dist/lifecycle-state.js.map +1 -0
  105. package/dist/lifecycle-status-decisions.d.ts +85 -0
  106. package/dist/lifecycle-status-decisions.d.ts.map +1 -0
  107. package/dist/lifecycle-status-decisions.js +262 -0
  108. package/dist/lifecycle-status-decisions.js.map +1 -0
  109. package/dist/lifecycle-transition.d.ts +81 -0
  110. package/dist/lifecycle-transition.d.ts.map +1 -0
  111. package/dist/lifecycle-transition.js +207 -0
  112. package/dist/lifecycle-transition.js.map +1 -0
  113. package/dist/metadata.d.ts +54 -0
  114. package/dist/metadata.d.ts.map +1 -0
  115. package/dist/metadata.js +484 -0
  116. package/dist/metadata.js.map +1 -0
  117. package/dist/migration/storage-v2.d.ts +76 -0
  118. package/dist/migration/storage-v2.d.ts.map +1 -0
  119. package/dist/migration/storage-v2.js +1614 -0
  120. package/dist/migration/storage-v2.js.map +1 -0
  121. package/dist/notification-data.d.ts +135 -0
  122. package/dist/notification-data.d.ts.map +1 -0
  123. package/dist/notification-data.js +204 -0
  124. package/dist/notification-data.js.map +1 -0
  125. package/dist/notification-observability.d.ts +21 -0
  126. package/dist/notification-observability.d.ts.map +1 -0
  127. package/dist/notification-observability.js +154 -0
  128. package/dist/notification-observability.js.map +1 -0
  129. package/dist/notifier-resolution.d.ts +14 -0
  130. package/dist/notifier-resolution.d.ts.map +1 -0
  131. package/dist/notifier-resolution.js +23 -0
  132. package/dist/notifier-resolution.js.map +1 -0
  133. package/dist/observability.d.ts +100 -0
  134. package/dist/observability.d.ts.map +1 -0
  135. package/dist/observability.js +535 -0
  136. package/dist/observability.js.map +1 -0
  137. package/dist/opencode-agents-md.d.ts +3 -0
  138. package/dist/opencode-agents-md.d.ts.map +1 -0
  139. package/dist/opencode-agents-md.js +40 -0
  140. package/dist/opencode-agents-md.js.map +1 -0
  141. package/dist/opencode-config.d.ts +2 -0
  142. package/dist/opencode-config.d.ts.map +1 -0
  143. package/dist/opencode-config.js +17 -0
  144. package/dist/opencode-config.js.map +1 -0
  145. package/dist/opencode-session-id.d.ts +2 -0
  146. package/dist/opencode-session-id.d.ts.map +1 -0
  147. package/dist/opencode-session-id.js +12 -0
  148. package/dist/opencode-session-id.js.map +1 -0
  149. package/dist/opencode-shared.d.ts +80 -0
  150. package/dist/opencode-shared.d.ts.map +1 -0
  151. package/dist/opencode-shared.js +202 -0
  152. package/dist/opencode-shared.js.map +1 -0
  153. package/dist/orchestrator-prompt.d.ts +19 -0
  154. package/dist/orchestrator-prompt.d.ts.map +1 -0
  155. package/dist/orchestrator-prompt.js +130 -0
  156. package/dist/orchestrator-prompt.js.map +1 -0
  157. package/dist/orchestrator-session-strategy.d.ts +5 -0
  158. package/dist/orchestrator-session-strategy.d.ts.map +1 -0
  159. package/dist/orchestrator-session-strategy.js +13 -0
  160. package/dist/orchestrator-session-strategy.js.map +1 -0
  161. package/dist/paths.d.ts +145 -0
  162. package/dist/paths.d.ts.map +1 -0
  163. package/dist/paths.js +288 -0
  164. package/dist/paths.js.map +1 -0
  165. package/dist/platform.d.ts +32 -0
  166. package/dist/platform.d.ts.map +1 -0
  167. package/dist/platform.js +211 -0
  168. package/dist/platform.js.map +1 -0
  169. package/dist/plugin-registry.d.ts +15 -0
  170. package/dist/plugin-registry.d.ts.map +1 -0
  171. package/dist/plugin-registry.js +499 -0
  172. package/dist/plugin-registry.js.map +1 -0
  173. package/dist/portfolio-projects.d.ts +7 -0
  174. package/dist/portfolio-projects.d.ts.map +1 -0
  175. package/dist/portfolio-projects.js +65 -0
  176. package/dist/portfolio-projects.js.map +1 -0
  177. package/dist/portfolio-registry.d.ts +42 -0
  178. package/dist/portfolio-registry.d.ts.map +1 -0
  179. package/dist/portfolio-registry.js +311 -0
  180. package/dist/portfolio-registry.js.map +1 -0
  181. package/dist/portfolio-routing.d.ts +5 -0
  182. package/dist/portfolio-routing.d.ts.map +1 -0
  183. package/dist/portfolio-routing.js +24 -0
  184. package/dist/portfolio-routing.js.map +1 -0
  185. package/dist/portfolio-session-service.d.ts +15 -0
  186. package/dist/portfolio-session-service.d.ts.map +1 -0
  187. package/dist/portfolio-session-service.js +206 -0
  188. package/dist/portfolio-session-service.js.map +1 -0
  189. package/dist/process-cache.d.ts +32 -0
  190. package/dist/process-cache.d.ts.map +1 -0
  191. package/dist/process-cache.js +44 -0
  192. package/dist/process-cache.js.map +1 -0
  193. package/dist/project-resolver.d.ts +5 -0
  194. package/dist/project-resolver.d.ts.map +1 -0
  195. package/dist/project-resolver.js +20 -0
  196. package/dist/project-resolver.js.map +1 -0
  197. package/dist/prompt-builder.d.ts +42 -0
  198. package/dist/prompt-builder.d.ts.map +1 -0
  199. package/dist/prompt-builder.js +182 -0
  200. package/dist/prompt-builder.js.map +1 -0
  201. package/dist/prompts/orchestrator.md.js +4 -0
  202. package/dist/prompts/orchestrator.md.js.map +1 -0
  203. package/dist/query-activity-events.d.ts +42 -0
  204. package/dist/query-activity-events.d.ts.map +1 -0
  205. package/dist/query-activity-events.js +170 -0
  206. package/dist/query-activity-events.js.map +1 -0
  207. package/dist/recovery/actions.d.ts +7 -0
  208. package/dist/recovery/actions.d.ts.map +1 -0
  209. package/dist/recovery/index.d.ts +8 -0
  210. package/dist/recovery/index.d.ts.map +1 -0
  211. package/dist/recovery/logger.d.ts +12 -0
  212. package/dist/recovery/logger.d.ts.map +1 -0
  213. package/dist/recovery/manager.d.ts +24 -0
  214. package/dist/recovery/manager.d.ts.map +1 -0
  215. package/dist/recovery/scanner.d.ts +11 -0
  216. package/dist/recovery/scanner.d.ts.map +1 -0
  217. package/dist/recovery/types.d.ts +170 -0
  218. package/dist/recovery/types.d.ts.map +1 -0
  219. package/dist/recovery/validator.d.ts +8 -0
  220. package/dist/recovery/validator.d.ts.map +1 -0
  221. package/dist/report-watcher.d.ts +93 -0
  222. package/dist/report-watcher.d.ts.map +1 -0
  223. package/dist/report-watcher.js +182 -0
  224. package/dist/report-watcher.js.map +1 -0
  225. package/dist/scm-webhook-utils.d.ts +6 -0
  226. package/dist/scm-webhook-utils.d.ts.map +1 -0
  227. package/dist/scm-webhook-utils.js +36 -0
  228. package/dist/scm-webhook-utils.js.map +1 -0
  229. package/dist/session-manager.d.ts +22 -0
  230. package/dist/session-manager.d.ts.map +1 -0
  231. package/dist/session-manager.js +3077 -0
  232. package/dist/session-manager.js.map +1 -0
  233. package/dist/spawn-target.d.ts +23 -0
  234. package/dist/spawn-target.d.ts.map +1 -0
  235. package/dist/spawn-target.js +39 -0
  236. package/dist/spawn-target.js.map +1 -0
  237. package/dist/storage-key.d.ts +9 -0
  238. package/dist/storage-key.d.ts.map +1 -0
  239. package/dist/storage-key.js +59 -0
  240. package/dist/storage-key.js.map +1 -0
  241. package/dist/tmux.d.ts +39 -0
  242. package/dist/tmux.d.ts.map +1 -0
  243. package/dist/tmux.js +141 -0
  244. package/dist/tmux.js.map +1 -0
  245. package/dist/types.d.ts +1496 -0
  246. package/dist/types.d.ts.map +1 -0
  247. package/dist/types.js +215 -0
  248. package/dist/types.js.map +1 -0
  249. package/dist/update-cache.d.ts +59 -0
  250. package/dist/update-cache.d.ts.map +1 -0
  251. package/dist/update-cache.js +77 -0
  252. package/dist/update-cache.js.map +1 -0
  253. package/dist/utils/metadata-flatten.d.ts +3 -0
  254. package/dist/utils/metadata-flatten.d.ts.map +1 -0
  255. package/dist/utils/metadata-flatten.js +18 -0
  256. package/dist/utils/metadata-flatten.js.map +1 -0
  257. package/dist/utils/pr.d.ts +7 -0
  258. package/dist/utils/pr.d.ts.map +1 -0
  259. package/dist/utils/pr.js +97 -0
  260. package/dist/utils/pr.js.map +1 -0
  261. package/dist/utils/session-from-metadata.d.ts +16 -0
  262. package/dist/utils/session-from-metadata.d.ts.map +1 -0
  263. package/dist/utils/session-from-metadata.js +87 -0
  264. package/dist/utils/session-from-metadata.js.map +1 -0
  265. package/dist/utils/session-id.d.ts +4 -0
  266. package/dist/utils/session-id.d.ts.map +1 -0
  267. package/dist/utils/session-id.js +9 -0
  268. package/dist/utils/session-id.js.map +1 -0
  269. package/dist/utils/validation.d.ts +9 -0
  270. package/dist/utils/validation.d.ts.map +1 -0
  271. package/dist/utils/validation.js +45 -0
  272. package/dist/utils/validation.js.map +1 -0
  273. package/dist/utils.d.ts +65 -0
  274. package/dist/utils.d.ts.map +1 -0
  275. package/dist/utils.js +189 -0
  276. package/dist/utils.js.map +1 -0
  277. package/dist/version-compare.d.ts +27 -0
  278. package/dist/version-compare.d.ts.map +1 -0
  279. package/dist/version-compare.js +121 -0
  280. package/dist/version-compare.js.map +1 -0
  281. package/dist/windows-pty-registry.d.ts +27 -0
  282. package/dist/windows-pty-registry.d.ts.map +1 -0
  283. package/dist/windows-pty-registry.js +109 -0
  284. package/dist/windows-pty-registry.js.map +1 -0
  285. package/package.json +110 -0
@@ -0,0 +1,3077 @@
1
+ import { existsSync, statSync, mkdirSync, writeFileSync, unlinkSync, utimesSync } from 'node:fs';
2
+ import { recordActivityEvent } from './activity-events.js';
3
+ import { execFile } from 'node:child_process';
4
+ import { join, resolve, basename } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import { promisify } from 'node:util';
7
+ import { PR_STATE, isRestorable, SessionNotFoundError, SessionNotRestorableError, NON_RESTORABLE_STATUSES, WorkspaceMissingError, isTerminalSession, isIssueNotFoundError } from './types.js';
8
+ import { listMetadata, readMetadataRaw, updateMetadata, mutateMetadata, deleteMetadata, writeMetadata, applyMetadataUpdates, reserveSessionId } from './metadata.js';
9
+ import { deriveLegacyStatus, parseCanonicalLifecycle, cloneLifecycle, buildLifecycleMetadataPatch, clearTerminalMarkersForNonTerminalState, createInitialCanonicalLifecycle } from './lifecycle-state.js';
10
+ import { buildPrompt } from './prompt-builder.js';
11
+ import { createActivitySignal, classifyActivitySignal } from './activity-signal.js';
12
+ import { getProjectSessionsDir, getProjectWorktreesDir, getProjectDir, generateSessionName } from './paths.js';
13
+ import { asValidOpenCodeSessionId } from './opencode-session-id.js';
14
+ import { getOpenCodeChildEnv, invalidateOpenCodeSessionListCache, getCachedOpenCodeSessionList } from './opencode-shared.js';
15
+ import { writeWorkspaceOpenCodeAgentsMd } from './opencode-agents-md.js';
16
+ import { writeOpenCodeConfig } from './opencode-config.js';
17
+ import { CleanupStack } from './cleanup-stack.js';
18
+ import { getOrchestratorSessionId, normalizeOrchestratorSessionStrategy } from './orchestrator-session-strategy.js';
19
+ import { sessionFromMetadata } from './utils/session-from-metadata.js';
20
+ import { dedupePrUrls } from './utils/pr.js';
21
+ import { safeJsonParse, validateStatus } from './utils/validation.js';
22
+ import { isGitBranchNameSafe } from './utils.js';
23
+ import { resolveAgentSelectionForSession, resolveAgentSelection } from './agent-selection.js';
24
+ import { PREFERRED_GH_PATH, buildAgentPath, setupPathWrapperWorkspace } from './agent-workspace-hooks.js';
25
+
26
+ /**
27
+ * Session Manager — CRUD for agent sessions.
28
+ *
29
+ * Orchestrates Runtime, Agent, and Workspace plugins to:
30
+ * - Spawn new sessions (create workspace → create runtime → launch agent)
31
+ * - List sessions (from metadata + live runtime checks)
32
+ * - Kill sessions (agent → runtime → workspace cleanup)
33
+ * - Cleanup completed sessions (PR merged / issue closed)
34
+ * - Send messages to running sessions
35
+ *
36
+ * Reference: scripts/claude-ao-session, scripts/send-to-session
37
+ */
38
+ const execFileAsync = promisify(execFile);
39
+ const OPENCODE_DISCOVERY_TIMEOUT_MS = 10_000;
40
+ const OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS = 10_000;
41
+ const INDEXED_PR_METADATA_KEY_REGEX = /^(prEnrichment|prReviewComments)_\d+$/;
42
+ // On Windows, execFile cannot resolve .cmd shim extensions without invoking the shell.
43
+ // windowsHide:true suppresses the conhost popup that the shell would otherwise flash.
44
+ const EXEC_SHELL_OPTION = process.platform === "win32" ? { shell: true, windowsHide: true } : {};
45
+ function errorIncludesSessionNotFound(err) {
46
+ if (!(err instanceof Error))
47
+ return false;
48
+ const e = err;
49
+ const combined = [err.message, e.stderr, e.stdout].filter(Boolean).join("\n");
50
+ return /session not found/i.test(combined);
51
+ }
52
+ async function deleteOpenCodeSession(sessionId) {
53
+ const validatedSessionId = asValidOpenCodeSessionId(sessionId);
54
+ if (!validatedSessionId)
55
+ return;
56
+ const retryDelaysMs = [0, 200, 600];
57
+ let lastError;
58
+ for (const delayMs of retryDelaysMs) {
59
+ if (delayMs > 0) {
60
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
61
+ }
62
+ try {
63
+ await execFileAsync("opencode", ["session", "delete", validatedSessionId], {
64
+ timeout: 30_000,
65
+ ...EXEC_SHELL_OPTION,
66
+ env: getOpenCodeChildEnv(),
67
+ });
68
+ // Drop cached list immediately so reuse / remap / restore call sites
69
+ // do not observe the deleted id for the remainder of the TTL window.
70
+ invalidateOpenCodeSessionListCache();
71
+ return;
72
+ }
73
+ catch (err) {
74
+ if (errorIncludesSessionNotFound(err)) {
75
+ invalidateOpenCodeSessionListCache();
76
+ return;
77
+ }
78
+ lastError = err;
79
+ }
80
+ }
81
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
82
+ }
83
+ async function fetchOpenCodeSessionList(timeoutMs = OPENCODE_DISCOVERY_TIMEOUT_MS) {
84
+ return getCachedOpenCodeSessionList({ timeoutMs });
85
+ }
86
+ async function discoverOpenCodeSessionIdsByTitle(sessionId, timeoutMs = OPENCODE_DISCOVERY_TIMEOUT_MS, sessionListPromise) {
87
+ const sessions = await (sessionListPromise ?? fetchOpenCodeSessionList(timeoutMs));
88
+ const title = `AO:${sessionId}`;
89
+ return sessions
90
+ .filter((entry) => entry.title === title)
91
+ .sort((a, b) => {
92
+ const ta = a.updatedAt ?? -Infinity;
93
+ const tb = b.updatedAt ?? -Infinity;
94
+ if (ta === tb)
95
+ return 0;
96
+ return tb - ta;
97
+ })
98
+ .map((entry) => entry.id);
99
+ }
100
+ async function discoverOpenCodeSessionIdByTitle(sessionId, timeoutMs, sessionListPromise) {
101
+ const matches = await discoverOpenCodeSessionIdsByTitle(sessionId, timeoutMs, sessionListPromise);
102
+ return matches[0];
103
+ }
104
+ /** Escape regex metacharacters in a string. */
105
+ function escapeRegex(str) {
106
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
107
+ }
108
+ /** Get the next session number for a project. */
109
+ function getNextSessionNumber(existingSessions, prefix) {
110
+ let max = 0;
111
+ const pattern = new RegExp(`^${escapeRegex(prefix)}-(\\d+)$`);
112
+ for (const name of existingSessions) {
113
+ const match = name.match(pattern);
114
+ if (match) {
115
+ const num = parseInt(match[1], 10);
116
+ if (num > max)
117
+ max = num;
118
+ }
119
+ }
120
+ return max + 1;
121
+ }
122
+ function getSessionNumber(sessionId, prefix) {
123
+ const match = sessionId.match(new RegExp(`^${escapeRegex(prefix)}-(\\d+)$`));
124
+ if (!match)
125
+ return undefined;
126
+ const parsed = Number.parseInt(match[1], 10);
127
+ return Number.isNaN(parsed) ? undefined : parsed;
128
+ }
129
+ const PR_TRACKING_STATUSES = new Set([
130
+ "pr_open",
131
+ "ci_failed",
132
+ "review_pending",
133
+ "changes_requested",
134
+ "approved",
135
+ "mergeable",
136
+ ]);
137
+ const STALE_PR_OWNERSHIP_STATUSES = new Set([
138
+ ...PR_TRACKING_STATUSES,
139
+ "merged",
140
+ ]);
141
+ /**
142
+ * Maximum length for the `displayName` metadata field.
143
+ * Long enough to express intent ("Refactor session manager to use flat metadata files")
144
+ * without overflowing kanban cards and tabs in the dashboard.
145
+ */
146
+ const DISPLAY_NAME_MAX_LENGTH = 80;
147
+ /**
148
+ * Derive a human-readable display name from any available task context.
149
+ *
150
+ * Priority:
151
+ * 1. Issue title (always the best signal when present)
152
+ * 2. First meaningful line of a freeform prompt
153
+ *
154
+ * The result is trimmed, collapsed to single-line, and truncated to
155
+ * {@link DISPLAY_NAME_MAX_LENGTH} characters (with an ellipsis).
156
+ * Returns `undefined` when no usable context exists so callers can skip
157
+ * writing the field entirely.
158
+ */
159
+ function deriveDisplayName(input) {
160
+ const pickLine = (text) => {
161
+ const line = text
162
+ .split(/\r?\n/)
163
+ .map((l) => l.trim())
164
+ .find((l) => l.length > 0);
165
+ return line ?? "";
166
+ };
167
+ const truncate = (text) => {
168
+ const collapsed = text.replace(/\s+/g, " ").trim();
169
+ // Split on code points so emoji / astral characters aren't cleaved into
170
+ // lone UTF-16 surrogates at the truncation boundary.
171
+ const codePoints = Array.from(collapsed);
172
+ if (codePoints.length <= DISPLAY_NAME_MAX_LENGTH)
173
+ return collapsed;
174
+ // Leave room for the ellipsis character.
175
+ return `${codePoints
176
+ .slice(0, DISPLAY_NAME_MAX_LENGTH - 1)
177
+ .join("")
178
+ .trimEnd()}…`;
179
+ };
180
+ if (input.issueTitle && input.issueTitle.trim()) {
181
+ return truncate(input.issueTitle);
182
+ }
183
+ if (input.prompt && input.prompt.trim()) {
184
+ const line = pickLine(input.prompt).replace(/^#{1,6}\s+/, "");
185
+ if (line)
186
+ return truncate(line);
187
+ }
188
+ return undefined;
189
+ }
190
+ const SEND_RESTORE_READY_TIMEOUT_MS = 5_000;
191
+ const SEND_RESTORE_READY_POLL_MS = 500;
192
+ const SEND_CONFIRMATION_ATTEMPTS = 6;
193
+ const SEND_CONFIRMATION_POLL_MS = 500;
194
+ const SEND_CONFIRMATION_OUTPUT_LINES = 20;
195
+ const SEND_BOOTSTRAP_READY_TIMEOUT_MS = 20_000;
196
+ const SEND_BOOTSTRAP_STABLE_POLLS = 2;
197
+ const ENSURE_ORCHESTRATOR_CONFLICT_WAIT_MS = 20_000;
198
+ const ENSURE_ORCHESTRATOR_CONFLICT_POLL_MS = 250;
199
+ function sleep(ms) {
200
+ return new Promise((resolve) => setTimeout(resolve, ms));
201
+ }
202
+ async function isAgentProcessNotDefinitelyMissing(agent, handle) {
203
+ try {
204
+ return (await agent.isProcessRunning(handle)) !== false;
205
+ }
206
+ catch {
207
+ // Send/restore readiness should only block on a definitive "process missing"
208
+ // verdict. Probe failures are no verdict, so keep waiting or fall back to
209
+ // terminal output instead of forcing a restore.
210
+ return true;
211
+ }
212
+ }
213
+ function isFixedOrchestratorReservationError(err, sessionId) {
214
+ return err instanceof Error && err.message.includes(`Orchestrator session "${sessionId}" already exists`);
215
+ }
216
+ async function getTmuxForegroundCommand(sessionName) {
217
+ try {
218
+ const { stdout } = await execFileAsync("tmux", ["display-message", "-p", "-t", sessionName, "#{pane_current_command}"], { timeout: 5_000, windowsHide: true });
219
+ const command = stdout.trim();
220
+ return command.length > 0 ? command : null;
221
+ }
222
+ catch {
223
+ return null;
224
+ }
225
+ }
226
+ /** Parse lifecycle from raw metadata for writeMetadata (restore path). */
227
+ function parseLifecycleFromRaw(raw) {
228
+ const source = raw["lifecycle"] ?? raw["statePayload"];
229
+ if (!source)
230
+ return undefined;
231
+ try {
232
+ return JSON.parse(source);
233
+ }
234
+ catch {
235
+ return undefined;
236
+ }
237
+ }
238
+ /** Reconstruct a Session object from raw metadata key=value pairs. */
239
+ function metadataToSession(sessionId, meta, options) {
240
+ const sessionKind = meta["role"] === "orchestrator" ||
241
+ (options.sessionPrefix
242
+ ? new RegExp(`^${escapeRegex(options.sessionPrefix)}-orchestrator-\\d+$`).test(sessionId)
243
+ : false)
244
+ ? "orchestrator"
245
+ : "worker";
246
+ return sessionFromMetadata(sessionId, meta, {
247
+ projectId: options.projectId,
248
+ workspacePathFallback: options.workspacePathFallback,
249
+ sessionKind,
250
+ createdAt: options.createdAt,
251
+ lastActivityAt: options.modifiedAt ?? new Date(),
252
+ });
253
+ }
254
+ /** Create a SessionManager instance. */
255
+ function createSessionManager(deps) {
256
+ const { config, registry } = deps;
257
+ function normalizePath(path) {
258
+ return resolve(path).replace(/[/\\]$/, "");
259
+ }
260
+ function isPathInside(path, parentPath) {
261
+ const normalizedPath = normalizePath(path);
262
+ const normalizedParent = normalizePath(parentPath);
263
+ const sep = process.platform === "win32" ? "\\" : "/";
264
+ return (normalizedPath === normalizedParent || normalizedPath.startsWith(`${normalizedParent}${sep}`));
265
+ }
266
+ function getManagedWorkspaceRoots(projectId, projectPath) {
267
+ const roots = [getProjectWorktreesDir(projectId)];
268
+ // Legacy: some worktrees live under ~/.worktrees/{basename}
269
+ const legacyIds = new Set();
270
+ legacyIds.add(projectId);
271
+ legacyIds.add(basename(projectPath));
272
+ for (const id of legacyIds) {
273
+ roots.push(join(homedir(), ".worktrees", id));
274
+ }
275
+ return roots;
276
+ }
277
+ function shouldDestroyWorkspacePath(project, projectId, workspacePath) {
278
+ if (!project || !projectId)
279
+ return false;
280
+ if (normalizePath(workspacePath) === normalizePath(project.path))
281
+ return false;
282
+ const roots = getManagedWorkspaceRoots(projectId, project.path);
283
+ return roots.some((root) => isPathInside(workspacePath, root));
284
+ }
285
+ function isOrchestratorSessionRecord(sessionId, raw, sessionPrefix) {
286
+ if (!raw)
287
+ return false;
288
+ if (raw["role"] === "orchestrator")
289
+ return true;
290
+ // Check the -orchestrator-N pattern only when the prefix is known so the
291
+ // regex is anchored to the project prefix, preventing false-positives when
292
+ // the user-configured sessionPrefix itself ends with "-orchestrator".
293
+ if (sessionPrefix) {
294
+ if (sessionId === `${sessionPrefix}-orchestrator`) {
295
+ return true;
296
+ }
297
+ return new RegExp(`^${escapeRegex(sessionPrefix)}-orchestrator-\\d+$`).test(sessionId);
298
+ }
299
+ return false;
300
+ }
301
+ function isCleanupProtectedSession(project, sessionId, metadata) {
302
+ if (sessionId === `${project.sessionPrefix}-orchestrator`) {
303
+ return true;
304
+ }
305
+ return isOrchestratorSessionRecord(sessionId, metadata ?? {}, project.sessionPrefix);
306
+ }
307
+ function applyMetadataUpdatesToRaw(raw, updates) {
308
+ let next = { ...raw };
309
+ for (const [key, value] of Object.entries(updates)) {
310
+ if (value === undefined)
311
+ continue;
312
+ if (value === "") {
313
+ const { [key]: _removed, ...rest } = next;
314
+ next = rest;
315
+ continue;
316
+ }
317
+ next[key] = value;
318
+ }
319
+ return next;
320
+ }
321
+ function buildUpdatedLifecycle(sessionId, raw, updater) {
322
+ const lifecycle = cloneLifecycle(parseCanonicalLifecycle(raw, {
323
+ sessionId,
324
+ status: validateStatus(raw["status"]),
325
+ }));
326
+ updater(lifecycle);
327
+ clearTerminalMarkersForNonTerminalState(lifecycle);
328
+ return lifecycle;
329
+ }
330
+ function lifecycleMetadataUpdates(raw, lifecycle) {
331
+ return buildLifecycleMetadataPatch(lifecycle);
332
+ }
333
+ function updateMetadataPreservingMtime(sessionsDir, sessionName, updates, modifiedAt) {
334
+ const metaPath = join(sessionsDir, `${sessionName}.json`);
335
+ let preservedMtime = modifiedAt;
336
+ if (!preservedMtime) {
337
+ try {
338
+ preservedMtime = statSync(metaPath).mtime;
339
+ }
340
+ catch {
341
+ preservedMtime = undefined;
342
+ }
343
+ }
344
+ updateMetadata(sessionsDir, sessionName, updates);
345
+ if (!preservedMtime)
346
+ return;
347
+ try {
348
+ utimesSync(metaPath, preservedMtime, preservedMtime);
349
+ }
350
+ catch {
351
+ }
352
+ }
353
+ const SESSION_CACHE_TTL_MS = 35_000;
354
+ let sessionCache = null;
355
+ const ensureOrchestratorPromises = new Map();
356
+ const relaunchOrchestratorPromises = new Map();
357
+ function invalidateCache() {
358
+ sessionCache = null;
359
+ }
360
+ function deduplicatePRStorageOnStartup() {
361
+ let migrated = false;
362
+ for (const [projectId] of Object.entries(config.projects)) {
363
+ const sessionsDir = getProjectSessionsDir(projectId);
364
+ if (!existsSync(sessionsDir))
365
+ continue;
366
+ for (const sessionName of listMetadata(sessionsDir)) {
367
+ const raw = readMetadataRaw(sessionsDir, sessionName);
368
+ if (!raw)
369
+ continue;
370
+ const rawPrUrls = raw["prs"]
371
+ ? raw["prs"].split(",").map((url) => url.trim()).filter(Boolean)
372
+ : [];
373
+ const uniquePrUrls = dedupePrUrls(rawPrUrls);
374
+ const updates = {};
375
+ if (rawPrUrls.length !== uniquePrUrls.length) {
376
+ updates["prs"] = uniquePrUrls.join(",");
377
+ }
378
+ let deletedIndexedKeyCount = 0;
379
+ for (const key of Object.keys(raw)) {
380
+ if (!INDEXED_PR_METADATA_KEY_REGEX.test(key))
381
+ continue;
382
+ updates[key] = "";
383
+ deletedIndexedKeyCount += 1;
384
+ }
385
+ if (Object.keys(updates).length === 0)
386
+ continue;
387
+ updateMetadata(sessionsDir, sessionName, updates);
388
+ migrated = true;
389
+ recordActivityEvent({
390
+ projectId,
391
+ sessionId: sessionName,
392
+ source: "session-manager",
393
+ kind: "metadata.deduplicated",
394
+ summary: `deduplicated PR metadata: ${sessionName}`,
395
+ data: {
396
+ beforePrCount: rawPrUrls.length,
397
+ afterPrCount: uniquePrUrls.length,
398
+ deletedIndexedKeyCount,
399
+ },
400
+ });
401
+ }
402
+ }
403
+ if (migrated)
404
+ invalidateCache();
405
+ }
406
+ deduplicatePRStorageOnStartup();
407
+ function repairSessionAgentMetadataOnRead(sessionsDir, record, project) {
408
+ if (record.raw["agent"])
409
+ return record;
410
+ const agent = resolveSelectionForSession(project, record.sessionName, record.raw).agentName;
411
+ updateMetadataPreservingMtime(sessionsDir, record.sessionName, { agent }, record.modifiedAt);
412
+ return {
413
+ ...record,
414
+ raw: applyMetadataUpdatesToRaw(record.raw, { agent }),
415
+ };
416
+ }
417
+ function repairSingleSessionMetadataOnRead(sessionsDir, record, sessionPrefix) {
418
+ const repaired = { ...record, raw: { ...record.raw } };
419
+ if (!isOrchestratorSessionRecord(repaired.sessionName, repaired.raw, sessionPrefix)) {
420
+ return repaired;
421
+ }
422
+ const updates = {};
423
+ if (repaired.raw["role"] !== "orchestrator") {
424
+ updates["role"] = "orchestrator";
425
+ }
426
+ if (repaired.raw["pr"]) {
427
+ updates["pr"] = "";
428
+ }
429
+ if (repaired.raw["prAutoDetect"] !== "off" && repaired.raw["prAutoDetect"] !== "false") {
430
+ updates["prAutoDetect"] = "false";
431
+ }
432
+ if (STALE_PR_OWNERSHIP_STATUSES.has(repaired.raw["status"] ?? "")) {
433
+ updates["status"] = "working";
434
+ }
435
+ if (Object.keys(updates).length > 0) {
436
+ const lifecycle = buildUpdatedLifecycle(repaired.sessionName, repaired.raw, (next) => {
437
+ next.session.kind = "orchestrator";
438
+ next.pr.state = "none";
439
+ next.pr.reason = "not_created";
440
+ next.pr.number = null;
441
+ next.pr.url = null;
442
+ next.pr.lastObservedAt = null;
443
+ if (updates["status"] === "working") {
444
+ next.session.state = "working";
445
+ next.session.reason = "task_in_progress";
446
+ }
447
+ });
448
+ updateMetadataPreservingMtime(sessionsDir, repaired.sessionName, { ...updates, ...lifecycleMetadataUpdates(repaired.raw, lifecycle) }, repaired.modifiedAt);
449
+ repaired.raw = applyMetadataUpdatesToRaw(repaired.raw, {
450
+ ...updates,
451
+ ...lifecycleMetadataUpdates(repaired.raw, lifecycle),
452
+ });
453
+ }
454
+ return repaired;
455
+ }
456
+ function sessionMetadataTimestamp(record) {
457
+ const metadataTimestamp = Date.parse(record.raw["restoredAt"] ?? record.raw["createdAt"] ?? "");
458
+ if (record.modifiedAt)
459
+ return record.modifiedAt.getTime();
460
+ return Number.isNaN(metadataTimestamp) ? 0 : metadataTimestamp;
461
+ }
462
+ function repairSessionMetadataOnRead(sessionsDir, records, project) {
463
+ const repaired = records.map((record) => ({ ...record, raw: { ...record.raw } }));
464
+ const duplicatePRAttachments = new Map();
465
+ for (const record of repaired) {
466
+ if (!record.raw["lifecycle"] && (!record.raw["statePayload"] || record.raw["stateVersion"] !== "2")) {
467
+ const lifecycle = cloneLifecycle(parseCanonicalLifecycle(record.raw, {
468
+ sessionId: record.sessionName,
469
+ status: validateStatus(record.raw["status"]),
470
+ createdAt: record.raw["createdAt"] ? new Date(record.raw["createdAt"]) : undefined,
471
+ sessionKind: isOrchestratorSessionRecord(record.sessionName, record.raw, project.sessionPrefix)
472
+ ? "orchestrator"
473
+ : "worker",
474
+ }));
475
+ const canonicalUpdates = lifecycleMetadataUpdates(record.raw, lifecycle);
476
+ updateMetadataPreservingMtime(sessionsDir, record.sessionName, canonicalUpdates, record.modifiedAt);
477
+ record.raw = applyMetadataUpdatesToRaw(record.raw, canonicalUpdates);
478
+ }
479
+ if (isOrchestratorSessionRecord(record.sessionName, record.raw, project.sessionPrefix)) {
480
+ record.raw = repairSingleSessionMetadataOnRead(sessionsDir, record, project.sessionPrefix).raw;
481
+ record.raw = repairSessionAgentMetadataOnRead(sessionsDir, record, project).raw;
482
+ continue;
483
+ }
484
+ record.raw = repairSessionAgentMetadataOnRead(sessionsDir, record, project).raw;
485
+ const prUrl = record.raw["pr"];
486
+ if (!prUrl)
487
+ continue;
488
+ const attached = duplicatePRAttachments.get(prUrl) ?? [];
489
+ attached.push(record);
490
+ duplicatePRAttachments.set(prUrl, attached);
491
+ }
492
+ for (const attachedRecords of duplicatePRAttachments.values()) {
493
+ if (attachedRecords.length < 2)
494
+ continue;
495
+ const [owner, ...staleRecords] = [...attachedRecords].sort((a, b) => {
496
+ const trackingDiff = Number(PR_TRACKING_STATUSES.has(b.raw["status"] ?? "")) -
497
+ Number(PR_TRACKING_STATUSES.has(a.raw["status"] ?? ""));
498
+ if (trackingDiff !== 0)
499
+ return trackingDiff;
500
+ const timestampDiff = sessionMetadataTimestamp(b) - sessionMetadataTimestamp(a);
501
+ if (timestampDiff !== 0)
502
+ return timestampDiff;
503
+ return b.sessionName.localeCompare(a.sessionName);
504
+ });
505
+ for (const record of staleRecords) {
506
+ const updates = {
507
+ pr: "",
508
+ prAutoDetect: "false",
509
+ ...(PR_TRACKING_STATUSES.has(record.raw["status"] ?? "") ? { status: "working" } : {}),
510
+ };
511
+ const lifecycle = buildUpdatedLifecycle(record.sessionName, record.raw, (next) => {
512
+ next.pr.state = "none";
513
+ next.pr.reason = "not_created";
514
+ next.pr.number = null;
515
+ next.pr.url = null;
516
+ next.pr.lastObservedAt = null;
517
+ if (updates["status"] === "working") {
518
+ next.session.state = "working";
519
+ next.session.reason = "task_in_progress";
520
+ }
521
+ });
522
+ const lifecycleUpdates = lifecycleMetadataUpdates(record.raw, lifecycle);
523
+ updateMetadataPreservingMtime(sessionsDir, record.sessionName, { ...updates, ...lifecycleUpdates }, record.modifiedAt);
524
+ record.raw = applyMetadataUpdatesToRaw(record.raw, { ...updates, ...lifecycleUpdates });
525
+ }
526
+ }
527
+ return repaired;
528
+ }
529
+ function loadActiveSessionRecords(projectId, project) {
530
+ const sessionsDir = getProjectSessionsDir(projectId);
531
+ if (!existsSync(sessionsDir))
532
+ return [];
533
+ const records = listMetadata(sessionsDir).flatMap((sessionName) => {
534
+ const raw = readMetadataRaw(sessionsDir, sessionName);
535
+ if (!raw)
536
+ return [];
537
+ let modifiedAt;
538
+ try {
539
+ modifiedAt = statSync(join(sessionsDir, `${sessionName}.json`)).mtime;
540
+ }
541
+ catch {
542
+ }
543
+ return [{ sessionName, raw, modifiedAt }];
544
+ });
545
+ return repairSessionMetadataOnRead(sessionsDir, records, project);
546
+ }
547
+ function sortSessionIdsForReuse(ids) {
548
+ const numericSuffix = (id) => {
549
+ const match = id.match(/-(\d+)$/);
550
+ if (!match)
551
+ return undefined;
552
+ const parsed = Number.parseInt(match[1], 10);
553
+ return Number.isNaN(parsed) ? undefined : parsed;
554
+ };
555
+ return [...ids].sort((a, b) => {
556
+ const aNum = numericSuffix(a);
557
+ const bNum = numericSuffix(b);
558
+ if (aNum !== undefined && bNum !== undefined && aNum !== bNum) {
559
+ return bNum - aNum;
560
+ }
561
+ if (aNum !== undefined && bNum === undefined)
562
+ return -1;
563
+ if (aNum === undefined && bNum !== undefined)
564
+ return 1;
565
+ return b.localeCompare(a);
566
+ });
567
+ }
568
+ function findOpenCodeSessionIds(sessionsDir, criteria) {
569
+ const matchesCriteria = (id, raw) => {
570
+ if (!raw)
571
+ return false;
572
+ if (raw["agent"] !== "opencode")
573
+ return false;
574
+ if (criteria.issueId !== undefined && raw["issue"] !== criteria.issueId)
575
+ return false;
576
+ if (criteria.sessionId !== undefined && id !== criteria.sessionId)
577
+ return false;
578
+ return true;
579
+ };
580
+ const ids = [];
581
+ const maybeAdd = (id, raw) => {
582
+ if (!matchesCriteria(id, raw))
583
+ return;
584
+ const mapped = asValidOpenCodeSessionId(raw?.["opencodeSessionId"]);
585
+ if (!mapped)
586
+ return;
587
+ ids.push(mapped);
588
+ };
589
+ for (const id of sortSessionIdsForReuse(listMetadata(sessionsDir))) {
590
+ maybeAdd(id, readMetadataRaw(sessionsDir, id));
591
+ }
592
+ return [...new Set(ids)];
593
+ }
594
+ async function resolveOpenCodeSessionReuse(options) {
595
+ const { sessionsDir, criteria, strategy, includeTitleDiscoveryForSessionId = false } = options;
596
+ if (strategy === "ignore")
597
+ return undefined;
598
+ let candidateIds = findOpenCodeSessionIds(sessionsDir, criteria);
599
+ if (strategy === "delete") {
600
+ if (includeTitleDiscoveryForSessionId && criteria.sessionId) {
601
+ candidateIds = [
602
+ ...candidateIds,
603
+ ...(await discoverOpenCodeSessionIdsByTitle(criteria.sessionId)),
604
+ ];
605
+ }
606
+ for (const openCodeSessionId of [...new Set(candidateIds)]) {
607
+ await deleteOpenCodeSession(openCodeSessionId);
608
+ }
609
+ return undefined;
610
+ }
611
+ if (candidateIds.length === 0 && criteria.sessionId) {
612
+ candidateIds = await discoverOpenCodeSessionIdsByTitle(criteria.sessionId);
613
+ }
614
+ return candidateIds[0];
615
+ }
616
+ async function listRemoteSessionNumbers(project) {
617
+ try {
618
+ const { stdout } = await execFileAsync("git", ["ls-remote", "--heads", "origin", `session/${project.sessionPrefix}-*`], {
619
+ cwd: project.path,
620
+ timeout: 5_000,
621
+ ...EXEC_SHELL_OPTION,
622
+ });
623
+ return stdout
624
+ .split("\n")
625
+ .flatMap((line) => {
626
+ const trimmed = line.trim();
627
+ if (!trimmed)
628
+ return [];
629
+ const ref = trimmed.split(/\s+/)[1] ?? "";
630
+ const match = ref.match(new RegExp(`refs/heads/session/${escapeRegex(project.sessionPrefix)}-(\\d+)$`));
631
+ if (!match)
632
+ return [];
633
+ const parsed = Number.parseInt(match[1], 10);
634
+ return Number.isNaN(parsed) ? [] : [parsed];
635
+ })
636
+ .filter((num, index, values) => values.indexOf(num) === index);
637
+ }
638
+ catch {
639
+ return [];
640
+ }
641
+ }
642
+ async function reserveNextSessionIdentity(project, sessionsDir) {
643
+ const usedNumbers = new Set();
644
+ for (const sessionName of listMetadata(sessionsDir)) {
645
+ const num = getSessionNumber(sessionName, project.sessionPrefix);
646
+ if (num !== undefined)
647
+ usedNumbers.add(num);
648
+ }
649
+ for (const num of await listRemoteSessionNumbers(project)) {
650
+ usedNumbers.add(num);
651
+ }
652
+ let num = getNextSessionNumber([...usedNumbers].map((value) => `${project.sessionPrefix}-${value}`), project.sessionPrefix);
653
+ for (let attempts = 0; attempts < 10_000; attempts++) {
654
+ const sessionId = `${project.sessionPrefix}-${num}`;
655
+ const tmuxName = project.path
656
+ ? generateSessionName(project.sessionPrefix, num)
657
+ : undefined;
658
+ if (!usedNumbers.has(num) && reserveSessionId(sessionsDir, sessionId)) {
659
+ return { num, sessionId, tmuxName };
660
+ }
661
+ usedNumbers.add(num);
662
+ num += 1;
663
+ }
664
+ throw new Error(`Failed to reserve session ID after 10000 attempts (prefix: ${project.sessionPrefix})`);
665
+ }
666
+ function reserveFixedOrchestratorIdentity(project, sessionsDir) {
667
+ const sessionId = getOrchestratorSessionId(project);
668
+ if (!reserveSessionId(sessionsDir, sessionId)) {
669
+ throw new Error(`Orchestrator session "${sessionId}" already exists. Use ensureOrchestrator() to reuse or restore it.`);
670
+ }
671
+ return {
672
+ sessionId,
673
+ tmuxName: config.configPath ? sessionId : undefined,
674
+ };
675
+ }
676
+ /** Resolve which plugins to use for a project. */
677
+ function resolvePlugins(project, agentName) {
678
+ const runtime = registry.get("runtime", project.runtime ?? config.defaults.runtime);
679
+ const agent = registry.get("agent", agentName ?? project.agent ?? config.defaults.agent);
680
+ const workspace = registry.get("workspace", project.workspace ?? config.defaults.workspace);
681
+ // After config validation, plugin is always set if tracker/scm exists
682
+ // (either from user config or auto-generated from package/path)
683
+ const tracker = project.tracker?.plugin
684
+ ? registry.get("tracker", project.tracker.plugin)
685
+ : null;
686
+ const scm = project.scm?.plugin ? registry.get("scm", project.scm.plugin) : null;
687
+ return { runtime, agent, workspace, tracker, scm };
688
+ }
689
+ function resolveSelectionForSession(project, sessionId, metadata) {
690
+ return resolveAgentSelectionForSession({
691
+ sessionId,
692
+ metadata,
693
+ project,
694
+ defaults: config.defaults,
695
+ allSessionPrefixes: Object.values(config.projects).map((p) => p.sessionPrefix),
696
+ });
697
+ }
698
+ async function ensureOpenCodeSessionMapping(session, sessionName, sessionsDir, effectiveAgentName, sessionListPromise) {
699
+ if (effectiveAgentName !== "opencode")
700
+ return;
701
+ if (asValidOpenCodeSessionId(session.metadata["opencodeSessionId"]))
702
+ return;
703
+ const discovered = await discoverOpenCodeSessionIdByTitle(sessionName, OPENCODE_DISCOVERY_TIMEOUT_MS, sessionListPromise);
704
+ if (!discovered)
705
+ return;
706
+ session.metadata["opencodeSessionId"] = discovered;
707
+ updateMetadata(sessionsDir, sessionName, { opencodeSessionId: discovered });
708
+ }
709
+ function findSessionRecord(sessionId) {
710
+ for (const [projectId, project] of Object.entries(config.projects)) {
711
+ const sessionsDir = getProjectSessionsDir(projectId);
712
+ const raw = readMetadataRaw(sessionsDir, sessionId);
713
+ if (!raw)
714
+ continue;
715
+ let modifiedAt;
716
+ try {
717
+ modifiedAt = statSync(join(sessionsDir, `${sessionId}.json`)).mtime;
718
+ }
719
+ catch {
720
+ modifiedAt = undefined;
721
+ }
722
+ const repaired = repairSessionAgentMetadataOnRead(sessionsDir, repairSingleSessionMetadataOnRead(sessionsDir, { sessionName: sessionId, raw, modifiedAt }, project.sessionPrefix), project);
723
+ return { raw: repaired.raw, sessionsDir, project, projectId };
724
+ }
725
+ return null;
726
+ }
727
+ function requireSessionRecord(sessionId) {
728
+ const located = findSessionRecord(sessionId);
729
+ if (!located) {
730
+ throw new SessionNotFoundError(sessionId);
731
+ }
732
+ return located;
733
+ }
734
+ /**
735
+ * Ensure session has a runtime handle (fabricate one if missing) and enrich
736
+ * with live runtime state + activity detection. Used by both list() and get().
737
+ */
738
+ async function ensureHandleAndEnrich(session, sessionName, sessionsDir, project, effectiveAgentName, plugins, sessionListPromise) {
739
+ await ensureOpenCodeSessionMapping(session, sessionName, sessionsDir, effectiveAgentName, sessionListPromise);
740
+ const tmuxNameFromMetadata = session.metadata["tmuxName"]?.trim();
741
+ const hasTmuxNameFromMetadata = typeof tmuxNameFromMetadata === "string" && tmuxNameFromMetadata.length > 0;
742
+ const handleFromMetadata = session.runtimeHandle !== null || hasTmuxNameFromMetadata;
743
+ if (!handleFromMetadata) {
744
+ session.runtimeHandle = {
745
+ id: sessionName,
746
+ runtimeName: project.runtime ?? config.defaults.runtime,
747
+ data: {},
748
+ };
749
+ }
750
+ else if (!session.runtimeHandle && hasTmuxNameFromMetadata) {
751
+ session.runtimeHandle = {
752
+ id: tmuxNameFromMetadata,
753
+ runtimeName: project.runtime ?? config.defaults.runtime,
754
+ data: {},
755
+ };
756
+ }
757
+ await enrichSessionWithRuntimeState(session, plugins, handleFromMetadata, sessionsDir);
758
+ }
759
+ /**
760
+ * Enrich session with live runtime state (alive/exited) and activity detection.
761
+ * Mutates the session object in place.
762
+ */
763
+ const TERMINAL_SESSION_STATUSES = new Set(["killed", "done", "merged", "terminated", "cleanup"]);
764
+ function hasPersistedNativeRestoreMetadata(session, agent) {
765
+ const metadata = session.metadata ?? {};
766
+ switch (agent.name) {
767
+ case "claude-code":
768
+ return typeof metadata["claudeSessionUuid"] === "string" && metadata["claudeSessionUuid"].trim().length > 0;
769
+ case "codex":
770
+ return typeof metadata["codexThreadId"] === "string" && metadata["codexThreadId"].trim().length > 0;
771
+ case "opencode":
772
+ return asValidOpenCodeSessionId(metadata["opencodeSessionId"]) !== null;
773
+ default:
774
+ return false;
775
+ }
776
+ }
777
+ function canDiscoverSessionInfoAfterRuntimeExit(agent) {
778
+ return agent.name === "claude-code" || agent.name === "codex";
779
+ }
780
+ async function enrichSessionWithRuntimeState(session, plugins, handleFromMetadata, sessionsDir) {
781
+ async function persistAgentSessionInfo(options) {
782
+ if (!plugins.agent)
783
+ return;
784
+ if (options?.skipIfNativeRestoreMetadataPresent &&
785
+ hasPersistedNativeRestoreMetadata(session, plugins.agent)) {
786
+ return;
787
+ }
788
+ let info;
789
+ try {
790
+ info = await plugins.agent.getSessionInfo(session);
791
+ }
792
+ catch {
793
+ // Can't get session info — keep existing values
794
+ info = null;
795
+ }
796
+ if (!info)
797
+ return;
798
+ session.agentInfo = info;
799
+ const metadataUpdates = info.metadata ?? {};
800
+ const allAlreadyPersisted = Object.keys(metadataUpdates).every((key) => session.metadata?.[key] === metadataUpdates[key]);
801
+ if (allAlreadyPersisted)
802
+ return;
803
+ if (Object.keys(metadataUpdates).length > 0) {
804
+ try {
805
+ updateMetadata(sessionsDir, session.id, metadataUpdates);
806
+ session.metadata = applyMetadataUpdates(session.metadata, metadataUpdates);
807
+ invalidateCache();
808
+ }
809
+ catch {
810
+ // Persisting agent metadata is best-effort; keep live agent info.
811
+ }
812
+ }
813
+ }
814
+ // Check runtime liveness first — for all statuses except "spawning".
815
+ // Skip spawning sessions because tmux may not be fully initialized yet,
816
+ // and a false-negative from isAlive() would permanently mark the session
817
+ // as "killed" (see #1035).
818
+ // This also fixes #1081: terminal statuses (merged, done, etc.) should not
819
+ // force activity to "exited" if the agent process is still alive.
820
+ // Fabricated handles (constructed as fallback for external sessions) should
821
+ // NOT override status to "killed" — we don't know if the session ever had
822
+ // a tmux session, and we'd clobber meaningful statuses like "pr_open".
823
+ if (handleFromMetadata &&
824
+ session.runtimeHandle &&
825
+ plugins.runtime &&
826
+ session.status !== "spawning") {
827
+ try {
828
+ const alive = await plugins.runtime.isAlive(session.runtimeHandle);
829
+ if (!alive) {
830
+ session.lifecycle.runtime.state = "missing";
831
+ session.lifecycle.runtime.reason =
832
+ session.runtimeHandle.runtimeName === "tmux" ? "tmux_missing" : "process_missing";
833
+ session.lifecycle.runtime.lastObservedAt = new Date().toISOString();
834
+ if (session.lifecycle.session.state !== "done" &&
835
+ session.lifecycle.session.state !== "terminated") {
836
+ session.lifecycle.session.state = "detecting";
837
+ session.lifecycle.session.reason = "runtime_lost";
838
+ session.lifecycle.session.lastTransitionAt = new Date().toISOString();
839
+ }
840
+ // Process is confirmed dead — set activity to exited.
841
+ // Only update status to "killed" if not already in a terminal state.
842
+ if (!TERMINAL_SESSION_STATUSES.has(session.status)) {
843
+ session.status = "killed";
844
+ }
845
+ session.activity = "exited";
846
+ session.activitySignal = createActivitySignal("valid", {
847
+ activity: "exited",
848
+ source: "runtime",
849
+ });
850
+ // Dead-runtime session info discovery is intentionally limited to
851
+ // agents that recover restore metadata from persisted local files.
852
+ if (plugins.agent && canDiscoverSessionInfoAfterRuntimeExit(plugins.agent)) {
853
+ await persistAgentSessionInfo({ skipIfNativeRestoreMetadataPresent: true });
854
+ }
855
+ return;
856
+ }
857
+ }
858
+ catch {
859
+ // Can't check liveness — continue to activity detection
860
+ session.lifecycle.runtime.state = "probe_failed";
861
+ session.lifecycle.runtime.reason = "probe_error";
862
+ session.lifecycle.runtime.lastObservedAt = new Date().toISOString();
863
+ }
864
+ }
865
+ // Detect activity independently of runtime handle and session status.
866
+ // Activity detection reads JSONL files on disk — it only needs workspacePath,
867
+ // not a runtime handle. Gating on runtimeHandle caused sessions created by
868
+ // external scripts (which don't store runtimeHandle) to always show "unknown".
869
+ // This now runs for ALL sessions, including terminal statuses, so a merged
870
+ // session with a live agent shows accurate activity (ready/idle/waiting_input).
871
+ session.activitySignal = createActivitySignal("unavailable");
872
+ if (plugins.agent) {
873
+ try {
874
+ const detected = await plugins.agent.getActivityState(session, config.readyThresholdMs);
875
+ if (detected !== null) {
876
+ session.activitySignal = classifyActivitySignal(detected, "native");
877
+ session.activity = detected.state;
878
+ session.lifecycle.runtime.state = "alive";
879
+ session.lifecycle.runtime.reason = "process_running";
880
+ session.lifecycle.runtime.lastObservedAt = new Date().toISOString();
881
+ if (detected.timestamp && detected.timestamp > session.lastActivityAt) {
882
+ session.lastActivityAt = detected.timestamp;
883
+ }
884
+ }
885
+ else {
886
+ session.activitySignal = createActivitySignal("null", { source: "native" });
887
+ }
888
+ }
889
+ catch {
890
+ session.activitySignal = createActivitySignal("probe_failure", { source: "native" });
891
+ }
892
+ // Enrich with agent session info (summary, cost, native restore metadata).
893
+ await persistAgentSessionInfo();
894
+ }
895
+ }
896
+ // Define methods as local functions so `this` is not needed
897
+ async function spawn(spawnConfig) {
898
+ recordActivityEvent({
899
+ projectId: spawnConfig.projectId,
900
+ source: "session-manager",
901
+ kind: "session.spawn_started",
902
+ summary: "spawn started",
903
+ data: { agent: spawnConfig.agent ?? undefined },
904
+ });
905
+ try {
906
+ return await _spawnInner(spawnConfig);
907
+ }
908
+ catch (err) {
909
+ recordActivityEvent({
910
+ projectId: spawnConfig.projectId,
911
+ source: "session-manager",
912
+ kind: "session.spawn_failed",
913
+ level: "error",
914
+ summary: `spawn failed`,
915
+ data: { reason: err instanceof Error ? err.message : String(err) },
916
+ });
917
+ throw err;
918
+ }
919
+ }
920
+ async function _spawnInner(spawnConfig) {
921
+ const project = config.projects[spawnConfig.projectId];
922
+ if (!project) {
923
+ throw new Error(`Unknown project: ${spawnConfig.projectId}`);
924
+ }
925
+ const selection = resolveAgentSelection({
926
+ role: "worker",
927
+ project,
928
+ defaults: config.defaults,
929
+ spawnAgentOverride: spawnConfig.agent,
930
+ });
931
+ const plugins = resolvePlugins(project, selection.agentName);
932
+ if (!plugins.runtime) {
933
+ throw new Error(`Runtime plugin '${project.runtime ?? config.defaults.runtime}' not found`);
934
+ }
935
+ if (!plugins.agent) {
936
+ throw new Error(`Agent plugin '${selection.agentName}' not found`);
937
+ }
938
+ // Validate issue exists BEFORE creating any resources
939
+ let resolvedIssue;
940
+ if (spawnConfig.issueId && plugins.tracker) {
941
+ try {
942
+ // Fetch and validate the issue exists
943
+ resolvedIssue = await plugins.tracker.getIssue(spawnConfig.issueId, project);
944
+ }
945
+ catch (err) {
946
+ // Issue fetch failed - determine why
947
+ if (isIssueNotFoundError(err)) ;
948
+ else {
949
+ // Other error (auth, network, etc) - fail fast
950
+ recordActivityEvent({
951
+ projectId: spawnConfig.projectId,
952
+ source: "session-manager",
953
+ kind: "tracker.issue_fetch_failed",
954
+ level: "error",
955
+ summary: `tracker getIssue failed for ${spawnConfig.issueId}`,
956
+ data: {
957
+ issueId: spawnConfig.issueId,
958
+ tracker: plugins.tracker.name,
959
+ reason: err instanceof Error ? err.message : String(err),
960
+ },
961
+ });
962
+ throw new Error(`Failed to fetch issue ${spawnConfig.issueId}: ${err}`, { cause: err });
963
+ }
964
+ }
965
+ }
966
+ // Get the sessions directory for this project
967
+ const sessionsDir = getProjectSessionsDir(spawnConfig.projectId);
968
+ // CleanupStack: each side effect pushes its undo as soon as the resource
969
+ // exists. On any failure below we runAll() in LIFO order; on success we
970
+ // dismiss(). Replaces the previous nested rollback ladder — adding a new
971
+ // step now requires pushing one cleanup, with no risk of forgetting prior
972
+ // ones.
973
+ const cleanupStack = new CleanupStack();
974
+ let sessionId;
975
+ try {
976
+ // Determine session ID — atomically reserve to prevent concurrent collisions
977
+ let tmuxName;
978
+ ({ sessionId, tmuxName } = await reserveNextSessionIdentity(project, sessionsDir));
979
+ const reservedSessionId = sessionId;
980
+ cleanupStack.push(() => deleteMetadata(sessionsDir, reservedSessionId));
981
+ // Determine branch name — explicit branch always takes priority
982
+ let branch;
983
+ if (spawnConfig.branch) {
984
+ branch = spawnConfig.branch;
985
+ }
986
+ else if (spawnConfig.issueId && plugins.tracker && resolvedIssue) {
987
+ const fromIssue = resolvedIssue.branchName;
988
+ branch =
989
+ fromIssue && isGitBranchNameSafe(fromIssue)
990
+ ? fromIssue
991
+ : plugins.tracker.branchName(spawnConfig.issueId, project);
992
+ }
993
+ else if (spawnConfig.issueId) {
994
+ // If the issueId is already branch-safe (e.g. "INT-9999"), use as-is.
995
+ // Otherwise sanitize free-text (e.g. "fix login bug") into a valid slug.
996
+ const id = spawnConfig.issueId;
997
+ const isBranchSafe = /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id) && !id.includes("..");
998
+ const slug = isBranchSafe
999
+ ? id
1000
+ : id
1001
+ .toLowerCase()
1002
+ .replace(/[^a-z0-9]+/g, "-")
1003
+ .slice(0, 60)
1004
+ .replace(/^-+|-+$/g, "");
1005
+ branch = `feat/${slug || sessionId}`;
1006
+ }
1007
+ else {
1008
+ branch = `session/${sessionId}`;
1009
+ }
1010
+ // Create workspace (if workspace plugin is available)
1011
+ let workspacePath = project.path;
1012
+ if (plugins.workspace) {
1013
+ const wsInfo = await plugins.workspace.create({
1014
+ projectId: spawnConfig.projectId,
1015
+ project,
1016
+ sessionId,
1017
+ branch,
1018
+ worktreeDir: getProjectWorktreesDir(spawnConfig.projectId),
1019
+ });
1020
+ workspacePath = wsInfo.path;
1021
+ // Only register destroy when the path is inside a managed root —
1022
+ // matches the prior shouldDestroyWorkspacePath gate so we never
1023
+ // destroy a user-owned project directory.
1024
+ if (shouldDestroyWorkspacePath(project, spawnConfig.projectId, workspacePath)) {
1025
+ const ws = plugins.workspace;
1026
+ cleanupStack.push(() => ws.destroy(workspacePath));
1027
+ }
1028
+ if (plugins.workspace.postCreate) {
1029
+ await plugins.workspace.postCreate(wsInfo, project);
1030
+ }
1031
+ }
1032
+ // Generate prompt with validated issue
1033
+ let issueContext;
1034
+ if (spawnConfig.issueId && plugins.tracker && resolvedIssue) {
1035
+ try {
1036
+ issueContext = await plugins.tracker.generatePrompt(spawnConfig.issueId, project);
1037
+ }
1038
+ catch (err) {
1039
+ // Non-fatal: continue without detailed issue context. Surface the
1040
+ // failure via AE so RCA can answer "did the agent get an enriched
1041
+ // prompt or just the bare issue ID?"
1042
+ recordActivityEvent({
1043
+ projectId: spawnConfig.projectId,
1044
+ sessionId,
1045
+ source: "session-manager",
1046
+ kind: "tracker.generate_prompt_failed",
1047
+ level: "warn",
1048
+ summary: `tracker generatePrompt failed for ${spawnConfig.issueId}`,
1049
+ data: {
1050
+ issueId: spawnConfig.issueId,
1051
+ tracker: plugins.tracker.name,
1052
+ reason: err instanceof Error ? err.message : String(err),
1053
+ },
1054
+ });
1055
+ }
1056
+ }
1057
+ // If an orchestrator session exists on disk for this project, give the
1058
+ // worker the literal command to message it. Existence-on-disk is the
1059
+ // signal: if metadata was ever written for the canonical orchestrator
1060
+ // ID, the orchestrator workflow is in play here.
1061
+ const orchestratorSessionId = `${project.sessionPrefix}-orchestrator`;
1062
+ const orchestratorExists = readMetadataRaw(sessionsDir, orchestratorSessionId) !== null;
1063
+ const { systemPrompt, taskPrompt } = buildPrompt({
1064
+ project,
1065
+ projectId: spawnConfig.projectId,
1066
+ issueId: spawnConfig.issueId,
1067
+ issueContext,
1068
+ userPrompt: spawnConfig.prompt,
1069
+ ...(orchestratorExists && { orchestratorSessionId }),
1070
+ });
1071
+ const baseDir = getProjectDir(spawnConfig.projectId);
1072
+ mkdirSync(baseDir, { recursive: true });
1073
+ const systemPromptFile = join(baseDir, `worker-prompt-${sessionId}.md`);
1074
+ writeFileSync(systemPromptFile, systemPrompt, "utf-8");
1075
+ cleanupStack.push(() => unlinkSync(systemPromptFile));
1076
+ // need a seperate config file to pass instructions for opencode session
1077
+ let opencodeConfigFile;
1078
+ if (plugins.agent.name === "opencode") {
1079
+ opencodeConfigFile = writeOpenCodeConfig(baseDir, sessionId, [systemPromptFile]);
1080
+ const cfg = opencodeConfigFile;
1081
+ cleanupStack.push(() => unlinkSync(cfg));
1082
+ }
1083
+ // Get agent launch config and create runtime
1084
+ const opencodeIssueSessionStrategy = project.opencodeIssueSessionStrategy ?? "reuse";
1085
+ const reusedOpenCodeSessionId = plugins.agent.name === "opencode" && spawnConfig.issueId
1086
+ ? await resolveOpenCodeSessionReuse({
1087
+ sessionsDir,
1088
+ criteria: { issueId: spawnConfig.issueId },
1089
+ strategy: opencodeIssueSessionStrategy,
1090
+ })
1091
+ : undefined;
1092
+ const agentLaunchConfig = {
1093
+ sessionId,
1094
+ projectConfig: {
1095
+ ...project,
1096
+ agentConfig: {
1097
+ ...selection.agentConfig,
1098
+ ...(reusedOpenCodeSessionId ? { opencodeSessionId: reusedOpenCodeSessionId } : {}),
1099
+ },
1100
+ },
1101
+ workspacePath,
1102
+ issueId: spawnConfig.issueId,
1103
+ prompt: taskPrompt,
1104
+ systemPromptFile,
1105
+ permissions: selection.permissions,
1106
+ model: selection.model,
1107
+ subagent: spawnConfig.subagent ?? selection.subagent,
1108
+ };
1109
+ const launchCommand = plugins.agent.getLaunchCommand(agentLaunchConfig);
1110
+ const environment = plugins.agent.getEnvironment(agentLaunchConfig);
1111
+ if (plugins.agent.preLaunchSetup) {
1112
+ await plugins.agent.preLaunchSetup(workspacePath);
1113
+ }
1114
+ // Install workspace hooks before launching the agent so that
1115
+ // PostToolUse hooks (e.g. Claude Code's metadata-updater) are
1116
+ // in place before the agent's first tool call.
1117
+ if (plugins.agent.setupWorkspaceHooks) {
1118
+ await plugins.agent.setupWorkspaceHooks(workspacePath, { dataDir: sessionsDir });
1119
+ }
1120
+ if (plugins.agent.name !== "claude-code") {
1121
+ await setupPathWrapperWorkspace(workspacePath);
1122
+ }
1123
+ const handle = await plugins.runtime.create({
1124
+ sessionId: tmuxName ?? sessionId, // Use tmux name for runtime if available
1125
+ workspacePath,
1126
+ launchCommand,
1127
+ environment: {
1128
+ ...environment,
1129
+ ...(opencodeConfigFile ? { OPENCODE_CONFIG: opencodeConfigFile } : {}),
1130
+ ...(project.env ?? {}),
1131
+ PATH: buildAgentPath(environment["PATH"] ?? process.env["PATH"]),
1132
+ GH_PATH: PREFERRED_GH_PATH,
1133
+ ...(process.env["AO_AGENT_GH_TRACE"] && {
1134
+ AO_AGENT_GH_TRACE: process.env["AO_AGENT_GH_TRACE"],
1135
+ }),
1136
+ AO_SESSION: sessionId,
1137
+ AO_DATA_DIR: sessionsDir, // Pass sessions directory (not root dataDir)
1138
+ AO_SESSION_NAME: sessionId, // User-facing session name
1139
+ ...(tmuxName && { AO_TMUX_NAME: tmuxName }), // Tmux session name if using new arch
1140
+ AO_CALLER_TYPE: "agent",
1141
+ AO_PROJECT_ID: spawnConfig.projectId,
1142
+ AO_CONFIG_PATH: config.configPath,
1143
+ ...(config.port !== undefined &&
1144
+ config.port !== null && { AO_PORT: String(config.port) }),
1145
+ },
1146
+ });
1147
+ const rt = plugins.runtime;
1148
+ cleanupStack.push(() => rt.destroy(handle));
1149
+ // Derive a stable display name from task context. Unlike issue-title
1150
+ // enrichment (which is a live tracker API call), this value is captured at
1151
+ // spawn time and persisted, so the dashboard has a good name even when the
1152
+ // tracker is unavailable or the session has no attached PR yet.
1153
+ const displayName = deriveDisplayName({
1154
+ issueTitle: resolvedIssue?.title,
1155
+ prompt: spawnConfig.prompt,
1156
+ });
1157
+ // Write metadata and run post-launch setup
1158
+ const createdAt = new Date();
1159
+ const lifecycle = createInitialCanonicalLifecycle("worker", createdAt);
1160
+ lifecycle.runtime.handle = handle;
1161
+ lifecycle.runtime.tmuxName = tmuxName ?? null;
1162
+ const session = {
1163
+ id: sessionId,
1164
+ projectId: spawnConfig.projectId,
1165
+ status: deriveLegacyStatus(lifecycle),
1166
+ activity: "active",
1167
+ activitySignal: createActivitySignal("valid", {
1168
+ activity: "active",
1169
+ timestamp: createdAt,
1170
+ source: "runtime",
1171
+ }),
1172
+ lifecycle,
1173
+ branch,
1174
+ issueId: spawnConfig.issueId ?? null,
1175
+ pr: null,
1176
+ prs: [],
1177
+ workspacePath,
1178
+ runtimeHandle: handle,
1179
+ agentInfo: null,
1180
+ createdAt,
1181
+ lastActivityAt: createdAt,
1182
+ metadata: {
1183
+ ...(reusedOpenCodeSessionId ? { opencodeSessionId: reusedOpenCodeSessionId } : {}),
1184
+ ...(spawnConfig.prompt ? { userPrompt: spawnConfig.prompt } : {}),
1185
+ ...(displayName ? { displayName } : {}),
1186
+ },
1187
+ };
1188
+ writeMetadata(sessionsDir, sessionId, {
1189
+ worktree: workspacePath,
1190
+ branch,
1191
+ status: deriveLegacyStatus(lifecycle),
1192
+ ...buildLifecycleMetadataPatch(lifecycle),
1193
+ // Override stringified lifecycle/runtimeHandle from the patch
1194
+ // with their canonical object forms. `buildLifecycleMetadataPatch`
1195
+ // produces `Partial<Record<string, string>>` for the
1196
+ // updateMetadata/mutateMetadata path; spreading it directly into
1197
+ // a typed `SessionMetadata` literal silently widens the
1198
+ // `lifecycle`/`runtimeHandle` fields to strings, which then get
1199
+ // re-encoded by `JSON.stringify` and rejected by
1200
+ // `parseLifecycleField` on the next read.
1201
+ lifecycle,
1202
+ tmuxName, // Store tmux name for mapping
1203
+ issue: spawnConfig.issueId,
1204
+ issueTitle: resolvedIssue?.title, // Store issue title for event enrichment
1205
+ project: spawnConfig.projectId,
1206
+ agent: selection.agentName, // Persist agent name for lifecycle manager
1207
+ createdAt: createdAt.toISOString(),
1208
+ runtimeHandle: handle,
1209
+ opencodeSessionId: reusedOpenCodeSessionId,
1210
+ userPrompt: spawnConfig.prompt,
1211
+ displayName,
1212
+ });
1213
+ if (plugins.agent.postLaunchSetup) {
1214
+ await plugins.agent.postLaunchSetup(session);
1215
+ }
1216
+ if (plugins.agent.promptDelivery === "post-launch" && agentLaunchConfig.prompt) {
1217
+ await plugins.runtime.sendMessage(handle, agentLaunchConfig.prompt);
1218
+ }
1219
+ if (plugins.agent.name === "opencode" &&
1220
+ opencodeIssueSessionStrategy === "reuse" &&
1221
+ !session.metadata["opencodeSessionId"]) {
1222
+ const discovered = await discoverOpenCodeSessionIdByTitle(sessionId, OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS);
1223
+ if (discovered) {
1224
+ session.metadata["opencodeSessionId"] = discovered;
1225
+ }
1226
+ }
1227
+ if (Object.keys(session.metadata || {}).length > 0) {
1228
+ updateMetadata(sessionsDir, sessionId, session.metadata);
1229
+ }
1230
+ invalidateCache();
1231
+ // Past this point every resource that needed an undo is on disk in its
1232
+ // final form. Dismiss the stack so nothing below can trigger a rollback.
1233
+ cleanupStack.dismiss();
1234
+ // Prompt is delivered inline via the agent's launch command (positional argument).
1235
+ // No post-launch polling needed — the prompt is part of process invocation.
1236
+ recordActivityEvent({
1237
+ projectId: spawnConfig.projectId,
1238
+ sessionId,
1239
+ source: "session-manager",
1240
+ kind: "session.spawned",
1241
+ summary: `spawned: ${sessionId}`,
1242
+ data: { agent: plugins.agent.name, branch: session.branch ?? undefined },
1243
+ });
1244
+ return session;
1245
+ }
1246
+ catch (err) {
1247
+ // Log cleanup failures so they don't disappear silently. The original
1248
+ // code used /* best effort */ swallows; the stack preserves that
1249
+ // behavior (cleanup errors don't propagate) but surfaces them for debug.
1250
+ recordActivityEvent({
1251
+ projectId: spawnConfig.projectId,
1252
+ sessionId,
1253
+ source: "session-manager",
1254
+ kind: "session.rollback_started",
1255
+ level: "warn",
1256
+ summary: "spawn rollback started",
1257
+ data: { reason: err instanceof Error ? err.message : String(err) },
1258
+ });
1259
+ await cleanupStack.runAll((cleanupErr) => {
1260
+ console.error("[session-manager] spawn rollback step failed:", cleanupErr);
1261
+ // B25: emit per-step rollback failure so each leaked resource is queryable.
1262
+ recordActivityEvent({
1263
+ projectId: spawnConfig.projectId,
1264
+ sessionId,
1265
+ source: "session-manager",
1266
+ kind: "session.rollback_step_failed",
1267
+ level: "error",
1268
+ summary: "spawn rollback step failed",
1269
+ data: {
1270
+ reason: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr),
1271
+ },
1272
+ });
1273
+ });
1274
+ throw err;
1275
+ }
1276
+ }
1277
+ function recordOrchestratorSpawnFailed(orchestratorConfig, err, sessionId) {
1278
+ recordActivityEvent({
1279
+ projectId: orchestratorConfig.projectId,
1280
+ ...(sessionId ? { sessionId } : {}),
1281
+ source: "session-manager",
1282
+ kind: "session.spawn_failed",
1283
+ level: "error",
1284
+ summary: "orchestrator spawn failed",
1285
+ data: {
1286
+ role: "orchestrator",
1287
+ reason: err instanceof Error ? err.message : String(err),
1288
+ },
1289
+ });
1290
+ }
1291
+ async function spawnOrchestrator(orchestratorConfig, options) {
1292
+ recordActivityEvent({
1293
+ projectId: orchestratorConfig.projectId,
1294
+ source: "session-manager",
1295
+ kind: "session.spawn_started",
1296
+ summary: "orchestrator spawn started",
1297
+ data: { agent: orchestratorConfig.agent ?? undefined, role: "orchestrator" },
1298
+ });
1299
+ try {
1300
+ return await _spawnOrchestratorInner(orchestratorConfig);
1301
+ }
1302
+ catch (err) {
1303
+ const project = config.projects[orchestratorConfig.projectId];
1304
+ const sessionId = project ? getOrchestratorSessionId(project) : undefined;
1305
+ const shouldSuppressRecoverableConflict = options?.suppressFixedReservationFailure === true &&
1306
+ sessionId !== undefined &&
1307
+ isFixedOrchestratorReservationError(err, sessionId);
1308
+ if (!shouldSuppressRecoverableConflict) {
1309
+ recordOrchestratorSpawnFailed(orchestratorConfig, err, sessionId);
1310
+ }
1311
+ throw err;
1312
+ }
1313
+ }
1314
+ async function _spawnOrchestratorInner(orchestratorConfig) {
1315
+ const project = config.projects[orchestratorConfig.projectId];
1316
+ if (!project) {
1317
+ throw new Error(`Unknown project: ${orchestratorConfig.projectId}`);
1318
+ }
1319
+ const selection = resolveAgentSelection({
1320
+ role: "orchestrator",
1321
+ project,
1322
+ defaults: config.defaults,
1323
+ spawnAgentOverride: orchestratorConfig.agent,
1324
+ });
1325
+ const plugins = resolvePlugins(project, selection.agentName);
1326
+ if (!plugins.runtime) {
1327
+ throw new Error(`Runtime plugin '${project.runtime ?? config.defaults.runtime}' not found`);
1328
+ }
1329
+ if (!plugins.agent) {
1330
+ throw new Error(`Agent plugin '${selection.agentName}' not found`);
1331
+ }
1332
+ // Get the sessions directory for this project
1333
+ const sessionsDir = getProjectSessionsDir(orchestratorConfig.projectId);
1334
+ const orchestratorSessionStrategy = normalizeOrchestratorSessionStrategy(project.orchestratorSessionStrategy);
1335
+ const identity = reserveFixedOrchestratorIdentity(project, sessionsDir);
1336
+ const sessionId = identity.sessionId;
1337
+ const tmuxName = identity.tmuxName;
1338
+ // The main orchestrator is deterministic, but still uses an isolated worktree.
1339
+ const branch = `orchestrator/${sessionId}`;
1340
+ if (!plugins.workspace) {
1341
+ try {
1342
+ deleteMetadata(sessionsDir, sessionId);
1343
+ }
1344
+ catch {
1345
+ /* best effort */
1346
+ }
1347
+ throw new Error(`spawnOrchestrator requires a workspace plugin but none is configured for project '${orchestratorConfig.projectId}'`);
1348
+ }
1349
+ const workspaceConfig = {
1350
+ projectId: orchestratorConfig.projectId,
1351
+ project,
1352
+ sessionId,
1353
+ branch,
1354
+ worktreeDir: getProjectWorktreesDir(orchestratorConfig.projectId),
1355
+ };
1356
+ let workspacePath;
1357
+ let adoptedManagedWorkspace = false;
1358
+ try {
1359
+ const adoptedInfo = await plugins.workspace.findManagedWorkspace?.(workspaceConfig);
1360
+ const wsInfo = adoptedInfo ?? (await plugins.workspace.create(workspaceConfig));
1361
+ workspacePath = wsInfo.path;
1362
+ adoptedManagedWorkspace = adoptedInfo !== undefined && adoptedInfo !== null;
1363
+ }
1364
+ catch (err) {
1365
+ recordActivityEvent({
1366
+ projectId: orchestratorConfig.projectId,
1367
+ sessionId,
1368
+ source: "session-manager",
1369
+ kind: "session.spawn_step_failed",
1370
+ level: "error",
1371
+ summary: "orchestrator workspace.create failed",
1372
+ data: {
1373
+ role: "orchestrator",
1374
+ stage: "workspace_create",
1375
+ reason: err instanceof Error ? err.message : String(err),
1376
+ },
1377
+ });
1378
+ try {
1379
+ deleteMetadata(sessionsDir, sessionId);
1380
+ }
1381
+ catch {
1382
+ /* best effort */
1383
+ }
1384
+ throw err;
1385
+ }
1386
+ // Helper: undo worktree + metadata if anything between workspace creation
1387
+ // and a fully-written metadata record fails.
1388
+ const cleanupWorktreeAndMetadata = async (promptFile) => {
1389
+ if (!adoptedManagedWorkspace) {
1390
+ try {
1391
+ // plugins.workspace is guaranteed non-null here: we threw above if it was null
1392
+ await plugins.workspace.destroy(workspacePath);
1393
+ }
1394
+ catch {
1395
+ /* best effort */
1396
+ }
1397
+ }
1398
+ try {
1399
+ deleteMetadata(sessionsDir, sessionId);
1400
+ }
1401
+ catch {
1402
+ /* best effort */
1403
+ }
1404
+ if (promptFile) {
1405
+ try {
1406
+ unlinkSync(promptFile);
1407
+ }
1408
+ catch {
1409
+ /* best effort */
1410
+ }
1411
+ }
1412
+ };
1413
+ // Setup agent hooks for automatic metadata updates.
1414
+ // Claude Code uses native PostToolUse hooks for metadata writes — skip
1415
+ // PATH wrappers to avoid two concurrent writers (wrapper + hook) hitting
1416
+ // the same metadata file with no locking.
1417
+ try {
1418
+ if (plugins.agent.setupWorkspaceHooks) {
1419
+ await plugins.agent.setupWorkspaceHooks(workspacePath, { dataDir: sessionsDir });
1420
+ }
1421
+ if (plugins.agent.name !== "claude-code") {
1422
+ await setupPathWrapperWorkspace(workspacePath);
1423
+ }
1424
+ }
1425
+ catch (err) {
1426
+ // PR tracking and CI fetch hooks are wired here — emit a dedicated AE
1427
+ // before rolling back so RCA can answer "did the orchestrator launch
1428
+ // succeed but lose its hook integration?".
1429
+ recordActivityEvent({
1430
+ projectId: orchestratorConfig.projectId,
1431
+ sessionId,
1432
+ source: "session-manager",
1433
+ kind: "session.workspace_hooks_failed",
1434
+ level: "error",
1435
+ summary: "orchestrator workspace hooks installation failed",
1436
+ data: {
1437
+ agent: plugins.agent.name,
1438
+ reason: err instanceof Error ? err.message : String(err),
1439
+ },
1440
+ });
1441
+ await cleanupWorktreeAndMetadata();
1442
+ throw err;
1443
+ }
1444
+ // Write system prompt to a file to avoid shell/tmux truncation.
1445
+ // Long prompts (2000+ chars) get mangled when inlined in shell commands
1446
+ // via tmux send-keys or paste-buffer. File-based approach is reliable.
1447
+ let systemPromptFile;
1448
+ if (orchestratorConfig.systemPrompt) {
1449
+ try {
1450
+ const projectDir = getProjectDir(orchestratorConfig.projectId);
1451
+ mkdirSync(projectDir, { recursive: true });
1452
+ systemPromptFile = join(projectDir, `orchestrator-prompt-${sessionId}.md`);
1453
+ writeFileSync(systemPromptFile, orchestratorConfig.systemPrompt, "utf-8");
1454
+ }
1455
+ catch (err) {
1456
+ recordActivityEvent({
1457
+ projectId: orchestratorConfig.projectId,
1458
+ sessionId,
1459
+ source: "session-manager",
1460
+ kind: "session.spawn_step_failed",
1461
+ level: "error",
1462
+ summary: "orchestrator systemPrompt write failed",
1463
+ data: {
1464
+ role: "orchestrator",
1465
+ stage: "system_prompt_write",
1466
+ reason: err instanceof Error ? err.message : String(err),
1467
+ },
1468
+ });
1469
+ await cleanupWorktreeAndMetadata(systemPromptFile);
1470
+ throw err;
1471
+ }
1472
+ }
1473
+ if (plugins.agent.name === "opencode" && systemPromptFile) {
1474
+ try {
1475
+ writeWorkspaceOpenCodeAgentsMd(workspacePath, systemPromptFile);
1476
+ }
1477
+ catch (err) {
1478
+ recordActivityEvent({
1479
+ projectId: orchestratorConfig.projectId,
1480
+ sessionId,
1481
+ source: "session-manager",
1482
+ kind: "session.spawn_step_failed",
1483
+ level: "error",
1484
+ summary: "orchestrator AGENTS.md write failed",
1485
+ data: {
1486
+ role: "orchestrator",
1487
+ stage: "agents_md_write",
1488
+ reason: err instanceof Error ? err.message : String(err),
1489
+ },
1490
+ });
1491
+ await cleanupWorktreeAndMetadata(systemPromptFile);
1492
+ throw err;
1493
+ }
1494
+ }
1495
+ let reusableOpenCodeSessionId;
1496
+ try {
1497
+ reusableOpenCodeSessionId =
1498
+ plugins.agent.name === "opencode" && orchestratorSessionStrategy === "reuse"
1499
+ ? await resolveOpenCodeSessionReuse({
1500
+ sessionsDir,
1501
+ criteria: { sessionId },
1502
+ strategy: "reuse",
1503
+ })
1504
+ : undefined;
1505
+ if (plugins.agent.name === "opencode" && orchestratorSessionStrategy === "delete") {
1506
+ await resolveOpenCodeSessionReuse({
1507
+ sessionsDir,
1508
+ criteria: { sessionId },
1509
+ strategy: "delete",
1510
+ includeTitleDiscoveryForSessionId: true,
1511
+ });
1512
+ }
1513
+ }
1514
+ catch (err) {
1515
+ recordActivityEvent({
1516
+ projectId: orchestratorConfig.projectId,
1517
+ sessionId,
1518
+ source: "session-manager",
1519
+ kind: "session.spawn_step_failed",
1520
+ level: "error",
1521
+ summary: "orchestrator opencode session resolution failed",
1522
+ data: {
1523
+ role: "orchestrator",
1524
+ stage: "opencode_session_reuse",
1525
+ reason: err instanceof Error ? err.message : String(err),
1526
+ },
1527
+ });
1528
+ await cleanupWorktreeAndMetadata(systemPromptFile);
1529
+ throw err;
1530
+ }
1531
+ // Get agent launch config — uses systemPromptFile, no issue/tracker interaction.
1532
+ // Orchestrator ALWAYS gets permissionless mode — it must run ao CLI commands autonomously.
1533
+ const agentLaunchConfig = {
1534
+ sessionId,
1535
+ projectConfig: {
1536
+ ...project,
1537
+ agentConfig: {
1538
+ ...selection.agentConfig,
1539
+ permissions: "permissionless",
1540
+ ...(reusableOpenCodeSessionId ? { opencodeSessionId: reusableOpenCodeSessionId } : {}),
1541
+ },
1542
+ },
1543
+ workspacePath,
1544
+ permissions: "permissionless",
1545
+ model: selection.model,
1546
+ systemPromptFile,
1547
+ subagent: selection.subagent,
1548
+ };
1549
+ const launchCommand = plugins.agent.getLaunchCommand(agentLaunchConfig);
1550
+ const environment = plugins.agent.getEnvironment(agentLaunchConfig);
1551
+ if (plugins.agent.preLaunchSetup) {
1552
+ await plugins.agent.preLaunchSetup(workspacePath);
1553
+ }
1554
+ // Create runtime — clean up worktree and metadata on failure
1555
+ let handle;
1556
+ try {
1557
+ handle = await plugins.runtime.create({
1558
+ sessionId: tmuxName ?? sessionId,
1559
+ workspacePath,
1560
+ launchCommand,
1561
+ environment: {
1562
+ ...environment,
1563
+ ...(project.env ?? {}),
1564
+ PATH: buildAgentPath(environment["PATH"] ?? process.env["PATH"]),
1565
+ GH_PATH: PREFERRED_GH_PATH,
1566
+ ...(process.env["AO_AGENT_GH_TRACE"] && {
1567
+ AO_AGENT_GH_TRACE: process.env["AO_AGENT_GH_TRACE"],
1568
+ }),
1569
+ AO_SESSION: sessionId,
1570
+ AO_DATA_DIR: sessionsDir,
1571
+ AO_SESSION_NAME: sessionId,
1572
+ ...(tmuxName && { AO_TMUX_NAME: tmuxName }),
1573
+ AO_CALLER_TYPE: "orchestrator",
1574
+ AO_PROJECT_ID: orchestratorConfig.projectId,
1575
+ AO_CONFIG_PATH: config.configPath,
1576
+ ...(config.port !== undefined &&
1577
+ config.port !== null && { AO_PORT: String(config.port) }),
1578
+ },
1579
+ });
1580
+ }
1581
+ catch (err) {
1582
+ // Outer envelope catches and emits session.spawn_failed; this step emit
1583
+ // tags the runtime.create failure path specifically so RCA can answer
1584
+ // "did the orchestrator runtime fail to start at all?".
1585
+ recordActivityEvent({
1586
+ projectId: orchestratorConfig.projectId,
1587
+ sessionId,
1588
+ source: "session-manager",
1589
+ kind: "session.spawn_step_failed",
1590
+ level: "error",
1591
+ summary: "orchestrator runtime.create failed",
1592
+ data: {
1593
+ role: "orchestrator",
1594
+ stage: "runtime_create",
1595
+ reason: err instanceof Error ? err.message : String(err),
1596
+ },
1597
+ });
1598
+ await cleanupWorktreeAndMetadata(systemPromptFile);
1599
+ throw err;
1600
+ }
1601
+ // Derive a stable display name from the orchestrator's system prompt so
1602
+ // the dashboard shows something more useful than "Ao Orchestrator 8".
1603
+ const displayName = deriveDisplayName({
1604
+ prompt: orchestratorConfig.systemPrompt,
1605
+ });
1606
+ // Write metadata and run post-launch setup
1607
+ const createdAt = new Date();
1608
+ const lifecycle = createInitialCanonicalLifecycle("orchestrator", createdAt);
1609
+ lifecycle.session.state = "working";
1610
+ lifecycle.session.reason = "task_in_progress";
1611
+ lifecycle.session.startedAt = createdAt.toISOString();
1612
+ lifecycle.session.lastTransitionAt = createdAt.toISOString();
1613
+ lifecycle.runtime.handle = handle;
1614
+ lifecycle.runtime.tmuxName = tmuxName ?? null;
1615
+ const session = {
1616
+ id: sessionId,
1617
+ projectId: orchestratorConfig.projectId,
1618
+ status: deriveLegacyStatus(lifecycle),
1619
+ activity: "active",
1620
+ activitySignal: createActivitySignal("valid", {
1621
+ activity: "active",
1622
+ timestamp: createdAt,
1623
+ source: "runtime",
1624
+ }),
1625
+ lifecycle,
1626
+ branch,
1627
+ issueId: null,
1628
+ pr: null,
1629
+ prs: [],
1630
+ workspacePath,
1631
+ runtimeHandle: handle,
1632
+ agentInfo: null,
1633
+ createdAt,
1634
+ lastActivityAt: createdAt,
1635
+ metadata: {
1636
+ ...(reusableOpenCodeSessionId ? { opencodeSessionId: reusableOpenCodeSessionId } : {}),
1637
+ ...(displayName ? { displayName } : {}),
1638
+ },
1639
+ };
1640
+ try {
1641
+ writeMetadata(sessionsDir, sessionId, {
1642
+ worktree: workspacePath,
1643
+ branch,
1644
+ status: deriveLegacyStatus(lifecycle),
1645
+ ...buildLifecycleMetadataPatch(lifecycle),
1646
+ // Object overrides for the typed writeMetadata path —
1647
+ // see the spawnSession site for the rationale.
1648
+ lifecycle,
1649
+ role: "orchestrator",
1650
+ tmuxName,
1651
+ project: orchestratorConfig.projectId,
1652
+ agent: selection.agentName,
1653
+ createdAt: createdAt.toISOString(),
1654
+ runtimeHandle: handle,
1655
+ opencodeSessionId: reusableOpenCodeSessionId,
1656
+ displayName,
1657
+ });
1658
+ if (plugins.agent.postLaunchSetup) {
1659
+ await plugins.agent.postLaunchSetup(session);
1660
+ }
1661
+ if (plugins.agent.promptDelivery === "post-launch" && orchestratorConfig.systemPrompt) {
1662
+ // The orchestrator prompt is already passed via systemPromptFile in the launch command.
1663
+ // Send only a minimal trigger so interactive post-launch agents start without
1664
+ // receiving their system instructions again as a user message.
1665
+ await plugins.runtime.sendMessage(handle, "Begin.");
1666
+ }
1667
+ if (plugins.agent.name === "opencode" &&
1668
+ orchestratorSessionStrategy === "reuse" &&
1669
+ !session.metadata["opencodeSessionId"]) {
1670
+ const discovered = await discoverOpenCodeSessionIdByTitle(sessionId, OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS);
1671
+ if (discovered) {
1672
+ session.metadata["opencodeSessionId"] = discovered;
1673
+ }
1674
+ }
1675
+ if (Object.keys(session.metadata || {}).length > 0) {
1676
+ updateMetadata(sessionsDir, sessionId, session.metadata);
1677
+ }
1678
+ invalidateCache();
1679
+ }
1680
+ catch (err) {
1681
+ recordActivityEvent({
1682
+ projectId: orchestratorConfig.projectId,
1683
+ sessionId,
1684
+ source: "session-manager",
1685
+ kind: "session.spawn_step_failed",
1686
+ level: "error",
1687
+ summary: "orchestrator post-launch metadata write failed",
1688
+ data: {
1689
+ role: "orchestrator",
1690
+ stage: "post_launch_metadata",
1691
+ reason: err instanceof Error ? err.message : String(err),
1692
+ },
1693
+ });
1694
+ // Clean up runtime on post-launch failure
1695
+ try {
1696
+ await plugins.runtime.destroy(handle);
1697
+ }
1698
+ catch {
1699
+ /* best effort */
1700
+ }
1701
+ await cleanupWorktreeAndMetadata(systemPromptFile);
1702
+ throw err;
1703
+ }
1704
+ recordActivityEvent({
1705
+ projectId: orchestratorConfig.projectId,
1706
+ sessionId,
1707
+ source: "session-manager",
1708
+ kind: "session.spawned",
1709
+ summary: `spawned: ${sessionId}`,
1710
+ data: {
1711
+ agent: plugins.agent.name,
1712
+ branch: session.branch ?? undefined,
1713
+ role: "orchestrator",
1714
+ },
1715
+ });
1716
+ return session;
1717
+ }
1718
+ async function waitForConcurrentOrchestrator(sessionId) {
1719
+ const deadline = Date.now() + ENSURE_ORCHESTRATOR_CONFLICT_WAIT_MS;
1720
+ while (Date.now() < deadline) {
1721
+ const existing = await get(sessionId);
1722
+ if (existing?.metadata["role"] === "orchestrator") {
1723
+ return existing;
1724
+ }
1725
+ await sleep(ENSURE_ORCHESTRATOR_CONFLICT_POLL_MS);
1726
+ }
1727
+ return null;
1728
+ }
1729
+ async function ensureOrchestratorInternal(orchestratorConfig) {
1730
+ const project = config.projects[orchestratorConfig.projectId];
1731
+ if (!project) {
1732
+ throw new Error(`Unknown project: ${orchestratorConfig.projectId}`);
1733
+ }
1734
+ const sessionId = getOrchestratorSessionId(project);
1735
+ // If a relaunch is mid-flight for this sessionId, wait it out — otherwise
1736
+ // we could return a session that relaunch is about to kill, or race the
1737
+ // relaunch's spawnOrchestrator on the same reservation.
1738
+ const pendingRelaunch = relaunchOrchestratorPromises.get(sessionId);
1739
+ if (pendingRelaunch) {
1740
+ await pendingRelaunch.catch((err) => {
1741
+ console.warn(`[ensureOrchestrator] in-flight relaunch for ${sessionId} failed before ensure proceeded:`, err);
1742
+ });
1743
+ }
1744
+ const existing = await get(sessionId);
1745
+ if (existing) {
1746
+ const orchestratorSessionStrategy = normalizeOrchestratorSessionStrategy(project.orchestratorSessionStrategy);
1747
+ if (orchestratorSessionStrategy === "delete" ||
1748
+ orchestratorSessionStrategy === "ignore") {
1749
+ await kill(sessionId, { purgeOpenCode: orchestratorSessionStrategy === "delete" });
1750
+ deleteMetadata(getProjectSessionsDir(orchestratorConfig.projectId), sessionId);
1751
+ return spawnOrchestrator(orchestratorConfig);
1752
+ }
1753
+ if (existing.lifecycle.session.state === "done") {
1754
+ throw new SessionNotRestorableError(sessionId, `canonical orchestrator session is terminal with status "${existing.status}". Remove or clean up this session before starting a new orchestrator.`);
1755
+ }
1756
+ if (isRestorable(existing)) {
1757
+ return restore(sessionId);
1758
+ }
1759
+ if (!isTerminalSession(existing)) {
1760
+ return existing;
1761
+ }
1762
+ throw new SessionNotRestorableError(sessionId, `canonical orchestrator session is terminal with status "${existing.status}". Remove or clean up this session before starting a new orchestrator.`);
1763
+ }
1764
+ try {
1765
+ return await spawnOrchestrator(orchestratorConfig, {
1766
+ suppressFixedReservationFailure: true,
1767
+ });
1768
+ }
1769
+ catch (err) {
1770
+ if (!isFixedOrchestratorReservationError(err, sessionId)) {
1771
+ throw err;
1772
+ }
1773
+ recordActivityEvent({
1774
+ projectId: orchestratorConfig.projectId,
1775
+ sessionId,
1776
+ source: "session-manager",
1777
+ kind: "session.orchestrator_conflict",
1778
+ level: "warn",
1779
+ summary: "concurrent orchestrator reservation conflict",
1780
+ data: { reason: err instanceof Error ? err.message : String(err) },
1781
+ });
1782
+ const concurrent = await waitForConcurrentOrchestrator(sessionId);
1783
+ if (concurrent)
1784
+ return concurrent;
1785
+ recordOrchestratorSpawnFailed(orchestratorConfig, err, sessionId);
1786
+ throw err;
1787
+ }
1788
+ }
1789
+ async function ensureOrchestrator(orchestratorConfig) {
1790
+ const project = config.projects[orchestratorConfig.projectId];
1791
+ if (!project) {
1792
+ throw new Error(`Unknown project: ${orchestratorConfig.projectId}`);
1793
+ }
1794
+ const sessionId = getOrchestratorSessionId(project);
1795
+ const existingPromise = ensureOrchestratorPromises.get(sessionId);
1796
+ if (existingPromise)
1797
+ return existingPromise;
1798
+ const promise = ensureOrchestratorInternal(orchestratorConfig).finally(() => {
1799
+ ensureOrchestratorPromises.delete(sessionId);
1800
+ });
1801
+ ensureOrchestratorPromises.set(sessionId, promise);
1802
+ return promise;
1803
+ }
1804
+ async function relaunchOrchestratorInternal(orchestratorConfig) {
1805
+ const project = config.projects[orchestratorConfig.projectId];
1806
+ if (!project) {
1807
+ throw new Error(`Unknown project: ${orchestratorConfig.projectId}`);
1808
+ }
1809
+ const sessionId = getOrchestratorSessionId(project);
1810
+ const sessionsDir = getProjectSessionsDir(orchestratorConfig.projectId);
1811
+ // If ensureOrchestrator is mid-flight for this sessionId, wait it out.
1812
+ // Otherwise get() would return null (metadata not yet written) and we'd
1813
+ // skip the kill, then race the in-flight spawnOrchestrator on the same
1814
+ // reservation — surfacing "session already exists" instead of replacing.
1815
+ const pendingEnsure = ensureOrchestratorPromises.get(sessionId);
1816
+ if (pendingEnsure) {
1817
+ await pendingEnsure.catch((err) => {
1818
+ console.warn(`[relaunchOrchestrator] in-flight ensure for ${sessionId} failed before relaunch proceeded:`, err);
1819
+ });
1820
+ }
1821
+ const existing = await get(sessionId);
1822
+ if (existing) {
1823
+ const existingAgent = resolveSelectionForSession(project, sessionId, readMetadataRaw(sessionsDir, sessionId) ?? {}).agentName;
1824
+ await kill(sessionId, { purgeOpenCode: existingAgent === "opencode" });
1825
+ deleteMetadata(sessionsDir, sessionId);
1826
+ }
1827
+ return spawnOrchestrator(orchestratorConfig);
1828
+ }
1829
+ async function relaunchOrchestrator(orchestratorConfig) {
1830
+ const project = config.projects[orchestratorConfig.projectId];
1831
+ if (!project) {
1832
+ throw new Error(`Unknown project: ${orchestratorConfig.projectId}`);
1833
+ }
1834
+ const sessionId = getOrchestratorSessionId(project);
1835
+ const existingPromise = relaunchOrchestratorPromises.get(sessionId);
1836
+ if (existingPromise)
1837
+ return existingPromise;
1838
+ const promise = relaunchOrchestratorInternal(orchestratorConfig).finally(() => {
1839
+ relaunchOrchestratorPromises.delete(sessionId);
1840
+ });
1841
+ relaunchOrchestratorPromises.set(sessionId, promise);
1842
+ return promise;
1843
+ }
1844
+ async function list(projectId) {
1845
+ const allSessions = Object.entries(config.projects).flatMap(([entryProjectId, project]) => {
1846
+ if (projectId && entryProjectId !== projectId)
1847
+ return [];
1848
+ return loadActiveSessionRecords(entryProjectId, project).map((record) => ({
1849
+ sessionName: record.sessionName,
1850
+ projectId: entryProjectId,
1851
+ raw: record.raw,
1852
+ }));
1853
+ });
1854
+ let openCodeSessionListPromise;
1855
+ const tasks = allSessions.map(async ({ sessionName, projectId: sessionProjectId, raw }) => {
1856
+ const project = config.projects[sessionProjectId];
1857
+ if (!project)
1858
+ return null;
1859
+ const sessionsDir = getProjectSessionsDir(sessionProjectId);
1860
+ let createdAt;
1861
+ let modifiedAt;
1862
+ try {
1863
+ const metaPath = join(sessionsDir, `${sessionName}.json`);
1864
+ const stats = statSync(metaPath);
1865
+ createdAt = stats.birthtime;
1866
+ modifiedAt = stats.mtime;
1867
+ }
1868
+ catch {
1869
+ // If stat fails, timestamps will fall back to current time
1870
+ }
1871
+ const session = metadataToSession(sessionName, raw, {
1872
+ projectId: sessionProjectId,
1873
+ sessionPrefix: project.sessionPrefix,
1874
+ createdAt,
1875
+ modifiedAt,
1876
+ workspacePathFallback: project.path,
1877
+ });
1878
+ const selection = resolveSelectionForSession(project, sessionName, raw);
1879
+ const effectiveAgentName = selection.agentName;
1880
+ const plugins = resolvePlugins(project, effectiveAgentName);
1881
+ const sessionListPromise = effectiveAgentName === "opencode"
1882
+ ? (openCodeSessionListPromise ??= fetchOpenCodeSessionList())
1883
+ : undefined;
1884
+ let enrichTimeoutId = null;
1885
+ const enrichTimeout = new Promise((resolve) => {
1886
+ enrichTimeoutId = setTimeout(resolve, OPENCODE_DISCOVERY_TIMEOUT_MS + 2_000);
1887
+ });
1888
+ const enrichPromise = ensureHandleAndEnrich(session, sessionName, sessionsDir, project, effectiveAgentName, plugins, sessionListPromise).catch(() => { });
1889
+ try {
1890
+ await Promise.race([enrichPromise, enrichTimeout]);
1891
+ }
1892
+ finally {
1893
+ if (enrichTimeoutId) {
1894
+ clearTimeout(enrichTimeoutId);
1895
+ }
1896
+ }
1897
+ // Persist runtime probe result to disk so the lifecycle manager sees it
1898
+ // on next poll. We only persist the runtime signal and detecting state —
1899
+ // the lifecycle manager's resolveProbeDecision pipeline is the single
1900
+ // authority on terminal decisions (terminated/done). See #1735.
1901
+ // Check the on-disk state (raw) to avoid re-writing when already
1902
+ // detecting — enrichment sets detecting in-memory, but we only need
1903
+ // to persist the transition once to avoid resetting lastTransitionAt.
1904
+ const onDiskLifecycle = parseCanonicalLifecycle(raw, {
1905
+ sessionId: sessionName,
1906
+ status: validateStatus(raw["status"]),
1907
+ });
1908
+ if (session.lifecycle &&
1909
+ (session.lifecycle.runtime.state === "missing" ||
1910
+ session.lifecycle.runtime.state === "exited") &&
1911
+ onDiskLifecycle.session.state !== "terminated" &&
1912
+ onDiskLifecycle.session.state !== "done" &&
1913
+ onDiskLifecycle.session.state !== "detecting") {
1914
+ const runtimeStateBefore = session.lifecycle.runtime.state;
1915
+ const runtimeReasonBefore = session.lifecycle.runtime.reason;
1916
+ try {
1917
+ const persisted = buildUpdatedLifecycle(sessionName, raw, (next) => {
1918
+ next.session.state = "detecting";
1919
+ next.session.reason = "runtime_lost";
1920
+ next.session.lastTransitionAt = new Date().toISOString();
1921
+ next.runtime.state = runtimeStateBefore;
1922
+ next.runtime.reason = runtimeReasonBefore;
1923
+ next.runtime.lastObservedAt = new Date().toISOString();
1924
+ });
1925
+ // B1: persist BEFORE emitting the event
1926
+ updateMetadata(sessionsDir, sessionName, lifecycleMetadataUpdates(raw, persisted));
1927
+ session.lifecycle = persisted;
1928
+ session.status = deriveLegacyStatus(persisted);
1929
+ recordActivityEvent({
1930
+ projectId: sessionProjectId,
1931
+ sessionId: sessionName,
1932
+ source: "session-manager",
1933
+ kind: "runtime.lost_detected",
1934
+ level: "warn",
1935
+ summary: `runtime lost reconciled: ${sessionName}`,
1936
+ data: {
1937
+ runtimeState: runtimeStateBefore,
1938
+ runtimeReason: runtimeReasonBefore,
1939
+ },
1940
+ });
1941
+ }
1942
+ catch (err) {
1943
+ // Persist failed — in-memory state is still correct for this request
1944
+ recordActivityEvent({
1945
+ projectId: sessionProjectId,
1946
+ sessionId: sessionName,
1947
+ source: "session-manager",
1948
+ kind: "runtime.lost_persist_failed",
1949
+ level: "error",
1950
+ summary: `runtime_lost persist failed: ${sessionName}`,
1951
+ data: { reason: err instanceof Error ? err.message : String(err) },
1952
+ });
1953
+ }
1954
+ }
1955
+ return session;
1956
+ });
1957
+ const resolved = await Promise.all(tasks);
1958
+ return resolved.filter((session) => session !== null);
1959
+ }
1960
+ async function listCached(projectId) {
1961
+ if (sessionCache && Date.now() < sessionCache.expiresAt) {
1962
+ return projectId
1963
+ ? sessionCache.sessions.filter((session) => session.projectId === projectId)
1964
+ : sessionCache.sessions;
1965
+ }
1966
+ const sessions = await list();
1967
+ sessionCache = {
1968
+ sessions,
1969
+ expiresAt: Date.now() + SESSION_CACHE_TTL_MS,
1970
+ };
1971
+ return projectId ? sessions.filter((session) => session.projectId === projectId) : sessions;
1972
+ }
1973
+ async function get(sessionId) {
1974
+ // Try to find the session in any project's sessions directory
1975
+ for (const [projectId, project] of Object.entries(config.projects)) {
1976
+ const sessionsDir = getProjectSessionsDir(projectId);
1977
+ const raw = readMetadataRaw(sessionsDir, sessionId);
1978
+ if (!raw)
1979
+ continue;
1980
+ // Get file timestamps for createdAt/lastActivityAt
1981
+ let createdAt;
1982
+ let modifiedAt;
1983
+ try {
1984
+ const metaPath = join(sessionsDir, `${sessionId}.json`);
1985
+ const stats = statSync(metaPath);
1986
+ createdAt = stats.birthtime;
1987
+ modifiedAt = stats.mtime;
1988
+ }
1989
+ catch {
1990
+ // If stat fails, timestamps will fall back to current time
1991
+ }
1992
+ const repaired = repairSessionAgentMetadataOnRead(sessionsDir, repairSingleSessionMetadataOnRead(sessionsDir, { sessionName: sessionId, raw, modifiedAt }, project.sessionPrefix), project);
1993
+ const session = metadataToSession(sessionId, repaired.raw, {
1994
+ projectId,
1995
+ sessionPrefix: project.sessionPrefix,
1996
+ createdAt,
1997
+ modifiedAt,
1998
+ workspacePathFallback: project.path,
1999
+ });
2000
+ const selection = resolveSelectionForSession(project, sessionId, repaired.raw);
2001
+ const effectiveAgentName = selection.agentName;
2002
+ const plugins = resolvePlugins(project, effectiveAgentName);
2003
+ await ensureHandleAndEnrich(session, sessionId, sessionsDir, project, effectiveAgentName, plugins);
2004
+ return session;
2005
+ }
2006
+ return null;
2007
+ }
2008
+ async function kill(sessionId, options) {
2009
+ const located = findSessionRecord(sessionId);
2010
+ if (!located) {
2011
+ // Session not found via findSessionRecord — check if it exists with
2012
+ // a terminated lifecycle so auto-cleanup retries don't throw.
2013
+ for (const [killProjectId] of Object.entries(config.projects)) {
2014
+ const sessionsDir = getProjectSessionsDir(killProjectId);
2015
+ const raw = readMetadataRaw(sessionsDir, sessionId);
2016
+ if (raw) {
2017
+ const lifecycle = parseLifecycleFromRaw(raw);
2018
+ if (lifecycle?.session.state === "terminated") {
2019
+ return { cleaned: false, alreadyTerminated: true };
2020
+ }
2021
+ }
2022
+ }
2023
+ throw new SessionNotFoundError(sessionId);
2024
+ }
2025
+ const { raw, sessionsDir, project, projectId } = located;
2026
+ // Idempotency: if lifecycle already says terminated, don't re-run destroys
2027
+ // (which could double-purge opencode or race with concurrent kills).
2028
+ const existingLifecycle = parseCanonicalLifecycle(raw);
2029
+ if (existingLifecycle?.session.state === "terminated") {
2030
+ return { cleaned: false, alreadyTerminated: true };
2031
+ }
2032
+ const killReason = options?.reason ?? "manually_killed";
2033
+ const cleanupAgent = resolveSelectionForSession(project, sessionId, raw).agentName;
2034
+ // Emit kill_started up-front — this is the only signal that the kill
2035
+ // intent reached the manager (the destroys below are silent on failure).
2036
+ recordActivityEvent({
2037
+ projectId,
2038
+ sessionId,
2039
+ source: "session-manager",
2040
+ kind: "session.kill_started",
2041
+ summary: `kill started: ${sessionId}`,
2042
+ data: { reason: killReason },
2043
+ });
2044
+ // Destroy runtime — prefer handle.runtimeName to find the correct plugin
2045
+ if (raw["runtimeHandle"]) {
2046
+ const handle = safeJsonParse(raw["runtimeHandle"]);
2047
+ if (handle) {
2048
+ const runtimePlugin = registry.get("runtime", handle.runtimeName ??
2049
+ (project ? (project.runtime ?? config.defaults.runtime) : config.defaults.runtime));
2050
+ if (runtimePlugin) {
2051
+ try {
2052
+ await runtimePlugin.destroy(handle);
2053
+ }
2054
+ catch (err) {
2055
+ // Runtime might already be gone — surface as AE so leaks are queryable.
2056
+ recordActivityEvent({
2057
+ projectId,
2058
+ sessionId,
2059
+ source: "session-manager",
2060
+ kind: "runtime.destroy_failed",
2061
+ level: "warn",
2062
+ summary: `runtime.destroy failed during kill: ${sessionId}`,
2063
+ data: {
2064
+ runtime: handle.runtimeName ?? null,
2065
+ reason: err instanceof Error ? err.message : String(err),
2066
+ },
2067
+ });
2068
+ }
2069
+ }
2070
+ }
2071
+ }
2072
+ const worktree = raw["worktree"];
2073
+ if (worktree && shouldDestroyWorkspacePath(project, projectId, worktree)) {
2074
+ const workspacePlugin = project
2075
+ ? resolvePlugins(project).workspace
2076
+ : registry.get("workspace", config.defaults.workspace);
2077
+ if (workspacePlugin) {
2078
+ try {
2079
+ await workspacePlugin.destroy(worktree);
2080
+ }
2081
+ catch (err) {
2082
+ // Workspace might already be gone — emit AE so abandoned worktrees
2083
+ // surface for cleanup tooling.
2084
+ recordActivityEvent({
2085
+ projectId,
2086
+ sessionId,
2087
+ source: "session-manager",
2088
+ kind: "workspace.destroy_failed",
2089
+ level: "warn",
2090
+ summary: `workspace.destroy failed during kill: ${sessionId}`,
2091
+ data: {
2092
+ workspace: workspacePlugin.name,
2093
+ reason: err instanceof Error ? err.message : String(err),
2094
+ },
2095
+ });
2096
+ }
2097
+ }
2098
+ }
2099
+ let didPurgeOpenCodeSession = false;
2100
+ if (options?.purgeOpenCode === true && cleanupAgent === "opencode") {
2101
+ const mappedOpenCodeSessionId = asValidOpenCodeSessionId(raw["opencodeSessionId"]) ??
2102
+ (await discoverOpenCodeSessionIdByTitle(sessionId, OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS));
2103
+ if (mappedOpenCodeSessionId) {
2104
+ try {
2105
+ await deleteOpenCodeSession(mappedOpenCodeSessionId);
2106
+ didPurgeOpenCodeSession = true;
2107
+ }
2108
+ catch (err) {
2109
+ // Dangling opencode session is a real leak — surface for RCA.
2110
+ recordActivityEvent({
2111
+ projectId,
2112
+ sessionId,
2113
+ source: "session-manager",
2114
+ kind: "agent.opencode_purge_failed",
2115
+ level: "warn",
2116
+ summary: `opencode session purge failed: ${sessionId}`,
2117
+ data: {
2118
+ opencodeSessionId: mappedOpenCodeSessionId,
2119
+ reason: err instanceof Error ? err.message : String(err),
2120
+ },
2121
+ });
2122
+ }
2123
+ }
2124
+ }
2125
+ const runtimeReason = killReason === "pr_merged"
2126
+ ? "pr_merged_cleanup"
2127
+ : killReason === "auto_cleanup"
2128
+ ? "auto_cleanup"
2129
+ : "manual_kill_requested";
2130
+ const terminatedLifecycle = buildUpdatedLifecycle(sessionId, raw, (next) => {
2131
+ next.session.state = "terminated";
2132
+ next.session.reason = killReason;
2133
+ next.session.terminatedAt = new Date().toISOString();
2134
+ next.session.lastTransitionAt = next.session.terminatedAt;
2135
+ next.runtime.state = raw["runtimeHandle"] || raw["tmuxName"] ? "missing" : "exited";
2136
+ next.runtime.reason = runtimeReason;
2137
+ next.runtime.lastObservedAt = new Date().toISOString();
2138
+ });
2139
+ updateMetadata(sessionsDir, sessionId, {
2140
+ ...lifecycleMetadataUpdates(raw, terminatedLifecycle),
2141
+ ...(didPurgeOpenCodeSession && {
2142
+ opencodeSessionId: "",
2143
+ opencodeCleanedAt: new Date().toISOString(),
2144
+ }),
2145
+ });
2146
+ invalidateCache();
2147
+ recordActivityEvent({
2148
+ projectId,
2149
+ sessionId,
2150
+ source: "session-manager",
2151
+ kind: "session.killed",
2152
+ summary: `killed: ${sessionId}`,
2153
+ data: { reason: killReason },
2154
+ });
2155
+ return { cleaned: true, alreadyTerminated: false };
2156
+ }
2157
+ async function cleanup(projectId, options) {
2158
+ const result = { killed: [], skipped: [], errors: [] };
2159
+ const sessions = await list(projectId);
2160
+ const killedKeys = new Set();
2161
+ const skippedKeys = new Set();
2162
+ const toEntryKey = (entryProjectId, id) => `${entryProjectId}:${id}`;
2163
+ const fromEntryKey = (entryKey) => {
2164
+ const separatorIndex = entryKey.indexOf(":");
2165
+ if (separatorIndex === -1) {
2166
+ return { projectId: "", id: entryKey };
2167
+ }
2168
+ return {
2169
+ projectId: entryKey.slice(0, separatorIndex),
2170
+ id: entryKey.slice(separatorIndex + 1),
2171
+ };
2172
+ };
2173
+ const pushKilled = (entryProjectId, id) => {
2174
+ const key = toEntryKey(entryProjectId, id);
2175
+ skippedKeys.delete(key);
2176
+ killedKeys.add(key);
2177
+ };
2178
+ const pushSkipped = (entryProjectId, id) => {
2179
+ const key = toEntryKey(entryProjectId, id);
2180
+ if (killedKeys.has(key))
2181
+ return;
2182
+ skippedKeys.add(key);
2183
+ };
2184
+ const shouldPurgeOpenCode = options?.purgeOpenCode !== false;
2185
+ for (const session of sessions) {
2186
+ try {
2187
+ const project = config.projects[session.projectId];
2188
+ if (!project) {
2189
+ pushSkipped(session.projectId, session.id);
2190
+ continue;
2191
+ }
2192
+ if (isCleanupProtectedSession(project, session.id, session.metadata)) {
2193
+ pushSkipped(session.projectId, session.id);
2194
+ continue;
2195
+ }
2196
+ const plugins = resolvePlugins(project);
2197
+ let shouldKill = false;
2198
+ // Check if all tracked PRs are closed without merging.
2199
+ // For multi-PR sessions, keep alive as long as any PR is still open.
2200
+ const prsToCheck = session.prs.length > 0 ? session.prs : session.pr ? [session.pr] : [];
2201
+ if (prsToCheck.length > 0 && plugins.scm) {
2202
+ try {
2203
+ const states = await Promise.all(prsToCheck.map((pr) => plugins.scm.getPRState(pr)));
2204
+ if (states.every((state) => state === PR_STATE.CLOSED)) {
2205
+ shouldKill = true;
2206
+ }
2207
+ }
2208
+ catch {
2209
+ // Can't check PR — skip
2210
+ }
2211
+ }
2212
+ // Check if issue is completed
2213
+ if (!shouldKill && session.issueId && plugins.tracker) {
2214
+ try {
2215
+ const completed = await plugins.tracker.isCompleted(session.issueId, project);
2216
+ if (completed)
2217
+ shouldKill = true;
2218
+ }
2219
+ catch {
2220
+ // Can't check issue — skip
2221
+ }
2222
+ }
2223
+ // Check if runtime is dead
2224
+ if (!shouldKill && session.runtimeHandle && plugins.runtime) {
2225
+ try {
2226
+ const alive = await plugins.runtime.isAlive(session.runtimeHandle);
2227
+ if (!alive)
2228
+ shouldKill = true;
2229
+ }
2230
+ catch {
2231
+ // Can't check — skip
2232
+ }
2233
+ }
2234
+ if (shouldKill) {
2235
+ if (!options?.dryRun) {
2236
+ await kill(session.id, { purgeOpenCode: shouldPurgeOpenCode });
2237
+ }
2238
+ pushKilled(session.projectId, session.id);
2239
+ }
2240
+ else {
2241
+ pushSkipped(session.projectId, session.id);
2242
+ }
2243
+ }
2244
+ catch (err) {
2245
+ const errorMessage = err instanceof Error ? err.message : String(err);
2246
+ result.errors.push({
2247
+ sessionId: session.id,
2248
+ error: errorMessage,
2249
+ });
2250
+ recordActivityEvent({
2251
+ projectId: session.projectId,
2252
+ sessionId: session.id,
2253
+ source: "session-manager",
2254
+ kind: "session.cleanup_error",
2255
+ level: "warn",
2256
+ summary: `cleanup error: ${session.id}`,
2257
+ data: { reason: errorMessage },
2258
+ });
2259
+ }
2260
+ }
2261
+ // Clean up terminated sessions with uncleaned OpenCode mappings.
2262
+ // These sessions are already in sessions/ (returned by listMetadata) and may
2263
+ // have been processed by the first loop via list(). We skip sessions that were
2264
+ // already killed above, but still process those that were only skipped.
2265
+ for (const [projectKey, project] of Object.entries(config.projects)) {
2266
+ if (projectId && projectKey !== projectId)
2267
+ continue;
2268
+ const sessionsDir = getProjectSessionsDir(projectKey);
2269
+ for (const terminatedId of listMetadata(sessionsDir)) {
2270
+ const entryKey = toEntryKey(projectKey, terminatedId);
2271
+ if (killedKeys.has(entryKey))
2272
+ continue;
2273
+ const terminatedRaw = readMetadataRaw(sessionsDir, terminatedId);
2274
+ if (!terminatedRaw)
2275
+ continue;
2276
+ const lifecycle = parseLifecycleFromRaw(terminatedRaw);
2277
+ if (lifecycle?.session.state !== "terminated")
2278
+ continue;
2279
+ if (isCleanupProtectedSession(project, terminatedId, terminatedRaw)) {
2280
+ pushSkipped(projectKey, terminatedId);
2281
+ continue;
2282
+ }
2283
+ const cleanupAgent = resolveSelectionForSession(project, terminatedId, terminatedRaw).agentName;
2284
+ const mappedOpenCodeSessionId = asValidOpenCodeSessionId(terminatedRaw["opencodeSessionId"]);
2285
+ if (cleanupAgent === "opencode" && terminatedRaw["opencodeCleanedAt"]) {
2286
+ pushSkipped(projectKey, terminatedId);
2287
+ continue;
2288
+ }
2289
+ if (cleanupAgent === "opencode" && mappedOpenCodeSessionId && shouldPurgeOpenCode) {
2290
+ if (!options?.dryRun) {
2291
+ try {
2292
+ await deleteOpenCodeSession(mappedOpenCodeSessionId);
2293
+ mutateMetadata(sessionsDir, terminatedId, (existing) => ({
2294
+ ...existing,
2295
+ opencodeSessionId: "",
2296
+ opencodeCleanedAt: new Date().toISOString(),
2297
+ }), { activityEventSource: "session-manager" });
2298
+ }
2299
+ catch (err) {
2300
+ const errorMessage = err instanceof Error ? err.message : String(err);
2301
+ result.errors.push({
2302
+ sessionId: terminatedId,
2303
+ error: `Failed to delete OpenCode session ${mappedOpenCodeSessionId}: ${errorMessage}`,
2304
+ });
2305
+ recordActivityEvent({
2306
+ projectId: projectKey,
2307
+ sessionId: terminatedId,
2308
+ source: "session-manager",
2309
+ kind: "agent.opencode_purge_failed",
2310
+ level: "warn",
2311
+ summary: `opencode session purge failed during cleanup: ${terminatedId}`,
2312
+ data: {
2313
+ opencodeSessionId: mappedOpenCodeSessionId,
2314
+ reason: errorMessage,
2315
+ },
2316
+ });
2317
+ continue;
2318
+ }
2319
+ }
2320
+ pushKilled(projectKey, terminatedId);
2321
+ }
2322
+ else {
2323
+ pushSkipped(projectKey, terminatedId);
2324
+ }
2325
+ }
2326
+ }
2327
+ const allEntryKeys = [...killedKeys, ...skippedKeys];
2328
+ const idCounts = new Map();
2329
+ for (const entryKey of allEntryKeys) {
2330
+ const { id } = fromEntryKey(entryKey);
2331
+ idCounts.set(id, (idCounts.get(id) ?? 0) + 1);
2332
+ }
2333
+ const formatEntry = (entryKey) => {
2334
+ const { projectId: entryProjectId, id } = fromEntryKey(entryKey);
2335
+ return (idCounts.get(id) ?? 0) > 1 ? `${entryProjectId}:${id}` : id;
2336
+ };
2337
+ result.killed = [...killedKeys].map(formatEntry);
2338
+ result.skipped = [...skippedKeys].map(formatEntry);
2339
+ return result;
2340
+ }
2341
+ async function send(sessionId, message) {
2342
+ const { raw, sessionsDir, project, projectId } = requireSessionRecord(sessionId);
2343
+ const selection = resolveSelectionForSession(project, sessionId, raw);
2344
+ const selectedAgent = selection.agentName;
2345
+ if (selectedAgent === "opencode" && !asValidOpenCodeSessionId(raw["opencodeSessionId"])) {
2346
+ const discovered = await discoverOpenCodeSessionIdByTitle(sessionId, OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS);
2347
+ if (discovered) {
2348
+ raw["opencodeSessionId"] = discovered;
2349
+ updateMetadata(sessionsDir, sessionId, { opencodeSessionId: discovered });
2350
+ invalidateCache();
2351
+ }
2352
+ }
2353
+ const parsedHandle = raw["runtimeHandle"]
2354
+ ? safeJsonParse(raw["runtimeHandle"])
2355
+ : null;
2356
+ const runtimeName = parsedHandle?.runtimeName ?? project.runtime ?? config.defaults.runtime;
2357
+ const agentName = selectedAgent;
2358
+ const runtimePlugin = registry.get("runtime", runtimeName);
2359
+ if (!runtimePlugin) {
2360
+ throw new Error(`No runtime plugin for session ${sessionId}`);
2361
+ }
2362
+ const agentPlugin = registry.get("agent", agentName);
2363
+ if (!agentPlugin) {
2364
+ throw new Error(`No agent plugin for session ${sessionId}`);
2365
+ }
2366
+ const captureOutput = async (handle) => {
2367
+ try {
2368
+ return (await runtimePlugin.getOutput(handle, SEND_CONFIRMATION_OUTPUT_LINES)) ?? "";
2369
+ }
2370
+ catch {
2371
+ return "";
2372
+ }
2373
+ };
2374
+ const detectActivityFromOutput = (output) => {
2375
+ if (!output)
2376
+ return null;
2377
+ try {
2378
+ return agentPlugin.detectActivity(output);
2379
+ }
2380
+ catch {
2381
+ return null;
2382
+ }
2383
+ };
2384
+ const hasQueuedMessage = (output) => {
2385
+ return output.includes("Press up to edit queued messages");
2386
+ };
2387
+ const getOpenCodeSessionUpdatedAt = async () => {
2388
+ const mappedSessionId = asValidOpenCodeSessionId(raw["opencodeSessionId"]);
2389
+ if (agentName !== "opencode" || !mappedSessionId) {
2390
+ return undefined;
2391
+ }
2392
+ const sessions = await fetchOpenCodeSessionList(OPENCODE_DISCOVERY_TIMEOUT_MS);
2393
+ return sessions.find((entry) => entry.id === mappedSessionId)?.updatedAt;
2394
+ };
2395
+ const waitForInteractiveReadiness = async (session, timeoutMs) => {
2396
+ const handle = session.runtimeHandle;
2397
+ if (!handle) {
2398
+ return;
2399
+ }
2400
+ const deadline = Date.now() + timeoutMs;
2401
+ let lastSettledOutput = null;
2402
+ let stablePolls = 0;
2403
+ while (true) {
2404
+ const [runtimeAlive, processRunning, output, foregroundCommand] = await Promise.all([
2405
+ runtimePlugin.isAlive(handle).catch(() => true),
2406
+ isAgentProcessNotDefinitelyMissing(agentPlugin, handle),
2407
+ captureOutput(handle),
2408
+ handle.runtimeName === "tmux"
2409
+ ? getTmuxForegroundCommand(handle.id)
2410
+ : Promise.resolve(agentPlugin.processName),
2411
+ ]);
2412
+ const outputReady = output.trim().length > 0;
2413
+ const foregroundReady = foregroundCommand === null || foregroundCommand === agentPlugin.processName;
2414
+ const settledOutput = outputReady ? output.trimEnd() : null;
2415
+ const isStable = settledOutput !== null && settledOutput === lastSettledOutput;
2416
+ if (runtimeAlive &&
2417
+ processRunning &&
2418
+ foregroundReady &&
2419
+ (hasQueuedMessage(output) || isStable)) {
2420
+ stablePolls += 1;
2421
+ if (stablePolls >= SEND_BOOTSTRAP_STABLE_POLLS) {
2422
+ return;
2423
+ }
2424
+ }
2425
+ else {
2426
+ stablePolls = 0;
2427
+ }
2428
+ lastSettledOutput = settledOutput;
2429
+ if (Date.now() >= deadline) {
2430
+ return;
2431
+ }
2432
+ await sleep(SEND_RESTORE_READY_POLL_MS);
2433
+ }
2434
+ };
2435
+ const waitForRestoredSession = async (restoredSession) => {
2436
+ const handle = restoredSession.runtimeHandle;
2437
+ if (!handle) {
2438
+ return false;
2439
+ }
2440
+ const deadline = Date.now() + SEND_RESTORE_READY_TIMEOUT_MS;
2441
+ while (true) {
2442
+ const [runtimeAlive, processRunning, output, foregroundCommand] = await Promise.all([
2443
+ runtimePlugin.isAlive(handle).catch(() => true),
2444
+ isAgentProcessNotDefinitelyMissing(agentPlugin, handle),
2445
+ captureOutput(handle),
2446
+ handle.runtimeName === "tmux"
2447
+ ? getTmuxForegroundCommand(handle.id)
2448
+ : Promise.resolve(agentPlugin.processName),
2449
+ ]);
2450
+ const foregroundReady = foregroundCommand === null || foregroundCommand === agentPlugin.processName;
2451
+ if (runtimeAlive && foregroundReady && (processRunning || output.trim().length > 0)) {
2452
+ return true;
2453
+ }
2454
+ if (Date.now() >= deadline) {
2455
+ return false;
2456
+ }
2457
+ await sleep(SEND_RESTORE_READY_POLL_MS);
2458
+ }
2459
+ };
2460
+ const restoreForDelivery = async (reason, session) => {
2461
+ if (session.lifecycle.session.state === "done") {
2462
+ throw new Error(`Cannot send to session ${sessionId}: ${reason}`);
2463
+ }
2464
+ let restored;
2465
+ try {
2466
+ restored = await restore(sessionId);
2467
+ }
2468
+ catch (err) {
2469
+ const detail = err instanceof Error ? err.message : String(err);
2470
+ throw new Error(`Cannot send to session ${sessionId}: ${reason} (${detail})`, {
2471
+ cause: err,
2472
+ });
2473
+ }
2474
+ const ready = await waitForRestoredSession(restored);
2475
+ if (!ready) {
2476
+ const detail = "restored session did not become ready for delivery";
2477
+ recordActivityEvent({
2478
+ projectId,
2479
+ sessionId,
2480
+ source: "session-manager",
2481
+ kind: "session.restore_failed",
2482
+ level: "error",
2483
+ summary: `restore for delivery failed: ${sessionId}`,
2484
+ data: { stage: "ready_timeout", reason: detail, trigger: "send" },
2485
+ });
2486
+ throw new Error(`Cannot send to session ${sessionId}: ${reason} (${detail})`);
2487
+ }
2488
+ return restored;
2489
+ };
2490
+ const prepareSession = async (forceRestore = false) => {
2491
+ const current = await get(sessionId);
2492
+ if (!current) {
2493
+ throw new SessionNotFoundError(sessionId);
2494
+ }
2495
+ const handle = current.runtimeHandle ??
2496
+ {
2497
+ id: sessionId,
2498
+ runtimeName,
2499
+ data: {},
2500
+ };
2501
+ const normalized = current.runtimeHandle ? current : { ...current, runtimeHandle: handle };
2502
+ if (forceRestore || isRestorable(normalized)) {
2503
+ return restoreForDelivery(forceRestore
2504
+ ? "session needed to be restarted before delivery"
2505
+ : "session is not running", normalized);
2506
+ }
2507
+ let [runtimeAlive, processRunning] = await Promise.all([
2508
+ runtimePlugin.isAlive(handle).catch(() => true),
2509
+ isAgentProcessNotDefinitelyMissing(agentPlugin, handle),
2510
+ ]);
2511
+ if (normalized.status === "spawning" && runtimeAlive) {
2512
+ await waitForInteractiveReadiness(normalized, SEND_BOOTSTRAP_READY_TIMEOUT_MS);
2513
+ [runtimeAlive, processRunning] = await Promise.all([
2514
+ runtimePlugin.isAlive(handle).catch(() => true),
2515
+ isAgentProcessNotDefinitelyMissing(agentPlugin, handle),
2516
+ ]);
2517
+ }
2518
+ if (!runtimeAlive || !processRunning) {
2519
+ return restoreForDelivery(!runtimeAlive ? "runtime is not alive" : "agent process is not running", normalized);
2520
+ }
2521
+ return normalized;
2522
+ };
2523
+ const sendWithConfirmation = async (session) => {
2524
+ const handle = session.runtimeHandle;
2525
+ if (!handle) {
2526
+ throw new Error(`Session ${sessionId} has no runtime handle`);
2527
+ }
2528
+ const baselineOutput = await captureOutput(handle);
2529
+ const baselineActivity = detectActivityFromOutput(baselineOutput) ?? session.activity;
2530
+ const baselineUpdatedAt = await getOpenCodeSessionUpdatedAt();
2531
+ await runtimePlugin.sendMessage(handle, message);
2532
+ for (let attempt = 1; attempt <= SEND_CONFIRMATION_ATTEMPTS; attempt++) {
2533
+ // Sleep before each check (including the first) so the runtime has time
2534
+ // to reflect the message in its output.
2535
+ await sleep(SEND_CONFIRMATION_POLL_MS);
2536
+ const output = await captureOutput(handle);
2537
+ const activity = detectActivityFromOutput(output) ?? session.activity;
2538
+ const updatedAt = await getOpenCodeSessionUpdatedAt();
2539
+ const delivered = (baselineUpdatedAt !== undefined &&
2540
+ updatedAt !== undefined &&
2541
+ updatedAt > baselineUpdatedAt) ||
2542
+ hasQueuedMessage(output) ||
2543
+ (output.length > 0 && output !== baselineOutput) ||
2544
+ (baselineActivity !== "active" && activity === "active") ||
2545
+ (baselineActivity !== "waiting_input" && activity === "waiting_input");
2546
+ if (delivered) {
2547
+ return;
2548
+ }
2549
+ }
2550
+ // Message was already sent via runtimePlugin.sendMessage above — if we
2551
+ // cannot *confirm* delivery (e.g. agent is slow to show output), treat it
2552
+ // as a soft success rather than throwing. Throwing here caused the caller
2553
+ // to report failure, which prevented the dispatch-hash from updating and
2554
+ // led to duplicate messages on the next poll cycle.
2555
+ return;
2556
+ };
2557
+ // Top-level try/catch: any final send failure (initial preparation,
2558
+ // retry-with-restore, etc.) emits a single `session.send_failed` event
2559
+ // (B16 — failure-only). Stage tag distinguishes which branch failed.
2560
+ let stage = "prepare";
2561
+ try {
2562
+ let prepared = await prepareSession();
2563
+ try {
2564
+ stage = "initial";
2565
+ await sendWithConfirmation(prepared);
2566
+ }
2567
+ catch (err) {
2568
+ const shouldRetryWithRestore = prepared.restoredAt === undefined && isRestorable(prepared);
2569
+ if (!shouldRetryWithRestore) {
2570
+ if (err instanceof Error) {
2571
+ throw err;
2572
+ }
2573
+ throw new Error(String(err), { cause: err });
2574
+ }
2575
+ stage = "restore_retry";
2576
+ prepared = await prepareSession(true);
2577
+ try {
2578
+ await sendWithConfirmation(prepared);
2579
+ }
2580
+ catch (retryErr) {
2581
+ if (retryErr instanceof Error) {
2582
+ throw retryErr;
2583
+ }
2584
+ throw new Error(String(retryErr), { cause: retryErr });
2585
+ }
2586
+ }
2587
+ }
2588
+ catch (err) {
2589
+ recordActivityEvent({
2590
+ projectId,
2591
+ sessionId,
2592
+ source: "session-manager",
2593
+ kind: "session.send_failed",
2594
+ level: "error",
2595
+ summary: `send failed: ${sessionId}`,
2596
+ data: {
2597
+ stage,
2598
+ reason: err instanceof Error ? err.message : String(err),
2599
+ },
2600
+ });
2601
+ throw err;
2602
+ }
2603
+ }
2604
+ async function claimPR(sessionId, prRef, options) {
2605
+ const reference = prRef.trim();
2606
+ if (!reference)
2607
+ throw new Error("PR reference is required");
2608
+ const { raw, sessionsDir, project, projectId } = requireSessionRecord(sessionId);
2609
+ if (isOrchestratorSessionRecord(sessionId, raw, project.sessionPrefix)) {
2610
+ throw new Error(`Session ${sessionId} is an orchestrator session and cannot claim PRs`);
2611
+ }
2612
+ const plugins = resolvePlugins(project, resolveSelectionForSession(project, sessionId, raw).agentName);
2613
+ const scm = plugins.scm;
2614
+ if (!scm?.resolvePR || !scm.checkoutPR) {
2615
+ throw new Error(`SCM plugin ${project.scm?.plugin ? `"${project.scm.plugin}" ` : ""}does not support claiming existing PRs`);
2616
+ }
2617
+ const pr = await scm.resolvePR(reference, project);
2618
+ const prState = await scm.getPRState(pr);
2619
+ if (prState !== PR_STATE.OPEN) {
2620
+ throw new Error(`Cannot claim PR #${pr.number} because it is ${prState}`);
2621
+ }
2622
+ const conflictingSessions = new Set();
2623
+ const activeRecords = loadActiveSessionRecords(projectId, project).filter((record) => record.sessionName !== sessionId);
2624
+ for (const { sessionName, raw: otherRaw } of activeRecords) {
2625
+ if (!otherRaw || isOrchestratorSessionRecord(sessionName, otherRaw, project.sessionPrefix))
2626
+ continue;
2627
+ const otherPrUrls = new Set([
2628
+ otherRaw["pr"],
2629
+ ...(typeof otherRaw["prs"] === "string" ? otherRaw["prs"].split(",") : []),
2630
+ ]
2631
+ .map((u) => (typeof u === "string" ? u.trim() : ""))
2632
+ .filter(Boolean));
2633
+ const samePr = otherPrUrls.has(pr.url);
2634
+ const sameBranch = otherRaw["branch"] === pr.branch && (otherRaw["prAutoDetect"] ?? "on") !== "off" && otherRaw["prAutoDetect"] !== "false";
2635
+ if (samePr || sameBranch) {
2636
+ conflictingSessions.add(sessionName);
2637
+ }
2638
+ }
2639
+ const takenOverFrom = [...conflictingSessions];
2640
+ const workspacePath = raw["worktree"];
2641
+ if (!workspacePath) {
2642
+ throw new Error(`Session ${sessionId} has no workspace to check out PR #${pr.number}`);
2643
+ }
2644
+ const branchChanged = await scm.checkoutPR(pr, workspacePath);
2645
+ const claimLifecycle = buildUpdatedLifecycle(sessionId, raw, (next) => {
2646
+ next.pr.state = "open";
2647
+ next.pr.reason = "in_progress";
2648
+ next.pr.number = pr.number;
2649
+ next.pr.url = pr.url;
2650
+ next.pr.lastObservedAt = new Date().toISOString();
2651
+ });
2652
+ // Stack: push claimed PR to front — it becomes primary (prs[0]) on next load.
2653
+ // Filter out duplicates, keep all other tracked PRs at the back.
2654
+ const existingPrs = raw["prs"] ?? raw["pr"] ?? "";
2655
+ const otherPrs = dedupePrUrls(existingPrs.split(",").filter((u) => u.trim() !== pr.url)).join(",");
2656
+ const newPrs = otherPrs ? `${pr.url},${otherPrs}` : pr.url;
2657
+ // Clear stale positional enrichment blobs — claimPR reorders prs[] so
2658
+ // index-keyed blobs no longer match. Lifecycle poll rewrites them within ~30s.
2659
+ const staleEnrichmentKeys = {
2660
+ prEnrichment: "",
2661
+ prReviewComments: "",
2662
+ };
2663
+ for (const key of Object.keys(raw)) {
2664
+ if (/^prEnrichment_\d+$/.test(key) || /^prReviewComments_\d+$/.test(key)) {
2665
+ staleEnrichmentKeys[key] = "";
2666
+ }
2667
+ }
2668
+ updateMetadata(sessionsDir, sessionId, {
2669
+ pr: pr.url,
2670
+ prs: newPrs,
2671
+ status: deriveLegacyStatus(claimLifecycle),
2672
+ branch: pr.branch,
2673
+ prAutoDetect: "",
2674
+ ...staleEnrichmentKeys,
2675
+ ...lifecycleMetadataUpdates(raw, claimLifecycle),
2676
+ });
2677
+ invalidateCache();
2678
+ for (const previousSessionId of takenOverFrom) {
2679
+ const previousRaw = readMetadataRaw(sessionsDir, previousSessionId);
2680
+ if (!previousRaw)
2681
+ continue;
2682
+ const previousLifecycle = buildUpdatedLifecycle(previousSessionId, previousRaw, (next) => {
2683
+ next.pr.state = "none";
2684
+ next.pr.reason = "not_created";
2685
+ next.pr.number = null;
2686
+ next.pr.url = null;
2687
+ next.pr.lastObservedAt = null;
2688
+ if (PR_TRACKING_STATUSES.has(previousRaw["status"] ?? "")) {
2689
+ next.session.state = "working";
2690
+ next.session.reason = "task_in_progress";
2691
+ }
2692
+ });
2693
+ updateMetadata(sessionsDir, previousSessionId, {
2694
+ pr: "",
2695
+ prs: "",
2696
+ prAutoDetect: "false",
2697
+ ...(PR_TRACKING_STATUSES.has(previousRaw["status"] ?? "")
2698
+ ? { status: "working" }
2699
+ : {}),
2700
+ ...lifecycleMetadataUpdates(previousRaw, previousLifecycle),
2701
+ });
2702
+ invalidateCache();
2703
+ }
2704
+ let githubAssigned = false;
2705
+ let githubAssignmentError;
2706
+ if (options?.assignOnGithub) {
2707
+ if (!scm.assignPRToCurrentUser) {
2708
+ githubAssignmentError = `SCM plugin "${scm.name}" does not support assigning PRs`;
2709
+ }
2710
+ else {
2711
+ try {
2712
+ await scm.assignPRToCurrentUser(pr);
2713
+ githubAssigned = true;
2714
+ }
2715
+ catch (err) {
2716
+ githubAssignmentError = err instanceof Error ? err.message : String(err);
2717
+ }
2718
+ }
2719
+ }
2720
+ return {
2721
+ sessionId,
2722
+ projectId,
2723
+ pr,
2724
+ branchChanged,
2725
+ githubAssigned,
2726
+ githubAssignmentError,
2727
+ takenOverFrom,
2728
+ };
2729
+ }
2730
+ async function remap(sessionId, force = false) {
2731
+ const { raw, sessionsDir, project } = requireSessionRecord(sessionId);
2732
+ const selection = resolveSelectionForSession(project, sessionId, raw);
2733
+ const selectedAgent = selection.agentName;
2734
+ if (selectedAgent !== "opencode") {
2735
+ throw new Error(`Session ${sessionId} is not using the opencode agent`);
2736
+ }
2737
+ const mapped = asValidOpenCodeSessionId(raw["opencodeSessionId"]);
2738
+ const discovered = force
2739
+ ? await discoverOpenCodeSessionIdByTitle(sessionId, OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS)
2740
+ : (mapped ??
2741
+ (await discoverOpenCodeSessionIdByTitle(sessionId, OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS)));
2742
+ if (!discovered) {
2743
+ throw new Error(`OpenCode session mapping is missing for ${sessionId}`);
2744
+ }
2745
+ updateMetadata(sessionsDir, sessionId, { opencodeSessionId: discovered });
2746
+ return discovered;
2747
+ }
2748
+ async function restore(sessionId) {
2749
+ // 1. Find session metadata across all projects
2750
+ const activeRecord = findSessionRecord(sessionId);
2751
+ if (!activeRecord) {
2752
+ throw new SessionNotFoundError(sessionId);
2753
+ }
2754
+ let raw = activeRecord.raw;
2755
+ const sessionsDir = activeRecord.sessionsDir;
2756
+ const project = activeRecord.project;
2757
+ const projectId = activeRecord.projectId;
2758
+ const selection = resolveSelectionForSession(project, sessionId, raw);
2759
+ const selectedAgent = selection.agentName;
2760
+ if (selectedAgent === "opencode" && !asValidOpenCodeSessionId(raw["opencodeSessionId"])) {
2761
+ const discovered = await discoverOpenCodeSessionIdByTitle(sessionId, OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS);
2762
+ if (!discovered) {
2763
+ throw new SessionNotRestorableError(sessionId, "OpenCode session mapping is missing");
2764
+ }
2765
+ raw = { ...raw, opencodeSessionId: discovered };
2766
+ updateMetadata(sessionsDir, sessionId, { opencodeSessionId: discovered });
2767
+ }
2768
+ // 2. Reconstruct Session from metadata and enrich with live runtime state.
2769
+ // metadataToSession sets activity: null, so without enrichment a crashed
2770
+ // session (status "working", agent exited) would not be detected as terminal
2771
+ // and isRestorable would reject it.
2772
+ const session = metadataToSession(sessionId, raw, {
2773
+ projectId,
2774
+ sessionPrefix: project.sessionPrefix,
2775
+ workspacePathFallback: project.path,
2776
+ });
2777
+ const plugins = resolvePlugins(project, selection.agentName);
2778
+ await enrichSessionWithRuntimeState(session, plugins, true, sessionsDir);
2779
+ // 3. Validate restorability
2780
+ if (!isRestorable(session)) {
2781
+ const reason = NON_RESTORABLE_STATUSES.has(session.status)
2782
+ ? `status "${session.status}" is not restorable`
2783
+ : `session is not in a terminal state (status: "${session.status}", activity: "${session.activity}")`;
2784
+ recordActivityEvent({
2785
+ projectId,
2786
+ sessionId,
2787
+ source: "session-manager",
2788
+ kind: "session.restore_failed",
2789
+ level: "error",
2790
+ summary: `restore not allowed: ${sessionId}`,
2791
+ data: {
2792
+ stage: "validation",
2793
+ status: session.status,
2794
+ activity: session.activity,
2795
+ reason,
2796
+ },
2797
+ });
2798
+ throw new SessionNotRestorableError(sessionId, reason);
2799
+ }
2800
+ // 4. Validate required plugins (plugins already resolved above for enrichment)
2801
+ if (!plugins.runtime) {
2802
+ throw new Error(`Runtime plugin '${project.runtime ?? config.defaults.runtime}' not found`);
2803
+ }
2804
+ if (!plugins.agent) {
2805
+ throw new Error(`Agent plugin '${selection.agentName}' not found`);
2806
+ }
2807
+ // 5. Check workspace
2808
+ const workspacePath = raw["worktree"] || project.path;
2809
+ const workspaceExists = plugins.workspace?.exists
2810
+ ? await plugins.workspace.exists(workspacePath)
2811
+ : existsSync(workspacePath);
2812
+ if (!workspaceExists) {
2813
+ // Try to restore workspace if plugin supports it
2814
+ if (!plugins.workspace?.restore) {
2815
+ recordActivityEvent({
2816
+ projectId,
2817
+ sessionId,
2818
+ source: "session-manager",
2819
+ kind: "session.restore_failed",
2820
+ level: "error",
2821
+ summary: `restore workspace failed: ${sessionId}`,
2822
+ data: {
2823
+ stage: "workspace_restore",
2824
+ workspacePath,
2825
+ reason: "workspace plugin does not support restore",
2826
+ },
2827
+ });
2828
+ throw new WorkspaceMissingError(workspacePath, "workspace plugin does not support restore");
2829
+ }
2830
+ if (!session.branch) {
2831
+ recordActivityEvent({
2832
+ projectId,
2833
+ sessionId,
2834
+ source: "session-manager",
2835
+ kind: "session.restore_failed",
2836
+ level: "error",
2837
+ summary: `restore workspace failed: ${sessionId}`,
2838
+ data: {
2839
+ stage: "workspace_restore",
2840
+ workspacePath,
2841
+ reason: "branch metadata is missing",
2842
+ },
2843
+ });
2844
+ throw new WorkspaceMissingError(workspacePath, "branch metadata is missing");
2845
+ }
2846
+ try {
2847
+ const wsInfo = await plugins.workspace.restore({
2848
+ projectId,
2849
+ project,
2850
+ sessionId,
2851
+ branch: session.branch,
2852
+ worktreeDir: getProjectWorktreesDir(projectId),
2853
+ }, workspacePath);
2854
+ // Run post-create hooks on restored workspace
2855
+ if (plugins.workspace.postCreate) {
2856
+ await plugins.workspace.postCreate(wsInfo, project);
2857
+ }
2858
+ }
2859
+ catch (err) {
2860
+ recordActivityEvent({
2861
+ projectId,
2862
+ sessionId,
2863
+ source: "session-manager",
2864
+ kind: "session.restore_failed",
2865
+ level: "error",
2866
+ summary: `workspace restore failed: ${sessionId}`,
2867
+ data: {
2868
+ stage: "workspace_restore",
2869
+ workspacePath,
2870
+ reason: err instanceof Error ? err.message : String(err),
2871
+ },
2872
+ });
2873
+ throw new WorkspaceMissingError(workspacePath, `restore failed: ${err instanceof Error ? err.message : String(err)}`);
2874
+ }
2875
+ }
2876
+ if (plugins.agent.name === "opencode" && selection.role === "orchestrator") {
2877
+ const projectDir = getProjectDir(projectId);
2878
+ const systemPromptFile = join(projectDir, `orchestrator-prompt-${sessionId}.md`);
2879
+ if (existsSync(systemPromptFile)) {
2880
+ try {
2881
+ writeWorkspaceOpenCodeAgentsMd(workspacePath, systemPromptFile);
2882
+ }
2883
+ catch (err) {
2884
+ throw new Error(`failed to restore OpenCode orchestrator AGENTS.md: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
2885
+ }
2886
+ }
2887
+ }
2888
+ let opencodeConfigPath;
2889
+ if (plugins.agent.name === "opencode" && selection.role !== "orchestrator") {
2890
+ const baseDir = getProjectDir(projectId);
2891
+ const systemPromptFile = join(baseDir, `worker-prompt-${sessionId}.md`);
2892
+ if (existsSync(systemPromptFile)) {
2893
+ opencodeConfigPath = writeOpenCodeConfig(baseDir, sessionId, [systemPromptFile]);
2894
+ }
2895
+ }
2896
+ // 6. Destroy old runtime if still alive (e.g. tmux session survives agent crash)
2897
+ if (session.runtimeHandle) {
2898
+ try {
2899
+ await plugins.runtime.destroy(session.runtimeHandle);
2900
+ }
2901
+ catch {
2902
+ // Best effort — may already be gone
2903
+ }
2904
+ }
2905
+ // 7. Get launch command — try restore command first, fall back to fresh launch
2906
+ let launchCommand;
2907
+ const projectConfigForLaunch = {
2908
+ ...project,
2909
+ agentConfig: {
2910
+ ...selection.agentConfig,
2911
+ ...(selection.role === "orchestrator" ? { permissions: "permissionless" } : {}),
2912
+ ...(session.metadata?.opencodeSessionId
2913
+ ? { opencodeSessionId: session.metadata.opencodeSessionId }
2914
+ : {}),
2915
+ },
2916
+ };
2917
+ // Orchestrator launches need the original systemPromptFile so the agent
2918
+ // boots as the orchestrator (not a bare TUI). spawnOrchestrator wrote it to
2919
+ // {baseDir}/orchestrator-prompt-{sessionId}.md and references it via
2920
+ // agentLaunchConfig.systemPromptFile. On restore we must re-attach it,
2921
+ // otherwise getLaunchCommand() (the fallback when getRestoreCommand returns
2922
+ // null — e.g. Codex with no resumable thread for the worktree) starts a
2923
+ // plain agent without orchestrator instructions.
2924
+ const orchestratorSystemPromptFile = (() => {
2925
+ if (selection.role !== "orchestrator")
2926
+ return undefined;
2927
+ // V2 storage: orchestrator-prompt-{sessionId}.md lives in the project dir
2928
+ // (~/.agent-orchestrator/projects/{projectId}/), not the legacy hashed base dir.
2929
+ const baseDir = getProjectDir(projectId);
2930
+ const file = join(baseDir, `orchestrator-prompt-${sessionId}.md`);
2931
+ return existsSync(file) ? file : undefined;
2932
+ })();
2933
+ const agentLaunchConfig = {
2934
+ sessionId,
2935
+ projectConfig: projectConfigForLaunch,
2936
+ workspacePath,
2937
+ issueId: session.issueId ?? undefined,
2938
+ permissions: selection.role === "orchestrator" ? "permissionless" : selection.permissions,
2939
+ model: selection.model,
2940
+ subagent: selection.subagent,
2941
+ ...(orchestratorSystemPromptFile && { systemPromptFile: orchestratorSystemPromptFile }),
2942
+ };
2943
+ if (plugins.agent.getRestoreCommand) {
2944
+ const restoreCmd = await plugins.agent.getRestoreCommand(session, projectConfigForLaunch);
2945
+ if (restoreCmd) {
2946
+ launchCommand = restoreCmd;
2947
+ updateMetadata(sessionsDir, sessionId, { restoreFallbackReason: "" });
2948
+ }
2949
+ else {
2950
+ // Agents with native restore can still launch fresh when no resumable
2951
+ // session metadata exists; this keeps restore from becoming a hard stop.
2952
+ const reason = `${plugins.agent.name}.getRestoreCommand returned null`;
2953
+ updateMetadata(sessionsDir, sessionId, {
2954
+ restoreFallbackReason: reason,
2955
+ });
2956
+ // Surface that AO fell back to a fresh launch instead of native restore.
2957
+ recordActivityEvent({
2958
+ projectId,
2959
+ sessionId,
2960
+ source: "session-manager",
2961
+ kind: "session.restore_fallback",
2962
+ level: "warn",
2963
+ summary: `using fresh launch instead of native restore: ${sessionId}`,
2964
+ data: { agent: plugins.agent.name, reason },
2965
+ });
2966
+ launchCommand = plugins.agent.getLaunchCommand(agentLaunchConfig);
2967
+ }
2968
+ }
2969
+ else {
2970
+ launchCommand = plugins.agent.getLaunchCommand(agentLaunchConfig);
2971
+ updateMetadata(sessionsDir, sessionId, { restoreFallbackReason: "" });
2972
+ }
2973
+ const environment = plugins.agent.getEnvironment(agentLaunchConfig);
2974
+ if (plugins.agent.preLaunchSetup) {
2975
+ await plugins.agent.preLaunchSetup(workspacePath);
2976
+ }
2977
+ // 8. Create runtime (reuse tmuxName from metadata)
2978
+ const tmuxName = raw["tmuxName"];
2979
+ const handle = await plugins.runtime.create({
2980
+ sessionId: tmuxName ?? sessionId,
2981
+ workspacePath,
2982
+ launchCommand,
2983
+ environment: {
2984
+ ...environment,
2985
+ ...(opencodeConfigPath ? { OPENCODE_CONFIG: opencodeConfigPath } : {}),
2986
+ ...(project.env ?? {}),
2987
+ PATH: buildAgentPath(environment["PATH"] ?? process.env["PATH"]),
2988
+ GH_PATH: PREFERRED_GH_PATH,
2989
+ ...(process.env["AO_AGENT_GH_TRACE"] && {
2990
+ AO_AGENT_GH_TRACE: process.env["AO_AGENT_GH_TRACE"],
2991
+ }),
2992
+ AO_SESSION: sessionId,
2993
+ AO_DATA_DIR: sessionsDir,
2994
+ AO_SESSION_NAME: sessionId,
2995
+ ...(tmuxName && { AO_TMUX_NAME: tmuxName }),
2996
+ AO_CALLER_TYPE: "agent",
2997
+ ...(projectId && { AO_PROJECT_ID: projectId }),
2998
+ AO_CONFIG_PATH: config.configPath,
2999
+ ...(config.port !== undefined && config.port !== null && { AO_PORT: String(config.port) }),
3000
+ },
3001
+ });
3002
+ // 9. Update metadata — reset lifecycle to working state
3003
+ const now = new Date().toISOString();
3004
+ const restoredLifecycle = cloneLifecycle(session.lifecycle);
3005
+ restoredLifecycle.session.state = "working";
3006
+ restoredLifecycle.session.reason = "task_in_progress";
3007
+ restoredLifecycle.session.lastTransitionAt = now;
3008
+ restoredLifecycle.session.terminatedAt = null;
3009
+ restoredLifecycle.session.completedAt = null;
3010
+ restoredLifecycle.runtime.state = "alive";
3011
+ restoredLifecycle.runtime.reason = "process_running";
3012
+ restoredLifecycle.runtime.handle = handle;
3013
+ restoredLifecycle.runtime.lastObservedAt = now;
3014
+ // Reset terminal PR state so the lifecycle manager doesn't immediately
3015
+ // re-terminate the session. The old PR is done — if the agent creates
3016
+ // a new one, PR auto-detect will pick it up.
3017
+ if (restoredLifecycle.pr.state === "merged" || restoredLifecycle.pr.state === "closed") {
3018
+ restoredLifecycle.pr.state = "none";
3019
+ restoredLifecycle.pr.reason = "cleared_on_restore";
3020
+ restoredLifecycle.pr.number = null;
3021
+ restoredLifecycle.pr.url = null;
3022
+ restoredLifecycle.pr.lastObservedAt = null;
3023
+ }
3024
+ updateMetadata(sessionsDir, sessionId, {
3025
+ ...buildLifecycleMetadataPatch(restoredLifecycle),
3026
+ agent: selection.agentName,
3027
+ restoredAt: now,
3028
+ mergedPendingCleanupSince: "",
3029
+ });
3030
+ invalidateCache();
3031
+ // 10. Run postLaunchSetup (non-fatal)
3032
+ const restoredStatus = deriveLegacyStatus(restoredLifecycle);
3033
+ const restoredSession = {
3034
+ ...session,
3035
+ status: restoredStatus,
3036
+ activity: "active",
3037
+ workspacePath,
3038
+ runtimeHandle: handle,
3039
+ restoredAt: new Date(now),
3040
+ };
3041
+ if (plugins.agent.postLaunchSetup) {
3042
+ try {
3043
+ const metadataBeforePostLaunch = { ...(restoredSession.metadata ?? {}) };
3044
+ await plugins.agent.postLaunchSetup(restoredSession);
3045
+ const metadataAfterPostLaunch = restoredSession.metadata ?? {};
3046
+ const metadataUpdates = Object.fromEntries(Object.entries(metadataAfterPostLaunch).filter(([key, value]) => metadataBeforePostLaunch[key] !== value));
3047
+ if (Object.keys(metadataUpdates).length > 0) {
3048
+ updateMetadata(sessionsDir, sessionId, metadataUpdates);
3049
+ invalidateCache();
3050
+ }
3051
+ }
3052
+ catch {
3053
+ // Non-fatal — session is already running
3054
+ }
3055
+ }
3056
+ return restoredSession;
3057
+ }
3058
+ return {
3059
+ spawn,
3060
+ spawnOrchestrator,
3061
+ ensureOrchestrator,
3062
+ relaunchOrchestrator,
3063
+ restore,
3064
+ list,
3065
+ listCached,
3066
+ invalidateCache,
3067
+ get,
3068
+ kill,
3069
+ cleanup,
3070
+ send,
3071
+ claimPR,
3072
+ remap,
3073
+ };
3074
+ }
3075
+
3076
+ export { createSessionManager };
3077
+ //# sourceMappingURL=session-manager.js.map