@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,1614 @@
1
+ import { existsSync, readdirSync, statSync, writeFileSync, unlinkSync, mkdirSync, rmSync, renameSync, readFileSync, cpSync } from 'node:fs';
2
+ import { join, basename } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { parse, stringify } from 'yaml';
5
+ import { parseKeyValueContent } from '../key-value.js';
6
+ import { generateSessionPrefix } from '../paths.js';
7
+ import { atomicWriteFileSync } from '../atomic-write.js';
8
+ import { withFileLockSync } from '../file-lock.js';
9
+ import { recordActivityEvent } from '../activity-events.js';
10
+
11
+ /**
12
+ * Storage V2 migration — converts old hash-based storage layout to
13
+ * the new `projects/{projectId}/` layout with JSON metadata.
14
+ *
15
+ * Old layout: ~/.agent-orchestrator/{12-hex}-{projectId}/sessions/{sessionId}
16
+ * New layout: ~/.agent-orchestrator/projects/{projectId}/sessions/{sessionId}.json
17
+ *
18
+ * This module is intentionally self-contained — it must NOT import
19
+ * deriveStorageKey, legacyProjectHash, or any old hash functions.
20
+ * Detection uses a single regex: /^([0-9a-f]{12})-(.+)$/
21
+ */
22
+ // ---------------------------------------------------------------------------
23
+ // Constants
24
+ // ---------------------------------------------------------------------------
25
+ /** Regex to detect old hash-based directory names: {12-hex}-{projectId}. */
26
+ const HASH_DIR_PATTERN = /^([0-9a-f]{12})-(.+)$/;
27
+ /** Regex to detect bare 12-hex hash directories (no project suffix). */
28
+ const BARE_HASH_DIR_PATTERN = /^([0-9a-f]{12})$/;
29
+ /** Regex to detect .migrated directories (for rollback). */
30
+ const MIGRATED_DIR_PATTERN = /^([0-9a-f]{12})-(.+)\.migrated$/;
31
+ /** Regex to detect bare .migrated directories. */
32
+ const BARE_MIGRATED_DIR_PATTERN = /^([0-9a-f]{12})\.migrated$/;
33
+ /** Directory name suffixes that are NOT project data and must be skipped by migration. */
34
+ const NON_PROJECT_SUFFIXES = new Set(["observability"]);
35
+ /** Marker file written during migration for crash-safety detection on re-run. */
36
+ const MIGRATION_MARKER = ".migration-in-progress";
37
+ // ---------------------------------------------------------------------------
38
+ // Inventory — detect old hash-based directories
39
+ // ---------------------------------------------------------------------------
40
+ function inventoryHashDirs(aoBaseDir, globalConfigPath) {
41
+ if (!existsSync(aoBaseDir))
42
+ return [];
43
+ // Build a storageKey→projectId lookup from global config (for bare hash dirs)
44
+ const storageKeyToProject = buildStorageKeyLookup(globalConfigPath);
45
+ const entries = [];
46
+ for (const name of readdirSync(aoBaseDir)) {
47
+ let hash;
48
+ let projectId;
49
+ // Skip already-migrated directories — prevents .migrated.migrated on re-run
50
+ if (name.endsWith(".migrated"))
51
+ continue;
52
+ const hashNameMatch = HASH_DIR_PATTERN.exec(name);
53
+ const bareHashMatch = BARE_HASH_DIR_PATTERN.exec(name);
54
+ if (hashNameMatch) {
55
+ hash = hashNameMatch[1];
56
+ projectId = sanitizeLegacyProjectId(hashNameMatch[2]);
57
+ // Skip non-project directories (e.g. {hash}-observability)
58
+ if (NON_PROJECT_SUFFIXES.has(hashNameMatch[2]))
59
+ continue;
60
+ }
61
+ else if (bareHashMatch) {
62
+ hash = bareHashMatch[1];
63
+ // Derive projectId: config lookup → session metadata → fallback to hash
64
+ const rawId = storageKeyToProject.get(hash) ?? deriveProjectIdFromDir(join(aoBaseDir, name)) ?? hash;
65
+ projectId = sanitizeLegacyProjectId(rawId);
66
+ }
67
+ else {
68
+ continue;
69
+ }
70
+ const dirPath = join(aoBaseDir, name);
71
+ try {
72
+ if (!statSync(dirPath).isDirectory())
73
+ continue;
74
+ }
75
+ catch {
76
+ continue;
77
+ }
78
+ // A directory is empty if it has no session files and no worktrees
79
+ const sessionsDir = join(dirPath, "sessions");
80
+ const worktreesDir = join(dirPath, "worktrees");
81
+ const hasSessions = existsSync(sessionsDir) && readdirSync(sessionsDir).some((f) => !f.startsWith(".") && f !== "archive");
82
+ const hasWorktrees = existsSync(worktreesDir) && readdirSync(worktreesDir).length > 0;
83
+ entries.push({
84
+ path: dirPath,
85
+ hash,
86
+ projectId,
87
+ empty: !hasSessions && !hasWorktrees,
88
+ });
89
+ }
90
+ return entries;
91
+ }
92
+ /**
93
+ * Build a storageKey → projectId lookup from the global config.
94
+ * Used to identify which project a bare hash directory belongs to.
95
+ */
96
+ function buildStorageKeyLookup(globalConfigPath) {
97
+ const lookup = new Map();
98
+ if (!globalConfigPath || !existsSync(globalConfigPath))
99
+ return lookup;
100
+ try {
101
+ const content = readFileSync(globalConfigPath, "utf-8");
102
+ const parsed = parse(content);
103
+ const projects = parsed?.["projects"];
104
+ if (!projects || typeof projects !== "object")
105
+ return lookup;
106
+ for (const [projectId, entry] of Object.entries(projects)) {
107
+ if (entry && typeof entry === "object" && typeof entry["storageKey"] === "string") {
108
+ lookup.set(entry["storageKey"], projectId);
109
+ }
110
+ }
111
+ }
112
+ catch {
113
+ // Config unreadable — proceed without lookup
114
+ }
115
+ return lookup;
116
+ }
117
+ /**
118
+ * Extract known project name prefixes from the global config.
119
+ * Used by detectActiveSessions to match V2 tmux session names.
120
+ */
121
+ function extractProjectPrefixes(globalConfigPath) {
122
+ if (!globalConfigPath || !existsSync(globalConfigPath))
123
+ return [];
124
+ try {
125
+ const content = readFileSync(globalConfigPath, "utf-8");
126
+ const parsed = parse(content);
127
+ const projects = parsed?.["projects"];
128
+ if (!projects || typeof projects !== "object")
129
+ return [];
130
+ return Array.from(new Set(Object.entries(projects).map(([projectId, entry]) => {
131
+ if (entry && typeof entry["sessionPrefix"] === "string" && entry["sessionPrefix"].trim()) {
132
+ return entry["sessionPrefix"].trim();
133
+ }
134
+ if (entry && typeof entry["path"] === "string" && entry["path"].trim()) {
135
+ return generateSessionPrefix(basename(entry["path"].trim()));
136
+ }
137
+ return generateSessionPrefix(projectId);
138
+ })));
139
+ }
140
+ catch {
141
+ return [];
142
+ }
143
+ }
144
+ /**
145
+ * Try to derive a projectId from session metadata files inside a directory.
146
+ * Reads the first session file that has a "project" field.
147
+ */
148
+ function deriveProjectIdFromDir(dirPath) {
149
+ const sessionsDir = join(dirPath, "sessions");
150
+ if (!existsSync(sessionsDir))
151
+ return null;
152
+ try {
153
+ for (const file of readdirSync(sessionsDir)) {
154
+ if (file === "archive" || file.startsWith("."))
155
+ continue;
156
+ const filePath = join(sessionsDir, file);
157
+ try {
158
+ if (!statSync(filePath).isFile())
159
+ continue;
160
+ const content = readFileSync(filePath, "utf-8").trim();
161
+ if (!content)
162
+ continue;
163
+ // Try JSON first, then key=value
164
+ let projectField;
165
+ if (content.startsWith("{")) {
166
+ const parsed = JSON.parse(content);
167
+ projectField = typeof parsed["project"] === "string" ? parsed["project"] : undefined;
168
+ }
169
+ else {
170
+ const kv = parseKeyValueContent(content);
171
+ projectField = kv["project"];
172
+ }
173
+ if (projectField)
174
+ return projectField;
175
+ }
176
+ catch {
177
+ continue;
178
+ }
179
+ }
180
+ }
181
+ catch {
182
+ // Can't read sessions dir
183
+ }
184
+ return null;
185
+ }
186
+ // ---------------------------------------------------------------------------
187
+ // Active session detection
188
+ // ---------------------------------------------------------------------------
189
+ /**
190
+ * Detect active AO tmux sessions. Returns session names that match
191
+ * either legacy ({hash}-{prefix}-{num}) or V2 ({prefix}-{num}) patterns.
192
+ *
193
+ * Legacy names: {12-hex}-{prefix}-{num} (e.g. abcdef012345-ao-1)
194
+ * V2 names: {prefix}-{num} (e.g. ao-17, app-orchestrator-1)
195
+ *
196
+ * To distinguish V2 names from unrelated tmux sessions, we match:
197
+ * - Any session ending in `-orchestrator-{num}` (always AO)
198
+ * - Sessions matching known AO prefixes: ao-{num}
199
+ * - If knownPrefixes are provided, also match {prefix}-{num}
200
+ */
201
+ async function detectActiveSessions(knownPrefixes) {
202
+ try {
203
+ const { execSync } = await import('node:child_process');
204
+ const output = execSync("tmux list-sessions -F '#{session_name}' 2>/dev/null", {
205
+ encoding: "utf-8",
206
+ timeout: 5000,
207
+ }).trim();
208
+ if (!output)
209
+ return [];
210
+ // Legacy pattern: {12-hex}-{anything}-{num}
211
+ const legacyPattern = /^[0-9a-f]{12}-.+-\d+$/;
212
+ // V2: default "ao" prefix
213
+ const v2DefaultPattern = /^ao-\d+$/;
214
+ // Build V2 prefix patterns from known project prefixes (workers + orchestrators)
215
+ const v2PrefixPatterns = (knownPrefixes ?? [])
216
+ .filter((p) => p && p !== "ao") // "ao" already covered above
217
+ .flatMap((p) => [
218
+ new RegExp(`^${escapeRegExp(p)}-\\d+$`),
219
+ new RegExp(`^${escapeRegExp(p)}-orchestrator-\\d+$`),
220
+ ]);
221
+ return output.split("\n").filter((name) => {
222
+ if (legacyPattern.test(name))
223
+ return true;
224
+ if (v2DefaultPattern.test(name))
225
+ return true;
226
+ if (/^ao-orchestrator-\d+$/.test(name))
227
+ return true;
228
+ return v2PrefixPatterns.some((pattern) => pattern.test(name));
229
+ });
230
+ }
231
+ catch {
232
+ // tmux not available or no sessions
233
+ return [];
234
+ }
235
+ }
236
+ function escapeRegExp(s) {
237
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
238
+ }
239
+ // ---------------------------------------------------------------------------
240
+ // Key-value to JSON conversion
241
+ // ---------------------------------------------------------------------------
242
+ /**
243
+ * Convert old key=value metadata content to a JSON object.
244
+ * Handles all the grouping and type conversions.
245
+ */
246
+ function convertKeyValueToJson(kvContent) {
247
+ const kv = parseKeyValueContent(kvContent);
248
+ const result = {};
249
+ // Direct string fields
250
+ const stringFields = [
251
+ "project", "agent", "createdAt", "branch", "tmuxName",
252
+ "issue", "pr", "summary", "restoredAt", "role",
253
+ "opencodeSessionId", "pinnedSummary", "userPrompt",
254
+ ];
255
+ for (const field of stringFields) {
256
+ if (kv[field])
257
+ result[field] = kv[field];
258
+ }
259
+ // Worktree: keep as-is (will be made relative in the migration step)
260
+ if (kv["worktree"])
261
+ result["worktree"] = kv["worktree"];
262
+ // prAutoDetect: "on"/"off" → true/false
263
+ if (kv["prAutoDetect"] === "on")
264
+ result["prAutoDetect"] = true;
265
+ else if (kv["prAutoDetect"] === "off")
266
+ result["prAutoDetect"] = false;
267
+ // runtimeHandle: parse JSON string → object
268
+ if (kv["runtimeHandle"]) {
269
+ try {
270
+ result["runtimeHandle"] = JSON.parse(kv["runtimeHandle"]);
271
+ }
272
+ catch {
273
+ result["runtimeHandle"] = kv["runtimeHandle"];
274
+ }
275
+ }
276
+ // statePayload → lifecycle object
277
+ if (kv["statePayload"]) {
278
+ try {
279
+ result["lifecycle"] = JSON.parse(kv["statePayload"]);
280
+ }
281
+ catch {
282
+ // If statePayload is unparseable, leave it as-is for debugging
283
+ result["statePayload"] = kv["statePayload"];
284
+ }
285
+ }
286
+ // Drop "stateVersion" (inside lifecycle).
287
+ // Preserve status for pre-lifecycle sessions that have no statePayload —
288
+ // without it, readMetadata falls through to "unknown".
289
+ if (!result["lifecycle"] && kv["status"]) {
290
+ result["status"] = kv["status"];
291
+ }
292
+ // Port fields: string → number
293
+ const portFields = {
294
+ dashboardPort: "port",
295
+ terminalWsPort: "terminalWsPort",
296
+ directTerminalWsPort: "directTerminalWsPort",
297
+ };
298
+ const dashboard = {};
299
+ for (const [kvKey, jsonKey] of Object.entries(portFields)) {
300
+ if (kv[kvKey]) {
301
+ const num = Number(kv[kvKey]);
302
+ if (Number.isFinite(num))
303
+ dashboard[jsonKey] = num;
304
+ }
305
+ }
306
+ if (Object.keys(dashboard).length > 0)
307
+ result["dashboard"] = dashboard;
308
+ // Agent report + report watcher fields stay flat to match runtime
309
+ // behavior. Live readers (agent-report.ts:565 — parseExistingAgentReport,
310
+ // lifecycle-manager.ts:2083, etc.) look up these keys directly on
311
+ // session.metadata, and readMetadataRaw → flattenToStringRecord does
312
+ // not unfold nested objects back into flat keys. Nesting them here
313
+ // would silently lose this state for migrated sessions until restart
314
+ // (and even then the freshness window means stale-yet-present is
315
+ // safer than missing). Same rationale as the `detecting*` fields below.
316
+ const flatPassthroughKeys = [
317
+ "agentReportedState",
318
+ "agentReportedAt",
319
+ "agentReportedNote",
320
+ "agentReportedPrUrl",
321
+ "agentReportedPrNumber",
322
+ "agentReportedPrIsDraft",
323
+ "reportWatcherLastAuditedAt",
324
+ "reportWatcherActiveTrigger",
325
+ "reportWatcherTriggerActivatedAt",
326
+ "reportWatcherTriggerCount",
327
+ ];
328
+ for (const flatKey of flatPassthroughKeys) {
329
+ if (kv[flatKey])
330
+ result[flatKey] = kv[flatKey];
331
+ }
332
+ // detecting fields — keep at top level to match runtime behavior.
333
+ // The lifecycle manager reads/writes these as flat top-level fields
334
+ // (session.metadata["detectingAttempts"], etc.), not from lifecycle.detecting.
335
+ if (kv["lifecycleEvidence"])
336
+ result["lifecycleEvidence"] = kv["lifecycleEvidence"];
337
+ if (kv["detectingAttempts"])
338
+ result["detectingAttempts"] = kv["detectingAttempts"];
339
+ if (kv["detectingStartedAt"])
340
+ result["detectingStartedAt"] = kv["detectingStartedAt"];
341
+ if (kv["detectingEvidenceHash"])
342
+ result["detectingEvidenceHash"] = kv["detectingEvidenceHash"];
343
+ // Preserve unknown fields that weren't handled above.
344
+ // This prevents data loss for custom or future metadata fields.
345
+ const handledKeys = new Set([
346
+ ...stringFields, "worktree", "prAutoDetect", "runtimeHandle",
347
+ "statePayload", "stateVersion", "status",
348
+ "dashboardPort", "terminalWsPort", "directTerminalWsPort",
349
+ ...flatPassthroughKeys,
350
+ "lifecycleEvidence", "detectingAttempts", "detectingStartedAt", "detectingEvidenceHash",
351
+ ]);
352
+ for (const [key, value] of Object.entries(kv)) {
353
+ if (!handledKeys.has(key) && !(key in result)) {
354
+ result[key] = value;
355
+ }
356
+ }
357
+ return result;
358
+ }
359
+ /**
360
+ * Detect if content is JSON or key=value format.
361
+ */
362
+ function isJsonContent(content) {
363
+ const trimmed = content.trim();
364
+ return trimmed.startsWith("{") || trimmed.startsWith("[");
365
+ }
366
+ /**
367
+ * Read and convert a metadata file — handles both old key=value and JSON.
368
+ */
369
+ function readAndConvertMetadata(filePath) {
370
+ try {
371
+ const content = readFileSync(filePath, "utf-8").trim();
372
+ if (!content)
373
+ return null;
374
+ if (isJsonContent(content)) {
375
+ return JSON.parse(content);
376
+ }
377
+ return convertKeyValueToJson(content);
378
+ }
379
+ catch {
380
+ return null;
381
+ }
382
+ }
383
+ // ---------------------------------------------------------------------------
384
+ // Legacy project ID sanitization
385
+ // ---------------------------------------------------------------------------
386
+ /** Pattern for safe project IDs — must match SAFE_PROJECT_ID_PATTERN in paths.ts. */
387
+ const SAFE_PROJECT_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
388
+ /**
389
+ * Sanitize a legacy project ID so it is safe for use as a V2 directory name.
390
+ * Replaces spaces and other disallowed characters with hyphens, collapses
391
+ * consecutive hyphens, trims leading/trailing hyphens, and ensures the ID
392
+ * starts with an alphanumeric character.
393
+ */
394
+ function sanitizeLegacyProjectId(projectId) {
395
+ if (SAFE_PROJECT_ID_PATTERN.test(projectId) && projectId.length <= 128) {
396
+ return projectId;
397
+ }
398
+ let sanitized = projectId
399
+ .replace(/[^a-zA-Z0-9._-]/g, "-") // replace unsafe chars with hyphens
400
+ .replace(/-{2,}/g, "-") // collapse consecutive hyphens
401
+ .replace(/^[-._]+/, "") // strip leading non-alphanumeric
402
+ .replace(/[-._]+$/, ""); // strip trailing non-alphanumeric
403
+ if (!sanitized || !/^[a-zA-Z0-9]/.test(sanitized)) {
404
+ sanitized = `project-${sanitized || "unknown"}`;
405
+ }
406
+ if (sanitized.length > 128) {
407
+ sanitized = sanitized.slice(0, 128);
408
+ }
409
+ return sanitized;
410
+ }
411
+ /** Get file mtime as epoch ms, returning 0 on error. */
412
+ function fileMtime(filePath) {
413
+ try {
414
+ return statSync(filePath).mtimeMs;
415
+ }
416
+ catch {
417
+ return 0;
418
+ }
419
+ }
420
+ /**
421
+ * Move a directory, falling back to recursive copy + delete on EXDEV
422
+ * (cross-device rename failure, e.g. Docker volumes, NFS mounts).
423
+ */
424
+ function crossDeviceMove(src, dest, log) {
425
+ try {
426
+ renameSync(src, dest);
427
+ }
428
+ catch (err) {
429
+ if (err.code === "EXDEV") {
430
+ log(` Cross-device move detected, copying: ${basename(src)}`);
431
+ cpSync(src, dest, { recursive: true });
432
+ rmSync(src, { recursive: true, force: true });
433
+ }
434
+ else {
435
+ throw err;
436
+ }
437
+ }
438
+ }
439
+ function migrateProject(projectId, hashDirs, aoBaseDir, dryRun, log) {
440
+ const projectDir = join(aoBaseDir, "projects", projectId);
441
+ const sessionsDir = join(projectDir, "sessions");
442
+ const worktreesDir = join(projectDir, "worktrees");
443
+ if (!dryRun) {
444
+ mkdirSync(sessionsDir, { recursive: true });
445
+ mkdirSync(worktreesDir, { recursive: true });
446
+ }
447
+ const result = {
448
+ sessions: 0,
449
+ worktrees: 0,
450
+ workspaceMoves: [],
451
+ };
452
+ // Collect all sessions across hash dirs
453
+ const allSessions = new Map();
454
+ for (const hashDir of hashDirs) {
455
+ const oldSessionsDir = join(hashDir.path, "sessions");
456
+ if (!existsSync(oldSessionsDir))
457
+ continue;
458
+ for (const file of readdirSync(oldSessionsDir)) {
459
+ if (file === "archive" || file.startsWith("."))
460
+ continue;
461
+ const filePath = join(oldSessionsDir, file);
462
+ try {
463
+ if (!statSync(filePath).isFile())
464
+ continue;
465
+ }
466
+ catch {
467
+ continue;
468
+ }
469
+ // Strip .json extension if present
470
+ const sessionId = file.endsWith(".json") ? file.slice(0, -5) : file;
471
+ const metadata = readAndConvertMetadata(filePath);
472
+ if (!metadata) {
473
+ log(` Warning: could not read metadata for ${sessionId} in ${hashDir.path}`);
474
+ continue;
475
+ }
476
+ // Handle duplicate session IDs across hash dirs.
477
+ //
478
+ // The multi-hash bug this PR cleans up made it possible for two
479
+ // unrelated `{hash}-{projectId}/sessions/` dirs to each carry an
480
+ // independently-numbered `ao-N` for the same project. Silently
481
+ // dropping the loser would lose work the user never marked
482
+ // terminal. Instead, rename the loser to
483
+ // `${sessionId}__from-${hash}` so both records survive in V2.
484
+ // The renamed copy is still a valid V2 sessionId
485
+ // (alphanum/underscore/hyphen) and never collides because the
486
+ // hash prefix is unique per V1 dir. We pick a "winner" using
487
+ // createdAt (newest first), then mtime, then path tiebreaker, so
488
+ // the most recent record keeps the canonical id.
489
+ const existing = allSessions.get(sessionId);
490
+ if (existing) {
491
+ const existingCreated = new Date(String(existing.metadata["createdAt"] ?? "")).getTime() || 0;
492
+ const newCreated = new Date(String(metadata["createdAt"] ?? "")).getTime() || 0;
493
+ const newIsNewer = newCreated > existingCreated
494
+ || (newCreated === existingCreated && fileMtime(filePath) > fileMtime(existing.sourcePath))
495
+ || (newCreated === existingCreated && fileMtime(filePath) === fileMtime(existing.sourcePath) && filePath > existing.sourcePath);
496
+ if (newIsNewer) {
497
+ // The new record wins the canonical id. Park the previous
498
+ // entry under a hash-suffixed alias before replacing it.
499
+ const loserHash = existing.sourceHash;
500
+ const loserAlias = `${sessionId}__from-${loserHash}`;
501
+ if (!allSessions.has(loserAlias)) {
502
+ allSessions.set(loserAlias, {
503
+ metadata: existing.metadata,
504
+ sourcePath: existing.sourcePath,
505
+ sourceHash: existing.sourceHash,
506
+ renamedFrom: sessionId,
507
+ });
508
+ log?.(` [rename] duplicate session ${sessionId} from hash ${loserHash} → ${loserAlias}`);
509
+ }
510
+ else {
511
+ log?.(` [warn] could not park duplicate ${sessionId} under ${loserAlias}: alias already taken`);
512
+ }
513
+ allSessions.set(sessionId, { metadata, sourcePath: filePath, sourceHash: hashDir.hash });
514
+ }
515
+ else {
516
+ // The existing entry wins. Park THIS record under the alias.
517
+ const loserAlias = `${sessionId}__from-${hashDir.hash}`;
518
+ if (!allSessions.has(loserAlias)) {
519
+ allSessions.set(loserAlias, {
520
+ metadata,
521
+ sourcePath: filePath,
522
+ sourceHash: hashDir.hash,
523
+ renamedFrom: sessionId,
524
+ });
525
+ log?.(` [rename] duplicate session ${sessionId} from hash ${hashDir.hash} → ${loserAlias}`);
526
+ }
527
+ else {
528
+ log?.(` [warn] could not park duplicate ${sessionId} under ${loserAlias}: alias already taken`);
529
+ }
530
+ }
531
+ }
532
+ else {
533
+ allSessions.set(sessionId, { metadata, sourcePath: filePath, sourceHash: hashDir.hash });
534
+ }
535
+ }
536
+ // Flatten archives into sessions/ as terminated records
537
+ const oldArchiveDir = join(oldSessionsDir, "archive");
538
+ if (existsSync(oldArchiveDir)) {
539
+ for (const archiveFile of readdirSync(oldArchiveDir)) {
540
+ // Extract sessionId from archive filename: `{sessionId}_{timestamp}[.json]`.
541
+ // Anchor on the timestamp suffix instead of a lazy prefix match —
542
+ // a lazy `[a-zA-Z0-9_-]+?` stops at the first `_<digit>`, so a
543
+ // session like `team_1-7` would be captured as `team` and the
544
+ // archive would be flattened under the wrong sessionId, silently
545
+ // overwriting another session's record.
546
+ // Compact form: 20260420T143052Z. Legacy form: 2026-04-20T14:30:52.000Z
547
+ // (the colon-and-dot legacy format predates the compact rewrite).
548
+ const match = archiveFile.match(/^(.+)_(\d{8}T\d{6}Z|\d{4}-\d{2}-\d{2}T[\d:.-]+Z)(?:\.json)?$/);
549
+ if (!match?.[1])
550
+ continue;
551
+ const archivedSessionId = match[1];
552
+ // Skip if an active session with this ID already exists
553
+ const targetPath = join(sessionsDir, `${archivedSessionId}.json`);
554
+ if (existsSync(targetPath)) {
555
+ log?.(` [skip] archive ${archivedSessionId}: active session already exists`);
556
+ continue;
557
+ }
558
+ if (dryRun) {
559
+ result.sessions++;
560
+ continue;
561
+ }
562
+ try {
563
+ const content = readFileSync(join(oldArchiveDir, archiveFile), "utf-8").trim();
564
+ if (!content)
565
+ continue;
566
+ let metadata;
567
+ try {
568
+ metadata = JSON.parse(content);
569
+ }
570
+ catch {
571
+ // Legacy key=value format
572
+ metadata = convertKeyValueToJson(content);
573
+ }
574
+ // Ensure terminated lifecycle state
575
+ if (typeof metadata["lifecycle"] === "object" && metadata["lifecycle"] !== null) {
576
+ const lifecycle = metadata["lifecycle"];
577
+ if (typeof lifecycle["session"] === "object" && lifecycle["session"] !== null) {
578
+ const session = lifecycle["session"];
579
+ if (session["state"] !== "terminated" && session["state"] !== "done") {
580
+ session["state"] = "terminated";
581
+ session["reason"] = session["reason"] ?? "migrated_from_archive";
582
+ session["terminatedAt"] = session["terminatedAt"] ?? new Date().toISOString();
583
+ }
584
+ }
585
+ }
586
+ else {
587
+ // Flat metadata — set status directly
588
+ metadata["status"] = metadata["status"] ?? "terminated";
589
+ }
590
+ atomicWriteFileSync(targetPath, JSON.stringify(metadata, null, 2) + "\n");
591
+ result.sessions++;
592
+ }
593
+ catch (err) {
594
+ log?.(` [warn] failed to flatten archive ${archiveFile}: ${err instanceof Error ? err.message : String(err)}`);
595
+ }
596
+ }
597
+ }
598
+ // Migrate worktrees
599
+ const oldWorktreesDir = join(hashDir.path, "worktrees");
600
+ if (existsSync(oldWorktreesDir)) {
601
+ for (const worktreeName of readdirSync(oldWorktreesDir)) {
602
+ const srcWorktree = join(oldWorktreesDir, worktreeName);
603
+ try {
604
+ if (!statSync(srcWorktree).isDirectory())
605
+ continue;
606
+ }
607
+ catch {
608
+ continue;
609
+ }
610
+ const destWorktree = join(worktreesDir, worktreeName);
611
+ if (!existsSync(destWorktree) && !dryRun) {
612
+ crossDeviceMove(srcWorktree, destWorktree, log);
613
+ }
614
+ result.worktrees++;
615
+ }
616
+ }
617
+ }
618
+ // Write all sessions to sessions/ (including orchestrators — runtime reads from sessions/)
619
+ for (const [sessionId, { metadata, renamedFrom }] of allSessions) {
620
+ // Renamed (loser-of-conflict) entries are preserved for inspection
621
+ // only — their V1 worktree was clobbered by the canonical entry's
622
+ // move (workspace dirs are keyed on the un-aliased sessionId). Keep
623
+ // the metadata pointing at the V1 worktree path so the user can
624
+ // still locate the original directory under its `.migrated` parent.
625
+ if (!renamedFrom &&
626
+ typeof metadata["worktree"] === "string" &&
627
+ metadata["worktree"]) {
628
+ const oldWorktreePath = metadata["worktree"];
629
+ const newWorktreePath = join(worktreesDir, sessionId);
630
+ if (existsSync(newWorktreePath) || dryRun) {
631
+ metadata["worktree"] = newWorktreePath;
632
+ // Capture (old, new) so we can relink agent session storage later.
633
+ // No-op when oldWorktreePath === newWorktreePath (rare, happens if
634
+ // metadata was already pointing at the V2 path on a re-run).
635
+ if (oldWorktreePath !== newWorktreePath) {
636
+ result.workspaceMoves.push({
637
+ oldWorkspacePath: oldWorktreePath,
638
+ newWorkspacePath: newWorktreePath,
639
+ });
640
+ }
641
+ }
642
+ // Otherwise keep the original path — the worktree may be at ~/.worktrees/{projectId}/{sessionId}/
643
+ // and will be moved by moveStrayWorktrees() later
644
+ }
645
+ if (!dryRun) {
646
+ const destPath = join(sessionsDir, `${sessionId}.json`);
647
+ atomicWriteFileSync(destPath, JSON.stringify(metadata, null, 2) + "\n");
648
+ }
649
+ result.sessions++;
650
+ }
651
+ return result;
652
+ }
653
+ // ---------------------------------------------------------------------------
654
+ // Git worktree repair — fix references broken by directory moves
655
+ // ---------------------------------------------------------------------------
656
+ /**
657
+ * After moving worktree directories, git's internal references
658
+ * (.git/worktrees/{id}/gitdir) still point to the old location.
659
+ * Run `git worktree repair` from each project's repo root to fix them.
660
+ */
661
+ async function repairGitWorktrees(aoBaseDir, globalConfigPath, log) {
662
+ // Build projectId → repo path lookup from global config
663
+ const repoPathByProject = new Map();
664
+ try {
665
+ if (existsSync(globalConfigPath)) {
666
+ const content = readFileSync(globalConfigPath, "utf-8");
667
+ const parsed = parse(content);
668
+ const projects = parsed?.["projects"];
669
+ if (projects && typeof projects === "object") {
670
+ for (const [projectId, entry] of Object.entries(projects)) {
671
+ if (entry && typeof entry["path"] === "string") {
672
+ repoPathByProject.set(projectId, entry["path"]);
673
+ }
674
+ }
675
+ }
676
+ }
677
+ }
678
+ catch {
679
+ // Config unreadable — skip repair
680
+ return;
681
+ }
682
+ const projectsDir = join(aoBaseDir, "projects");
683
+ if (!existsSync(projectsDir))
684
+ return;
685
+ const { execSync } = await import('node:child_process');
686
+ for (const projectId of readdirSync(projectsDir)) {
687
+ const worktreesDir = join(projectsDir, projectId, "worktrees");
688
+ if (!existsSync(worktreesDir))
689
+ continue;
690
+ const repoPath = repoPathByProject.get(projectId);
691
+ if (!repoPath || !existsSync(repoPath))
692
+ continue;
693
+ try {
694
+ execSync(`git worktree repair`, { cwd: repoPath, timeout: 10_000, stdio: "ignore" });
695
+ log(` Repaired git worktree references for ${projectId}`);
696
+ }
697
+ catch {
698
+ log(` Warning: git worktree repair failed for ${projectId} — run manually in ${repoPath}`);
699
+ }
700
+ }
701
+ }
702
+ // ---------------------------------------------------------------------------
703
+ // Config update — strip storageKey
704
+ // ---------------------------------------------------------------------------
705
+ function stripStorageKeysFromConfig(configPath, dryRun, log) {
706
+ if (!existsSync(configPath))
707
+ return;
708
+ const content = readFileSync(configPath, "utf-8");
709
+ const parsed = parse(content);
710
+ if (!parsed || typeof parsed !== "object")
711
+ return;
712
+ const projects = parsed["projects"];
713
+ if (!projects || typeof projects !== "object")
714
+ return;
715
+ let stripped = 0;
716
+ for (const [, entry] of Object.entries(projects)) {
717
+ if (entry && typeof entry === "object" && "storageKey" in entry) {
718
+ delete entry["storageKey"];
719
+ stripped++;
720
+ }
721
+ }
722
+ if (stripped > 0) {
723
+ log(` Stripped storageKey from ${stripped} project(s) in config.`);
724
+ if (!dryRun) {
725
+ withFileLockSync(`${configPath}.lock`, () => {
726
+ // Backup the config before modifying
727
+ const backupPath = `${configPath}.pre-migration`;
728
+ if (!existsSync(backupPath)) {
729
+ atomicWriteFileSync(backupPath, content);
730
+ log(` Config backed up to ${basename(backupPath)}`);
731
+ }
732
+ atomicWriteFileSync(configPath, stringify(parsed, { indent: 2 }));
733
+ });
734
+ }
735
+ }
736
+ }
737
+ // ---------------------------------------------------------------------------
738
+ // Agent session storage relinking (Mode A fix for PR #1466)
739
+ // ---------------------------------------------------------------------------
740
+ /**
741
+ * Encode a workspace path the way Claude Code does for `~/.claude/projects/`.
742
+ * Mirrors `toClaudeProjectPath` in `agent-claude-code/src/index.ts`. Kept
743
+ * in sync by hand — duplicating the function here avoids pulling the agent
744
+ * plugin into core/migration just for this string transformation.
745
+ */
746
+ function encodeClaudeProjectPath(workspacePath) {
747
+ return workspacePath
748
+ .replace(/\\/g, "/")
749
+ .replace(/:/g, "")
750
+ .replace(/[/.]/g, "-");
751
+ }
752
+ /**
753
+ * After `migrate-storage` moves a session's worktree from V1 to V2, Claude
754
+ * Code's session JSONLs are still keyed by the encoded form of the OLD
755
+ * workspace path, so `getRestoreCommand` looks up the new encoded path,
756
+ * finds nothing, and the agent launches without chat history.
757
+ *
758
+ * Move each `~/.claude/projects/<encoded(old)>/` directory to
759
+ * `<encoded(new)>/`. Skip when the source doesn't exist (no Claude history)
760
+ * or the target already exists (manual reconciliation needed). Both paths
761
+ * resolving to the same encoded string is a no-op.
762
+ *
763
+ * Returns the number of directories actually relinked.
764
+ *
765
+ * Codex stores its sessions date-sharded with the cwd embedded inside each
766
+ * JSONL's `session_meta` line, so the same physical-rename trick doesn't
767
+ * apply. Codex relinking is a separate follow-up — see PR #1466 thread.
768
+ */
769
+ function relinkClaudeSessionStorage(moves, dryRun, log) {
770
+ if (moves.length === 0)
771
+ return 0;
772
+ const claudeProjectsDir = join(homedir(), ".claude", "projects");
773
+ if (!existsSync(claudeProjectsDir))
774
+ return 0;
775
+ let relinked = 0;
776
+ for (const { oldWorkspacePath, newWorkspacePath } of moves) {
777
+ const oldEncoded = encodeClaudeProjectPath(oldWorkspacePath);
778
+ const newEncoded = encodeClaudeProjectPath(newWorkspacePath);
779
+ if (oldEncoded === newEncoded)
780
+ continue;
781
+ const oldDir = join(claudeProjectsDir, oldEncoded);
782
+ const newDir = join(claudeProjectsDir, newEncoded);
783
+ if (!existsSync(oldDir))
784
+ continue; // no Claude history for this session — nothing to do
785
+ if (existsSync(newDir)) {
786
+ log(` [skip] Claude session dir already exists at new path: ${newEncoded}`);
787
+ continue;
788
+ }
789
+ if (dryRun) {
790
+ log(` [dry-run] Would relink Claude sessions: ${oldEncoded} → ${newEncoded}`);
791
+ relinked++;
792
+ continue;
793
+ }
794
+ try {
795
+ renameSync(oldDir, newDir);
796
+ log(` Relinked Claude sessions: ${oldEncoded} → ${newEncoded}`);
797
+ relinked++;
798
+ }
799
+ catch (err) {
800
+ log(` [warn] failed to relink Claude session dir ${oldEncoded}: ${err instanceof Error ? err.message : String(err)}`);
801
+ }
802
+ }
803
+ return relinked;
804
+ }
805
+ /**
806
+ * Codex stores rollouts at `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` and
807
+ * embeds the working directory inside the very first JSONL record's
808
+ * `session_meta` payload. The `agent-codex` plugin's restore lookup matches
809
+ * `session_meta.cwd === session.workspacePath` exactly, so once
810
+ * `migrate-storage` rewrites a session's `workspacePath` to the V2 layout,
811
+ * Codex restore stops finding the prior thread and `getRestoreCommand`
812
+ * returns null — the user loses chat history on `athene start` restore.
813
+ *
814
+ * For each (oldPath → newPath) move, scan rollout files, look at the first
815
+ * non-empty parsed line, and if it is a `session_meta` entry whose
816
+ * `payload.cwd` exactly matches `oldWorkspacePath`, rewrite that single line
817
+ * to point at `newWorkspacePath`. Other lines are copied byte-for-byte. The
818
+ * rewrite goes through an atomic temp-file rename so a crash mid-rewrite
819
+ * cannot corrupt the rollout.
820
+ *
821
+ * Returns the number of rollout files actually rewritten.
822
+ */
823
+ function rewriteCodexSessionStorage(moves, dryRun, log) {
824
+ if (moves.length === 0)
825
+ return 0;
826
+ const codexSessionsDir = join(homedir(), ".codex", "sessions");
827
+ if (!existsSync(codexSessionsDir))
828
+ return 0;
829
+ // Index moves by old path for O(1) lookup.
830
+ const oldToNew = new Map();
831
+ for (const { oldWorkspacePath, newWorkspacePath } of moves) {
832
+ if (oldWorkspacePath !== newWorkspacePath) {
833
+ oldToNew.set(oldWorkspacePath, newWorkspacePath);
834
+ }
835
+ }
836
+ if (oldToNew.size === 0)
837
+ return 0;
838
+ // Walk year/month/day shards collecting rollout-*.jsonl files.
839
+ const jsonlFiles = [];
840
+ function walk(dir) {
841
+ let entries;
842
+ try {
843
+ entries = readdirSync(dir, { withFileTypes: true });
844
+ }
845
+ catch {
846
+ return;
847
+ }
848
+ for (const entry of entries) {
849
+ const full = join(dir, entry.name);
850
+ if (entry.isDirectory())
851
+ walk(full);
852
+ else if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl")) {
853
+ jsonlFiles.push(full);
854
+ }
855
+ }
856
+ }
857
+ walk(codexSessionsDir);
858
+ let rewritten = 0;
859
+ for (const filePath of jsonlFiles) {
860
+ let content;
861
+ try {
862
+ content = readFileSync(filePath, "utf-8");
863
+ }
864
+ catch {
865
+ continue;
866
+ }
867
+ // Find the first parseable JSONL line. Codex writes session_meta as the
868
+ // very first record so this is cheap; bail out after a small bounded
869
+ // scan to avoid pathological cases.
870
+ const newlineIdx = content.indexOf("\n");
871
+ const firstLineEnd = newlineIdx === -1 ? content.length : newlineIdx;
872
+ const firstLine = content.slice(0, firstLineEnd);
873
+ if (!firstLine.trim())
874
+ continue;
875
+ let parsed;
876
+ try {
877
+ parsed = JSON.parse(firstLine);
878
+ }
879
+ catch {
880
+ continue;
881
+ }
882
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
883
+ continue;
884
+ const entry = parsed;
885
+ if (entry.type !== "session_meta")
886
+ continue;
887
+ const cwd = entry.payload?.cwd;
888
+ if (typeof cwd !== "string")
889
+ continue;
890
+ const newCwd = oldToNew.get(cwd);
891
+ if (!newCwd)
892
+ continue;
893
+ if (dryRun) {
894
+ log(` [dry-run] Would rewrite Codex session_meta cwd: ${filePath}`);
895
+ log(` ${cwd} → ${newCwd}`);
896
+ rewritten++;
897
+ continue;
898
+ }
899
+ // Mutate only the cwd field. Preserve insertion order and other payload
900
+ // fields by editing the parsed object then re-serialising.
901
+ entry.payload.cwd = newCwd;
902
+ const newFirstLine = JSON.stringify(entry);
903
+ const rest = newlineIdx === -1 ? "" : content.slice(newlineIdx);
904
+ try {
905
+ atomicWriteFileSync(filePath, newFirstLine + rest);
906
+ log(` Rewrote Codex session_meta cwd in ${filePath}`);
907
+ rewritten++;
908
+ }
909
+ catch (err) {
910
+ log(` [warn] failed to rewrite Codex session ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
911
+ }
912
+ }
913
+ return rewritten;
914
+ }
915
+ // ---------------------------------------------------------------------------
916
+ // Stray worktree detection
917
+ // ---------------------------------------------------------------------------
918
+ /**
919
+ * Try to move a single worktree directory to the matching project.
920
+ * Returns true if matched and moved (or would be moved in dry-run).
921
+ * Appends a (old, new) pair to `workspaceMoves` when a real move happens
922
+ * so the caller can relink agent session storage afterwards.
923
+ */
924
+ function tryMoveWorktree(sessionId, srcPath, projectsDir, dryRun, log, workspaceMoves, skipProjects) {
925
+ for (const projectId of readdirSync(projectsDir)) {
926
+ if (skipProjects?.has(projectId))
927
+ continue;
928
+ const sessionsDir = join(projectsDir, projectId, "sessions");
929
+ if (!existsSync(sessionsDir))
930
+ continue;
931
+ const sessionFile = join(sessionsDir, `${sessionId}.json`);
932
+ if (existsSync(sessionFile)) {
933
+ const destPath = join(projectsDir, projectId, "worktrees", sessionId);
934
+ if (!existsSync(destPath)) {
935
+ log(` Moving stray worktree ${sessionId} → projects/${projectId}/worktrees/`);
936
+ if (srcPath !== destPath) {
937
+ workspaceMoves.push({
938
+ oldWorkspacePath: srcPath,
939
+ newWorkspacePath: destPath,
940
+ });
941
+ }
942
+ if (!dryRun) {
943
+ mkdirSync(join(projectsDir, projectId, "worktrees"), { recursive: true });
944
+ crossDeviceMove(srcPath, destPath, log);
945
+ // Patch session JSON to point at the new worktree location
946
+ try {
947
+ const raw = readFileSync(sessionFile, "utf-8");
948
+ const meta = JSON.parse(raw);
949
+ if (typeof meta["worktree"] === "string") {
950
+ meta["worktree"] = destPath;
951
+ atomicWriteFileSync(sessionFile, JSON.stringify(meta, null, 2) + "\n");
952
+ }
953
+ }
954
+ catch {
955
+ log(` Warning: could not patch worktree path in ${sessionId}.json`);
956
+ }
957
+ }
958
+ return true;
959
+ }
960
+ }
961
+ }
962
+ return false;
963
+ }
964
+ function moveStrayWorktrees(aoBaseDir, dryRun, log, workspaceMoves, skipProjects) {
965
+ const strayDir = join(homedir(), ".worktrees");
966
+ if (!existsSync(strayDir))
967
+ return 0;
968
+ const projectsDir = join(aoBaseDir, "projects");
969
+ if (!existsSync(projectsDir))
970
+ return 0;
971
+ let moved = 0;
972
+ for (const name of readdirSync(strayDir)) {
973
+ const srcPath = join(strayDir, name);
974
+ try {
975
+ if (!statSync(srcPath).isDirectory())
976
+ continue;
977
+ }
978
+ catch {
979
+ continue;
980
+ }
981
+ // The default workspace plugin stores worktrees at ~/.worktrees/{projectId}/{sessionId}/.
982
+ // Check if this entry is a projectId directory containing session worktrees.
983
+ const children = readdirSync(srcPath);
984
+ let isProjectDir = false;
985
+ for (const child of children) {
986
+ const childPath = join(srcPath, child);
987
+ try {
988
+ if (!statSync(childPath).isDirectory())
989
+ continue;
990
+ }
991
+ catch {
992
+ continue;
993
+ }
994
+ // If any child matches a session in any project, treat parent as a projectId dir
995
+ if (tryMoveWorktree(child, childPath, projectsDir, dryRun, log, workspaceMoves, skipProjects)) {
996
+ moved++;
997
+ isProjectDir = true;
998
+ }
999
+ }
1000
+ if (isProjectDir) {
1001
+ // Remove the now-empty projectId directory (if empty)
1002
+ if (!dryRun) {
1003
+ try {
1004
+ const remaining = readdirSync(srcPath);
1005
+ if (remaining.length === 0) {
1006
+ rmSync(srcPath, { recursive: true, force: true });
1007
+ }
1008
+ }
1009
+ catch {
1010
+ // Ignore — non-critical
1011
+ }
1012
+ }
1013
+ continue;
1014
+ }
1015
+ // Not a projectId directory — treat as a flat session worktree
1016
+ if (tryMoveWorktree(name, srcPath, projectsDir, dryRun, log, workspaceMoves, skipProjects)) {
1017
+ moved++;
1018
+ }
1019
+ else {
1020
+ log(` Warning: stray worktree ${name} in ~/.worktrees/ has no matching session — left in place.`);
1021
+ }
1022
+ }
1023
+ return moved;
1024
+ }
1025
+ // ---------------------------------------------------------------------------
1026
+ // Main migration entry point
1027
+ // ---------------------------------------------------------------------------
1028
+ async function migrateStorage(options = {}) {
1029
+ const aoBaseDir = options.aoBaseDir ?? join(homedir(), ".agent-orchestrator");
1030
+ const dryRun = options.dryRun ?? false;
1031
+ const log = options.log ?? console.log;
1032
+ const globalConfigPath = options.globalConfigPath ??
1033
+ join(process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config"), "agent-orchestrator", "config.yaml");
1034
+ // Use the actual global config path if it exists at the standard location
1035
+ const effectiveConfigPath = existsSync(globalConfigPath)
1036
+ ? globalConfigPath
1037
+ : existsSync(join(aoBaseDir, "config.yaml"))
1038
+ ? join(aoBaseDir, "config.yaml")
1039
+ : globalConfigPath;
1040
+ if (dryRun) {
1041
+ log("DRY RUN — no changes will be made.\n");
1042
+ }
1043
+ // Crash-safety: detect incomplete previous migration
1044
+ const markerPath = join(aoBaseDir, MIGRATION_MARKER);
1045
+ if (existsSync(markerPath)) {
1046
+ log("WARNING: Previous migration was interrupted. Re-running — already-migrated directories will be skipped.\n");
1047
+ }
1048
+ // Pre-flight: detect active sessions (include V2 prefix patterns from config)
1049
+ if (!options.force && !dryRun) {
1050
+ const knownPrefixes = extractProjectPrefixes(effectiveConfigPath);
1051
+ const activeSessions = await detectActiveSessions(knownPrefixes);
1052
+ if (activeSessions.length > 0) {
1053
+ recordActivityEvent({
1054
+ source: "migration",
1055
+ kind: "migration.blocked",
1056
+ level: "warn",
1057
+ summary: `migration blocked by ${activeSessions.length} active session(s)`,
1058
+ data: {
1059
+ activeSessionCount: activeSessions.length,
1060
+ sample: activeSessions.slice(0, 5),
1061
+ },
1062
+ });
1063
+ throw new Error(`Found ${activeSessions.length} active AO tmux session(s): ${activeSessions.slice(0, 5).join(", ")}${activeSessions.length > 5 ? "..." : ""}. ` +
1064
+ `Kill active sessions first (athene session kill --all) or use --force to migrate anyway.`);
1065
+ }
1066
+ }
1067
+ // Write marker file before making any changes (removed on success)
1068
+ if (!dryRun) {
1069
+ writeFileSync(markerPath, new Date().toISOString());
1070
+ }
1071
+ // Inventory hash directories (pass config path for bare-hash projectId lookup)
1072
+ const hashDirs = inventoryHashDirs(aoBaseDir, effectiveConfigPath);
1073
+ if (hashDirs.length === 0) {
1074
+ log("No legacy hash-based directories found. Nothing to migrate.");
1075
+ if (!dryRun && existsSync(markerPath)) {
1076
+ try {
1077
+ unlinkSync(markerPath);
1078
+ }
1079
+ catch { /* best-effort */ }
1080
+ }
1081
+ const totals = {
1082
+ projects: 0,
1083
+ sessions: 0,
1084
+ worktrees: 0,
1085
+ emptyDirsDeleted: 0,
1086
+ strayWorktreesMoved: 0,
1087
+ claudeSessionsRelinked: 0,
1088
+ codexSessionsRewritten: 0,
1089
+ };
1090
+ recordActivityEvent({
1091
+ source: "migration",
1092
+ kind: "migration.completed",
1093
+ level: "info",
1094
+ summary: "migration completed: 0 project(s), 0 session(s)",
1095
+ data: {
1096
+ dryRun,
1097
+ projectsMigrated: totals.projects,
1098
+ sessions: totals.sessions,
1099
+ worktrees: totals.worktrees,
1100
+ strayWorktreesMoved: totals.strayWorktreesMoved,
1101
+ claudeSessionsRelinked: totals.claudeSessionsRelinked,
1102
+ codexSessionsRewritten: totals.codexSessionsRewritten,
1103
+ emptyDirsDeleted: totals.emptyDirsDeleted,
1104
+ projectErrors: 0,
1105
+ },
1106
+ });
1107
+ return totals;
1108
+ }
1109
+ log(`Found ${hashDirs.length} legacy director${hashDirs.length === 1 ? "y" : "ies"}.`);
1110
+ // Group by projectId
1111
+ const projectGroups = new Map();
1112
+ for (const entry of hashDirs) {
1113
+ const group = projectGroups.get(entry.projectId) ?? [];
1114
+ group.push(entry);
1115
+ projectGroups.set(entry.projectId, group);
1116
+ }
1117
+ // Detect case-insensitive projectId collisions (macOS HFS+/APFS is case-insensitive)
1118
+ const lowerCaseIndex = new Map();
1119
+ for (const projectId of projectGroups.keys()) {
1120
+ const lower = projectId.toLowerCase();
1121
+ const existing = lowerCaseIndex.get(lower) ?? [];
1122
+ existing.push(projectId);
1123
+ lowerCaseIndex.set(lower, existing);
1124
+ }
1125
+ for (const [lower, ids] of lowerCaseIndex) {
1126
+ if (ids.length > 1) {
1127
+ log(`\nWARNING: Case-insensitive collision detected for projectIds: ${ids.join(", ")} (resolve to "${lower}" on case-insensitive filesystems).`);
1128
+ log(` Skipping colliding projects — rename them manually before re-running migration.`);
1129
+ for (const id of ids) {
1130
+ projectGroups.delete(id);
1131
+ }
1132
+ }
1133
+ }
1134
+ // Create projects/ directory
1135
+ if (!dryRun) {
1136
+ mkdirSync(join(aoBaseDir, "projects"), { recursive: true });
1137
+ }
1138
+ const totals = {
1139
+ projects: 0,
1140
+ sessions: 0,
1141
+ worktrees: 0,
1142
+ emptyDirsDeleted: 0,
1143
+ strayWorktreesMoved: 0,
1144
+ claudeSessionsRelinked: 0,
1145
+ codexSessionsRewritten: 0,
1146
+ };
1147
+ // (oldWorkspacePath, newWorkspacePath) pairs collected across both
1148
+ // migration phases. Drives the agent-session-storage relink at the end.
1149
+ const allWorkspaceMoves = [];
1150
+ // Migrate each project
1151
+ const projectErrors = [];
1152
+ for (const [projectId, dirs] of projectGroups) {
1153
+ const nonEmpty = dirs.filter((d) => !d.empty);
1154
+ if (nonEmpty.length === 0) {
1155
+ // All dirs are empty — just delete them
1156
+ for (const dir of dirs) {
1157
+ log(` Deleting empty directory: ${basename(dir.path)}`);
1158
+ if (!dryRun) {
1159
+ rmSync(dir.path, { recursive: true, force: true });
1160
+ }
1161
+ totals.emptyDirsDeleted++;
1162
+ }
1163
+ continue;
1164
+ }
1165
+ log(`\nMigrating project: ${projectId} (${dirs.length} hash dir${dirs.length > 1 ? "s" : ""})`);
1166
+ try {
1167
+ const projectResult = migrateProject(projectId, dirs, aoBaseDir, dryRun, log);
1168
+ totals.projects++;
1169
+ totals.sessions += projectResult.sessions;
1170
+ totals.worktrees += projectResult.worktrees;
1171
+ allWorkspaceMoves.push(...projectResult.workspaceMoves);
1172
+ }
1173
+ catch (err) {
1174
+ const msg = err instanceof Error ? err.message : String(err);
1175
+ log(` ERROR migrating project ${projectId}: ${msg}`);
1176
+ projectErrors.push({ projectId, error: msg });
1177
+ recordActivityEvent({
1178
+ projectId,
1179
+ source: "migration",
1180
+ kind: "migration.project_failed",
1181
+ level: "error",
1182
+ summary: `migration failed for project ${projectId}`,
1183
+ data: {
1184
+ dryRun,
1185
+ hashDirCount: dirs.length,
1186
+ error: msg,
1187
+ },
1188
+ });
1189
+ continue;
1190
+ }
1191
+ // Rename old directories to .migrated
1192
+ for (const dir of dirs) {
1193
+ if (dir.empty) {
1194
+ log(` Deleting empty directory: ${basename(dir.path)}`);
1195
+ if (!dryRun) {
1196
+ rmSync(dir.path, { recursive: true, force: true });
1197
+ }
1198
+ totals.emptyDirsDeleted++;
1199
+ }
1200
+ else {
1201
+ const migratedPath = `${dir.path}.migrated`;
1202
+ log(` Renaming: ${basename(dir.path)} → ${basename(dir.path)}.migrated`);
1203
+ if (!dryRun) {
1204
+ try {
1205
+ renameSync(dir.path, migratedPath);
1206
+ }
1207
+ catch (err) {
1208
+ // .migrated target may already exist from a previous interrupted run
1209
+ if (err.code === "ENOTEMPTY" && existsSync(migratedPath)) {
1210
+ log(` WARNING: ${basename(migratedPath)} already exists — removing source directory`);
1211
+ rmSync(dir.path, { recursive: true, force: true });
1212
+ }
1213
+ else {
1214
+ const msg = err instanceof Error ? err.message : String(err);
1215
+ log(` ERROR: Failed to rename ${basename(dir.path)}: ${msg}`);
1216
+ projectErrors.push({
1217
+ projectId,
1218
+ error: `Failed to rename ${basename(dir.path)} to ${basename(migratedPath)}: ${msg}`,
1219
+ });
1220
+ recordActivityEvent({
1221
+ projectId,
1222
+ source: "migration",
1223
+ kind: "migration.rename_failed",
1224
+ level: "error",
1225
+ summary: `failed to rename ${basename(dir.path)} to .migrated`,
1226
+ data: {
1227
+ from: basename(dir.path),
1228
+ to: basename(migratedPath),
1229
+ error: msg,
1230
+ },
1231
+ });
1232
+ }
1233
+ }
1234
+ }
1235
+ }
1236
+ }
1237
+ }
1238
+ // Move stray worktrees from ~/.worktrees/ (skip projects that failed migration)
1239
+ const failedProjects = new Set(projectErrors.map((e) => e.projectId));
1240
+ totals.strayWorktreesMoved = moveStrayWorktrees(aoBaseDir, dryRun, log, allWorkspaceMoves, failedProjects);
1241
+ // Repair git worktree references broken by directory moves
1242
+ if (!dryRun && (totals.worktrees > 0 || totals.strayWorktreesMoved > 0)) {
1243
+ await repairGitWorktrees(aoBaseDir, effectiveConfigPath, log);
1244
+ }
1245
+ // Relink Claude Code session storage so chat history survives the
1246
+ // worktree-path change. Without this, athene start → restore launches a
1247
+ // fresh `claude` instance and the prior conversation is lost.
1248
+ totals.claudeSessionsRelinked = relinkClaudeSessionStorage(allWorkspaceMoves, dryRun, log);
1249
+ totals.codexSessionsRewritten = rewriteCodexSessionStorage(allWorkspaceMoves, dryRun, log);
1250
+ // Only strip storageKey and remove marker when ALL projects succeeded.
1251
+ // Partial failure leaves the marker and config intact so the migration
1252
+ // can be retried after fixing the failing project(s).
1253
+ if (projectErrors.length === 0) {
1254
+ log("\nUpdating config...");
1255
+ stripStorageKeysFromConfig(effectiveConfigPath, dryRun, log);
1256
+ }
1257
+ else {
1258
+ log("\nSkipping config update — some projects failed migration.");
1259
+ }
1260
+ // Summary
1261
+ log("\n--- Migration Summary ---");
1262
+ log(`Migrated ${totals.projects} project${totals.projects !== 1 ? "s" : ""}, ` +
1263
+ `${totals.sessions} session${totals.sessions !== 1 ? "s" : ""}, ` +
1264
+ `${totals.worktrees} worktree${totals.worktrees !== 1 ? "s" : ""}.`);
1265
+ if (totals.strayWorktreesMoved > 0) {
1266
+ log(`Moved ${totals.strayWorktreesMoved} stray worktree${totals.strayWorktreesMoved !== 1 ? "s" : ""} from ~/.worktrees/.`);
1267
+ }
1268
+ if (totals.claudeSessionsRelinked > 0) {
1269
+ log(`Relinked ${totals.claudeSessionsRelinked} Claude session director${totals.claudeSessionsRelinked !== 1 ? "ies" : "y"} to new worktree paths.`);
1270
+ }
1271
+ if (totals.codexSessionsRewritten > 0) {
1272
+ log(`Rewrote ${totals.codexSessionsRewritten} Codex rollout file${totals.codexSessionsRewritten !== 1 ? "s" : ""} to new worktree paths.`);
1273
+ }
1274
+ if (totals.emptyDirsDeleted > 0) {
1275
+ log(`Deleted ${totals.emptyDirsDeleted} empty director${totals.emptyDirsDeleted !== 1 ? "ies" : "y"}.`);
1276
+ }
1277
+ if (projectErrors.length > 0) {
1278
+ log(`\nFailed to migrate ${projectErrors.length} project${projectErrors.length !== 1 ? "s" : ""}:`);
1279
+ for (const { projectId, error } of projectErrors) {
1280
+ log(` - ${projectId}: ${error}`);
1281
+ }
1282
+ log("Migration marker preserved — re-run after fixing the above errors.");
1283
+ }
1284
+ else {
1285
+ log("Old directories renamed to *.migrated — verify and rm -rf when ready.");
1286
+ }
1287
+ // Remove crash-safety marker only on full success
1288
+ if (!dryRun && existsSync(markerPath) && projectErrors.length === 0) {
1289
+ try {
1290
+ unlinkSync(markerPath);
1291
+ }
1292
+ catch { /* best-effort */ }
1293
+ }
1294
+ recordActivityEvent({
1295
+ source: "migration",
1296
+ kind: "migration.completed",
1297
+ level: projectErrors.length > 0 ? "warn" : "info",
1298
+ summary: projectErrors.length > 0
1299
+ ? `migration finished with ${projectErrors.length} error(s)`
1300
+ : `migration completed: ${totals.projects} project(s), ${totals.sessions} session(s)`,
1301
+ data: {
1302
+ dryRun,
1303
+ projectsMigrated: totals.projects,
1304
+ sessions: totals.sessions,
1305
+ worktrees: totals.worktrees,
1306
+ strayWorktreesMoved: totals.strayWorktreesMoved,
1307
+ claudeSessionsRelinked: totals.claudeSessionsRelinked,
1308
+ codexSessionsRewritten: totals.codexSessionsRewritten,
1309
+ emptyDirsDeleted: totals.emptyDirsDeleted,
1310
+ projectErrors: projectErrors.length,
1311
+ },
1312
+ });
1313
+ return totals;
1314
+ }
1315
+ // ---------------------------------------------------------------------------
1316
+ // Rollback
1317
+ // ---------------------------------------------------------------------------
1318
+ /**
1319
+ * Count sessions in a V2 project dir that don't exist in any of the .migrated dirs.
1320
+ * These are sessions created after migration and would be lost by rollback.
1321
+ */
1322
+ function countPostMigrationSessions(projectDir, migratedDirs) {
1323
+ const sessionsDir = join(projectDir, "sessions");
1324
+ if (!existsSync(sessionsDir))
1325
+ return 0;
1326
+ // Collect all session IDs from .migrated dirs
1327
+ const migratedSessionIds = new Set();
1328
+ for (const dir of migratedDirs) {
1329
+ const oldSessionsDir = join(dir.path, "sessions");
1330
+ if (!existsSync(oldSessionsDir))
1331
+ continue;
1332
+ for (const file of readdirSync(oldSessionsDir)) {
1333
+ if (file === "archive" || file.startsWith("."))
1334
+ continue;
1335
+ const sessionId = file.endsWith(".json") ? file.slice(0, -5) : file;
1336
+ migratedSessionIds.add(sessionId);
1337
+ }
1338
+ const oldArchiveDir = join(oldSessionsDir, "archive");
1339
+ if (!existsSync(oldArchiveDir))
1340
+ continue;
1341
+ for (const file of readdirSync(oldArchiveDir)) {
1342
+ if (file.startsWith("."))
1343
+ continue;
1344
+ // Same anchor-on-timestamp pattern as the archive flattening loop;
1345
+ // the lazy `[a-zA-Z0-9_-]+?_\d` mismatched any sessionId containing
1346
+ // `_<digit>` (e.g. `team_1-7`).
1347
+ const match = file.match(/^(.+)_(\d{8}T\d{6}Z|\d{4}-\d{2}-\d{2}T[\d:.-]+Z)(?:\.json)?$/);
1348
+ if (match?.[1]) {
1349
+ migratedSessionIds.add(match[1]);
1350
+ }
1351
+ }
1352
+ }
1353
+ // Count sessions in V2 dir that aren't in any .migrated dir
1354
+ let count = 0;
1355
+ for (const file of readdirSync(sessionsDir)) {
1356
+ if (file === "archive" || file.startsWith("."))
1357
+ continue;
1358
+ const sessionId = file.endsWith(".json") ? file.slice(0, -5) : file;
1359
+ if (!migratedSessionIds.has(sessionId)) {
1360
+ count++;
1361
+ }
1362
+ }
1363
+ return count;
1364
+ }
1365
+ function collectSessionIds(dirPath) {
1366
+ const sessionIds = new Set();
1367
+ const sessionsDir = join(dirPath, "sessions");
1368
+ if (!existsSync(sessionsDir))
1369
+ return sessionIds;
1370
+ for (const file of readdirSync(sessionsDir)) {
1371
+ if (file === "archive" || file.startsWith("."))
1372
+ continue;
1373
+ sessionIds.add(file.endsWith(".json") ? file.slice(0, -5) : file);
1374
+ }
1375
+ return sessionIds;
1376
+ }
1377
+ function resolveRollbackProjectId(aoBaseDir, migratedDirPath, hash) {
1378
+ const derivedProjectId = deriveProjectIdFromDir(migratedDirPath);
1379
+ if (derivedProjectId)
1380
+ return derivedProjectId;
1381
+ const migratedSessionIds = collectSessionIds(migratedDirPath);
1382
+ if (migratedSessionIds.size === 0)
1383
+ return hash;
1384
+ const projectsDir = join(aoBaseDir, "projects");
1385
+ if (!existsSync(projectsDir))
1386
+ return hash;
1387
+ for (const projectId of readdirSync(projectsDir)) {
1388
+ const projectDir = join(projectsDir, projectId);
1389
+ try {
1390
+ if (!statSync(projectDir).isDirectory())
1391
+ continue;
1392
+ }
1393
+ catch {
1394
+ continue;
1395
+ }
1396
+ const projectSessionIds = collectSessionIds(projectDir);
1397
+ for (const sessionId of migratedSessionIds) {
1398
+ if (projectSessionIds.has(sessionId))
1399
+ return projectId;
1400
+ }
1401
+ }
1402
+ return hash;
1403
+ }
1404
+ async function rollbackStorage(options = {}) {
1405
+ const aoBaseDir = options.aoBaseDir ?? join(homedir(), ".agent-orchestrator");
1406
+ const dryRun = options.dryRun ?? false;
1407
+ const log = options.log ?? console.log;
1408
+ const globalConfigPath = options.globalConfigPath ??
1409
+ join(process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config"), "agent-orchestrator", "config.yaml");
1410
+ const effectiveConfigPath = existsSync(globalConfigPath)
1411
+ ? globalConfigPath
1412
+ : existsSync(join(aoBaseDir, "config.yaml"))
1413
+ ? join(aoBaseDir, "config.yaml")
1414
+ : globalConfigPath;
1415
+ if (dryRun) {
1416
+ log("DRY RUN — no changes will be made.\n");
1417
+ }
1418
+ if (!existsSync(aoBaseDir)) {
1419
+ log("No AO base directory found. Nothing to rollback.");
1420
+ return;
1421
+ }
1422
+ // Find .migrated directories (both {hash}-{name}.migrated and {hash}.migrated)
1423
+ const migratedDirs = [];
1424
+ for (const name of readdirSync(aoBaseDir)) {
1425
+ const hashNameMatch = MIGRATED_DIR_PATTERN.exec(name);
1426
+ const bareHashMatch = BARE_MIGRATED_DIR_PATTERN.exec(name);
1427
+ if (!hashNameMatch && !bareHashMatch)
1428
+ continue;
1429
+ const dirPath = join(aoBaseDir, name);
1430
+ try {
1431
+ if (!statSync(dirPath).isDirectory())
1432
+ continue;
1433
+ }
1434
+ catch {
1435
+ continue;
1436
+ }
1437
+ if (hashNameMatch) {
1438
+ migratedDirs.push({
1439
+ path: dirPath,
1440
+ hash: hashNameMatch[1],
1441
+ projectId: hashNameMatch[2],
1442
+ });
1443
+ }
1444
+ else if (bareHashMatch) {
1445
+ migratedDirs.push({
1446
+ path: dirPath,
1447
+ hash: bareHashMatch[1],
1448
+ projectId: resolveRollbackProjectId(aoBaseDir, dirPath, bareHashMatch[1]),
1449
+ });
1450
+ }
1451
+ }
1452
+ if (migratedDirs.length === 0) {
1453
+ log("No .migrated directories found. Nothing to rollback.");
1454
+ return;
1455
+ }
1456
+ log(`Found ${migratedDirs.length} .migrated director${migratedDirs.length === 1 ? "y" : "ies"}.`);
1457
+ // Check for post-migration sessions BEFORE renaming .migrated dirs
1458
+ // (we need to read the .migrated dir contents to compare).
1459
+ const projectsDir = join(aoBaseDir, "projects");
1460
+ const safeToDeleteProjects = new Set();
1461
+ const restoredProjects = new Set();
1462
+ const migratedProjectIds = new Set(migratedDirs.map((d) => d.projectId));
1463
+ if (existsSync(projectsDir)) {
1464
+ for (const projectId of migratedProjectIds) {
1465
+ const projectDir = join(projectsDir, projectId);
1466
+ if (!existsSync(projectDir))
1467
+ continue;
1468
+ const postMigrationSessions = countPostMigrationSessions(projectDir, migratedDirs.filter((d) => d.projectId === projectId));
1469
+ if (postMigrationSessions > 0) {
1470
+ log(` Warning: projects/${projectId} has ${postMigrationSessions} session(s) created after migration — skipping deletion.`);
1471
+ log(` These sessions exist only in projects/${projectId}/ and would be lost. Remove manually after verifying.`);
1472
+ recordActivityEvent({
1473
+ projectId,
1474
+ source: "migration",
1475
+ kind: "migration.rollback_skipped",
1476
+ level: "warn",
1477
+ summary: `rollback skipped projects/${projectId} — ${postMigrationSessions} post-migration session(s)`,
1478
+ data: {
1479
+ postMigrationSessions,
1480
+ },
1481
+ });
1482
+ }
1483
+ else {
1484
+ safeToDeleteProjects.add(projectId);
1485
+ }
1486
+ }
1487
+ }
1488
+ // Rename .migrated back to original
1489
+ for (const dir of migratedDirs) {
1490
+ const originalPath = dir.path.replace(/\.migrated$/, "");
1491
+ if (existsSync(originalPath)) {
1492
+ log(` Warning: ${basename(originalPath)} already exists — skipping restore of ${basename(dir.path)}. Resolve manually.`);
1493
+ safeToDeleteProjects.delete(dir.projectId);
1494
+ continue;
1495
+ }
1496
+ log(` Restoring: ${basename(dir.path)} → ${basename(originalPath)}`);
1497
+ if (!dryRun) {
1498
+ renameSync(dir.path, originalPath);
1499
+ }
1500
+ restoredProjects.add(dir.projectId);
1501
+ }
1502
+ // Move worktrees back to restored hash dirs, then remove project directories
1503
+ let rollbackWorktreesMoved = false;
1504
+ // (V2 → V1) pairs collected so we can reverse the Claude session-storage
1505
+ // relink that the forward migration performed.
1506
+ const rollbackWorkspaceMoves = [];
1507
+ if (existsSync(projectsDir)) {
1508
+ for (const projectId of safeToDeleteProjects) {
1509
+ if (!restoredProjects.has(projectId))
1510
+ continue;
1511
+ const projectDir = join(projectsDir, projectId);
1512
+ if (!existsSync(projectDir))
1513
+ continue;
1514
+ // Move worktrees back before deleting the project directory.
1515
+ // If multiple hash dirs existed for this project, consolidate worktrees into the
1516
+ // first restored hash dir. The original hash→worktree mapping is lost after
1517
+ // forward migration (worktrees were merged), so this is best-effort.
1518
+ const v2WorktreesDir = join(projectDir, "worktrees");
1519
+ if (existsSync(v2WorktreesDir)) {
1520
+ const projectMigratedDirs = migratedDirs.filter((d) => d.projectId === projectId);
1521
+ const targetHashDir = projectMigratedDirs[0]
1522
+ ? projectMigratedDirs[0].path.replace(/\.migrated$/, "")
1523
+ : null;
1524
+ if (targetHashDir && existsSync(targetHashDir)) {
1525
+ const oldWorktreesDir = join(targetHashDir, "worktrees");
1526
+ if (!dryRun)
1527
+ mkdirSync(oldWorktreesDir, { recursive: true });
1528
+ for (const wt of readdirSync(v2WorktreesDir)) {
1529
+ const src = join(v2WorktreesDir, wt);
1530
+ const dest = join(oldWorktreesDir, wt);
1531
+ if (!existsSync(dest)) {
1532
+ log(` Moving worktree back: projects/${projectId}/worktrees/${wt} → ${basename(targetHashDir)}/worktrees/${wt}`);
1533
+ if (!dryRun)
1534
+ crossDeviceMove(src, dest, log);
1535
+ rollbackWorktreesMoved = true;
1536
+ // For Claude relink-reverse: source dir's encoded path moves
1537
+ // back to the destination's encoded path.
1538
+ rollbackWorkspaceMoves.push({
1539
+ oldWorkspacePath: src,
1540
+ newWorkspacePath: dest,
1541
+ });
1542
+ }
1543
+ }
1544
+ }
1545
+ }
1546
+ }
1547
+ // Repair git worktree references broken by moving worktrees back
1548
+ if (!dryRun && rollbackWorktreesMoved) {
1549
+ await repairGitWorktrees(aoBaseDir, effectiveConfigPath, log);
1550
+ }
1551
+ // Reverse the Claude session-storage relink so chat history follows
1552
+ // the worktree back to its V1 encoded path.
1553
+ relinkClaudeSessionStorage(rollbackWorkspaceMoves, dryRun, log);
1554
+ // Reverse the Codex session_meta cwd rewrite so Codex restore lookup
1555
+ // continues to find threads after rollback.
1556
+ rewriteCodexSessionStorage(rollbackWorkspaceMoves, dryRun, log);
1557
+ // Remove project directories that are safe to delete
1558
+ for (const projectId of safeToDeleteProjects) {
1559
+ if (!restoredProjects.has(projectId))
1560
+ continue;
1561
+ const projectDir = join(projectsDir, projectId);
1562
+ if (!existsSync(projectDir))
1563
+ continue;
1564
+ log(` Removing migrated project directory: projects/${projectId}`);
1565
+ if (!dryRun) {
1566
+ rmSync(projectDir, { recursive: true, force: true });
1567
+ }
1568
+ }
1569
+ // Remove projects/ only if it's now empty
1570
+ if (!dryRun) {
1571
+ try {
1572
+ const remaining = readdirSync(projectsDir);
1573
+ if (remaining.length === 0) {
1574
+ rmSync(projectsDir, { recursive: true, force: true });
1575
+ }
1576
+ else {
1577
+ log(` Note: projects/ retained — contains ${remaining.length} non-migrated project(s).`);
1578
+ }
1579
+ }
1580
+ catch {
1581
+ // Ignore
1582
+ }
1583
+ }
1584
+ }
1585
+ // Re-add storageKey to config.
1586
+ if (existsSync(effectiveConfigPath)) {
1587
+ const content = readFileSync(effectiveConfigPath, "utf-8");
1588
+ const parsed = parse(content);
1589
+ if (parsed && typeof parsed === "object") {
1590
+ const projects = parsed["projects"];
1591
+ if (projects && typeof projects === "object") {
1592
+ let restored = 0;
1593
+ for (const dir of migratedDirs) {
1594
+ const entry = projects[dir.projectId];
1595
+ if (entry && typeof entry === "object") {
1596
+ const originalDirName = basename(dir.path).replace(/\.migrated$/, "");
1597
+ entry["storageKey"] = originalDirName;
1598
+ restored++;
1599
+ }
1600
+ }
1601
+ if (restored > 0) {
1602
+ log(` Restored storageKey for ${restored} project(s) in config.`);
1603
+ if (!dryRun) {
1604
+ writeFileSync(effectiveConfigPath, stringify(parsed, { indent: 2 }));
1605
+ }
1606
+ }
1607
+ }
1608
+ }
1609
+ }
1610
+ log("\nRollback complete. Old hash-based directories restored.");
1611
+ }
1612
+
1613
+ export { convertKeyValueToJson, detectActiveSessions, inventoryHashDirs, migrateStorage, rollbackStorage };
1614
+ //# sourceMappingURL=storage-v2.js.map