harnery 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/README.md +1 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +6 -0
  4. package/dist/commands/agents.d.ts +24 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +244 -13
  7. package/dist/commands/backup.d.ts +5 -4
  8. package/dist/commands/backup.d.ts.map +1 -1
  9. package/dist/commands/backup.js +15 -14
  10. package/dist/commands/browse.d.ts.map +1 -1
  11. package/dist/commands/browse.js +27 -1
  12. package/dist/commands/claude-desktop.d.ts +19 -0
  13. package/dist/commands/claude-desktop.d.ts.map +1 -0
  14. package/dist/commands/claude-desktop.js +168 -0
  15. package/dist/commands/context.d.ts.map +1 -1
  16. package/dist/commands/context.js +149 -1
  17. package/dist/commands/doctor.d.ts.map +1 -1
  18. package/dist/commands/doctor.js +2 -3
  19. package/dist/commands/eml.d.ts +32 -0
  20. package/dist/commands/eml.d.ts.map +1 -1
  21. package/dist/commands/eml.js +16 -3
  22. package/dist/commands/harness.d.ts +11 -0
  23. package/dist/commands/harness.d.ts.map +1 -0
  24. package/dist/commands/harness.js +118 -0
  25. package/dist/commands/presence.d.ts +9 -4
  26. package/dist/commands/presence.d.ts.map +1 -1
  27. package/dist/commands/presence.js +88 -5
  28. package/dist/commands/relay.d.ts +9 -0
  29. package/dist/commands/relay.d.ts.map +1 -0
  30. package/dist/commands/relay.js +143 -0
  31. package/dist/commands/sync.d.ts.map +1 -1
  32. package/dist/commands/sync.js +5 -0
  33. package/dist/commands/workflow.d.ts.map +1 -1
  34. package/dist/commands/workflow.js +53 -17
  35. package/dist/core/agents/canonical-emit.d.ts +15 -0
  36. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  37. package/dist/core/agents/canonical-emit.js +41 -3
  38. package/dist/core/agents/cli.js +4 -2
  39. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  40. package/dist/core/agents/render/prompt-context.js +44 -3
  41. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  42. package/dist/core/agents/render/session-context.js +26 -1
  43. package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
  44. package/dist/core/agents/rules/commit-conflict.js +4 -37
  45. package/dist/core/agents/session-events.d.ts.map +1 -1
  46. package/dist/core/agents/session-events.js +16 -43
  47. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  48. package/dist/core/agents/state/heartbeat-projector.js +11 -2
  49. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  50. package/dist/core/agents/state/heartbeat-writer.js +3 -3
  51. package/dist/core/agents/state/names.d.ts +38 -3
  52. package/dist/core/agents/state/names.d.ts.map +1 -1
  53. package/dist/core/agents/state/names.js +46 -7
  54. package/dist/core/agents/state/pidmap.d.ts +5 -1
  55. package/dist/core/agents/state/pidmap.d.ts.map +1 -1
  56. package/dist/core/agents/state/pidmap.js +58 -2
  57. package/dist/core/agents/state/stale-sweep.d.ts +3 -2
  58. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  59. package/dist/core/agents/state/stale-sweep.js +6 -6
  60. package/dist/core/config.d.ts +68 -11
  61. package/dist/core/config.d.ts.map +1 -1
  62. package/dist/core/config.js +191 -25
  63. package/dist/core/context/index.d.ts +144 -0
  64. package/dist/core/context/index.d.ts.map +1 -0
  65. package/dist/core/context/index.js +380 -0
  66. package/dist/core/harnesses/bench.d.ts +29 -0
  67. package/dist/core/harnesses/bench.d.ts.map +1 -0
  68. package/dist/core/harnesses/bench.js +151 -0
  69. package/dist/core/harnesses/index.d.ts +9 -0
  70. package/dist/core/harnesses/index.d.ts.map +1 -0
  71. package/dist/core/harnesses/index.js +4 -0
  72. package/dist/core/harnesses/profiles.d.ts +62 -0
  73. package/dist/core/harnesses/profiles.d.ts.map +1 -0
  74. package/dist/core/harnesses/profiles.js +115 -0
  75. package/dist/core/harnesses/registry.d.ts +15 -0
  76. package/dist/core/harnesses/registry.d.ts.map +1 -0
  77. package/dist/core/harnesses/registry.js +113 -0
  78. package/dist/core/harnesses/types.d.ts +64 -0
  79. package/dist/core/harnesses/types.d.ts.map +1 -0
  80. package/dist/core/harnesses/types.js +20 -0
  81. package/dist/core/hooks/cli.js +280 -63
  82. package/dist/core/hooks/events/schema.d.ts +38 -1
  83. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  84. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  85. package/dist/core/hooks/harness/events.js +3 -0
  86. package/dist/core/hooks/harness/parse.d.ts +1 -1
  87. package/dist/core/hooks/harness/parse.d.ts.map +1 -1
  88. package/dist/core/hooks/harness/parse.js +4 -0
  89. package/dist/core/presence/blob.d.ts +40 -0
  90. package/dist/core/presence/blob.d.ts.map +1 -0
  91. package/dist/core/presence/blob.js +91 -0
  92. package/dist/core/presence/git.d.ts +68 -0
  93. package/dist/core/presence/git.d.ts.map +1 -0
  94. package/dist/core/presence/git.js +173 -0
  95. package/dist/core/presence/index.d.ts +74 -0
  96. package/dist/core/presence/index.d.ts.map +1 -0
  97. package/dist/core/presence/index.js +224 -0
  98. package/dist/core/presence/relay-client.d.ts +39 -0
  99. package/dist/core/presence/relay-client.d.ts.map +1 -0
  100. package/dist/core/presence/relay-client.js +295 -0
  101. package/dist/core/presence/relay-protocol.d.ts +94 -0
  102. package/dist/core/presence/relay-protocol.d.ts.map +1 -0
  103. package/dist/core/presence/relay-protocol.js +169 -0
  104. package/dist/core/workflow/billing.d.ts +1 -1
  105. package/dist/core/workflow/billing.d.ts.map +1 -1
  106. package/dist/core/workflow/billing.js +7 -1
  107. package/dist/core/workflow/engine.d.ts +5 -0
  108. package/dist/core/workflow/engine.d.ts.map +1 -1
  109. package/dist/core/workflow/engine.js +164 -16
  110. package/dist/core/workflow/harnesses.d.ts +3 -3
  111. package/dist/core/workflow/harnesses.d.ts.map +1 -1
  112. package/dist/core/workflow/harnesses.js +8 -17
  113. package/dist/core/workflow/index.d.ts +5 -0
  114. package/dist/core/workflow/index.d.ts.map +1 -0
  115. package/dist/core/workflow/index.js +3 -0
  116. package/dist/core/workflow/proof.d.ts +42 -0
  117. package/dist/core/workflow/proof.d.ts.map +1 -0
  118. package/dist/core/workflow/proof.js +342 -0
  119. package/dist/core/workflow/spawn-claude.d.ts +4 -1
  120. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  121. package/dist/core/workflow/spawn-claude.js +39 -18
  122. package/dist/core/workflow/spawn-codex.d.ts +4 -1
  123. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  124. package/dist/core/workflow/spawn-codex.js +44 -22
  125. package/dist/core/workflow/spawn-cursor.d.ts +4 -1
  126. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  127. package/dist/core/workflow/spawn-cursor.js +35 -16
  128. package/dist/core/workflow/types.d.ts +156 -2
  129. package/dist/core/workflow/types.d.ts.map +1 -1
  130. package/dist/core/workflow/types.js +1 -1
  131. package/dist/lib/browser/client.d.ts +16 -0
  132. package/dist/lib/browser/client.d.ts.map +1 -1
  133. package/dist/lib/browser/client.js +22 -0
  134. package/dist/lib/browser/index.d.ts +1 -0
  135. package/dist/lib/browser/index.d.ts.map +1 -1
  136. package/dist/lib/browser/index.js +1 -0
  137. package/dist/lib/browser/launch-args.d.ts +21 -0
  138. package/dist/lib/browser/launch-args.d.ts.map +1 -0
  139. package/dist/lib/browser/launch-args.js +32 -0
  140. package/dist/lib/claude-desktop.d.ts +105 -0
  141. package/dist/lib/claude-desktop.d.ts.map +1 -0
  142. package/dist/lib/claude-desktop.js +217 -0
  143. package/dist/lib/docs-lint.d.ts.map +1 -1
  144. package/dist/lib/docs-lint.js +5 -2
  145. package/dist/lib/identities/assume.d.ts +51 -0
  146. package/dist/lib/identities/assume.d.ts.map +1 -0
  147. package/dist/lib/identities/assume.js +274 -0
  148. package/dist/lib/identities/index.d.ts +7 -7
  149. package/dist/lib/identities/index.d.ts.map +1 -1
  150. package/dist/lib/identities/index.js +24 -24
  151. package/dist/lib/instructions/templates.d.ts.map +1 -1
  152. package/dist/lib/instructions/templates.js +15 -2
  153. package/package.json +22 -6
  154. package/schemas/config.schema.json +88 -19
  155. package/src/commander.ts +6 -0
  156. package/src/commands/agents.ts +280 -14
  157. package/src/commands/backup.ts +17 -16
  158. package/src/commands/browse.ts +32 -0
  159. package/src/commands/claude-desktop.ts +215 -0
  160. package/src/commands/context.ts +174 -1
  161. package/src/commands/doctor.ts +2 -3
  162. package/src/commands/eml.ts +22 -4
  163. package/src/commands/harness.ts +147 -0
  164. package/src/commands/presence.ts +111 -5
  165. package/src/commands/relay.ts +160 -0
  166. package/src/commands/sync.ts +4 -0
  167. package/src/commands/workflow.ts +62 -19
  168. package/src/core/agents/canonical-emit.ts +41 -3
  169. package/src/core/agents/cli.ts +4 -2
  170. package/src/core/agents/render/prompt-context.ts +48 -3
  171. package/src/core/agents/render/session-context.ts +27 -1
  172. package/src/core/agents/rules/commit-conflict.ts +4 -33
  173. package/src/core/agents/session-events.ts +15 -39
  174. package/src/core/agents/state/heartbeat-projector.ts +10 -1
  175. package/src/core/agents/state/heartbeat-writer.ts +3 -2
  176. package/src/core/agents/state/names.ts +68 -9
  177. package/src/core/agents/state/pidmap.ts +59 -2
  178. package/src/core/agents/state/stale-sweep.ts +6 -11
  179. package/src/core/config.ts +248 -24
  180. package/src/core/context/index.ts +575 -0
  181. package/src/core/harnesses/bench.ts +214 -0
  182. package/src/core/harnesses/index.ts +31 -0
  183. package/src/core/harnesses/profiles.ts +141 -0
  184. package/src/core/harnesses/registry.ts +140 -0
  185. package/src/core/harnesses/types.ts +90 -0
  186. package/src/core/hooks/cli.ts +331 -62
  187. package/src/core/hooks/events/schema.ts +68 -0
  188. package/src/core/hooks/harness/events.ts +3 -0
  189. package/src/core/hooks/harness/parse.ts +7 -1
  190. package/src/core/presence/blob.ts +125 -0
  191. package/src/core/presence/git.ts +191 -0
  192. package/src/core/presence/index.ts +274 -0
  193. package/src/core/presence/relay-client.ts +322 -0
  194. package/src/core/presence/relay-protocol.ts +246 -0
  195. package/src/core/workflow/billing.ts +8 -2
  196. package/src/core/workflow/engine.ts +182 -16
  197. package/src/core/workflow/harnesses.ts +14 -19
  198. package/src/core/workflow/index.ts +43 -0
  199. package/src/core/workflow/proof.ts +434 -0
  200. package/src/core/workflow/spawn-claude.ts +39 -20
  201. package/src/core/workflow/spawn-codex.ts +47 -28
  202. package/src/core/workflow/spawn-cursor.ts +37 -18
  203. package/src/core/workflow/types.ts +176 -2
  204. package/src/lib/browser/client.ts +37 -0
  205. package/src/lib/browser/index.ts +1 -0
  206. package/src/lib/browser/launch-args.ts +33 -0
  207. package/src/lib/claude-desktop.ts +293 -0
  208. package/src/lib/docs-lint.ts +5 -2
  209. package/src/lib/identities/assume.ts +363 -0
  210. package/src/lib/identities/index.ts +28 -24
  211. package/src/lib/instructions/templates.ts +16 -2
@@ -0,0 +1,293 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, join } from "node:path";
4
+
5
+ /**
6
+ * Claude desktop app session-index access.
7
+ *
8
+ * The Claude desktop app keeps each signed-in account's Claude Code session
9
+ * sidebar as plain JSON files:
10
+ *
11
+ * <dataDir>/claude-code-sessions/<account-uuid>/<env-id>/local_<uuid>.json
12
+ *
13
+ * One file per sidebar entry: { sessionId, cliSessionId, cwd, title, ... }.
14
+ * Nothing inside the file binds it to an account — the scoping is purely
15
+ * which <account-uuid> directory it sits in, and the transcripts themselves
16
+ * live in the (account-agnostic) `~/.claude/projects/` tree of the runtime
17
+ * environment. So when a user signs out of account A and into account B,
18
+ * their sessions "disappear" from the sidebar even though every byte is
19
+ * still on disk. Copying an entry file into another account's directory
20
+ * makes the session visible (and resumable) there.
21
+ *
22
+ * These helpers locate the desktop app's data directory (including the
23
+ * Windows one from inside WSL, where the app runs on the Windows side but
24
+ * the CLI runs in the distro), enumerate the per-account entries, and
25
+ * plan/apply cross-account mirrors. The file format is the desktop app's
26
+ * private state, not a public API — everything here treats it read-mostly
27
+ * and copies files verbatim rather than rewriting them.
28
+ */
29
+
30
+ export interface DesktopSessionEntry {
31
+ /** Absolute path of the local_*.json entry file. */
32
+ file: string;
33
+ /** Account UUID directory the entry sits under (= sidebar scoping). */
34
+ accountUuid: string;
35
+ /** Environment directory between account and entry (VM/env identity). */
36
+ envId: string;
37
+ /** The entry's own id (usually "local_<uuid>", mirrors the filename). */
38
+ sessionId: string | null;
39
+ /** The Claude Code session id — matches the JSONL transcript filename. */
40
+ cliSessionId: string | null;
41
+ cwd: string | null;
42
+ title: string | null;
43
+ model: string | null;
44
+ isArchived: boolean;
45
+ createdAt: number | null;
46
+ lastActivityAt: number | null;
47
+ }
48
+
49
+ export interface DesktopAccount {
50
+ accountUuid: string;
51
+ path: string;
52
+ entries: DesktopSessionEntry[];
53
+ }
54
+
55
+ export interface MirrorAction {
56
+ from: string;
57
+ to: string;
58
+ entry: DesktopSessionEntry;
59
+ targetAccountUuid: string;
60
+ }
61
+
62
+ export interface MirrorPlan {
63
+ actions: MirrorAction[];
64
+ /** Entries skipped because the target already lists that cliSessionId. */
65
+ skippedExisting: number;
66
+ /** Entries skipped because they are archived (opt in via includeArchived). */
67
+ skippedArchived: number;
68
+ }
69
+
70
+ const SESSIONS_DIRNAME = "claude-code-sessions";
71
+
72
+ function isWsl(): boolean {
73
+ if (process.env.WSL_DISTRO_NAME) return true;
74
+ try {
75
+ return readFileSync("/proc/version", "utf8").toLowerCase().includes("microsoft");
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Candidate desktop-app data directories for this machine, most specific
83
+ * first. Only directories that exist AND contain a claude-code-sessions/
84
+ * subdirectory are returned. Precedence: explicit arg, then
85
+ * HARNERY_CLAUDE_DESKTOP_DIR, then platform defaults (on WSL that means
86
+ * scanning every /mnt/c/Users/<user>/AppData/Roaming/Claude).
87
+ */
88
+ export function findDesktopDataDirs(explicit?: string): string[] {
89
+ const candidates: string[] = [];
90
+ if (explicit) candidates.push(explicit);
91
+ else if (process.env.HARNERY_CLAUDE_DESKTOP_DIR) {
92
+ candidates.push(process.env.HARNERY_CLAUDE_DESKTOP_DIR);
93
+ } else {
94
+ const home = homedir();
95
+ if (process.platform === "darwin") {
96
+ candidates.push(join(home, "Library", "Application Support", "Claude"));
97
+ } else if (process.platform === "win32") {
98
+ if (process.env.APPDATA) candidates.push(join(process.env.APPDATA, "Claude"));
99
+ candidates.push(join(home, "AppData", "Roaming", "Claude"));
100
+ } else {
101
+ candidates.push(join(home, ".config", "Claude"));
102
+ if (isWsl()) {
103
+ for (const usersRoot of ["/mnt/c/Users"]) {
104
+ if (!existsSync(usersRoot)) continue;
105
+ for (const user of safeReaddir(usersRoot)) {
106
+ candidates.push(join(usersRoot, user, "AppData", "Roaming", "Claude"));
107
+ }
108
+ }
109
+ }
110
+ }
111
+ }
112
+ const seen = new Set<string>();
113
+ const out: string[] = [];
114
+ for (const dir of candidates) {
115
+ if (seen.has(dir)) continue;
116
+ seen.add(dir);
117
+ if (existsSync(join(dir, SESSIONS_DIRNAME))) out.push(dir);
118
+ }
119
+ return out;
120
+ }
121
+
122
+ function safeReaddir(dir: string): string[] {
123
+ try {
124
+ return readdirSync(dir);
125
+ } catch {
126
+ return [];
127
+ }
128
+ }
129
+
130
+ function isDir(p: string): boolean {
131
+ try {
132
+ return statSync(p).isDirectory();
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ function readEntry(file: string, accountUuid: string, envId: string): DesktopSessionEntry | null {
139
+ let raw: unknown;
140
+ try {
141
+ raw = JSON.parse(readFileSync(file, "utf8"));
142
+ } catch {
143
+ return null;
144
+ }
145
+ if (typeof raw !== "object" || raw === null) return null;
146
+ const d = raw as Record<string, unknown>;
147
+ const str = (k: string): string | null => (typeof d[k] === "string" ? (d[k] as string) : null);
148
+ const num = (k: string): number | null => (typeof d[k] === "number" ? (d[k] as number) : null);
149
+ return {
150
+ file,
151
+ accountUuid,
152
+ envId,
153
+ sessionId: str("sessionId"),
154
+ cliSessionId: str("cliSessionId"),
155
+ cwd: str("cwd"),
156
+ title: str("title"),
157
+ model: str("model"),
158
+ isArchived: d.isArchived === true,
159
+ createdAt: num("createdAt"),
160
+ lastActivityAt: num("lastActivityAt"),
161
+ };
162
+ }
163
+
164
+ /** Enumerate every account directory + its session entries under one data dir. */
165
+ export function listAccounts(dataDir: string): DesktopAccount[] {
166
+ const root = join(dataDir, SESSIONS_DIRNAME);
167
+ const accounts: DesktopAccount[] = [];
168
+ for (const accountUuid of safeReaddir(root)) {
169
+ const accountPath = join(root, accountUuid);
170
+ if (!isDir(accountPath)) continue;
171
+ const entries: DesktopSessionEntry[] = [];
172
+ for (const envId of safeReaddir(accountPath)) {
173
+ const envPath = join(accountPath, envId);
174
+ if (!isDir(envPath)) continue;
175
+ for (const f of safeReaddir(envPath)) {
176
+ if (!f.endsWith(".json")) continue;
177
+ const entry = readEntry(join(envPath, f), accountUuid, envId);
178
+ if (entry) entries.push(entry);
179
+ }
180
+ }
181
+ entries.sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0));
182
+ accounts.push({ accountUuid, path: accountPath, entries });
183
+ }
184
+ accounts.sort(
185
+ (a, b) =>
186
+ (b.entries[0]?.lastActivityAt ?? 0) - (a.entries[0]?.lastActivityAt ?? 0) ||
187
+ a.accountUuid.localeCompare(b.accountUuid),
188
+ );
189
+ return accounts;
190
+ }
191
+
192
+ /**
193
+ * The account the runtime environment's Claude Code CLI is signed into,
194
+ * read from ~/.claude.json (best-effort; null when unavailable). Lets the
195
+ * command label an otherwise-opaque account UUID with an email address.
196
+ */
197
+ export function readCliAccount(): { accountUuid: string; email: string | null } | null {
198
+ try {
199
+ const raw = JSON.parse(readFileSync(join(homedir(), ".claude.json"), "utf8")) as Record<
200
+ string,
201
+ unknown
202
+ >;
203
+ const oa = raw.oauthAccount as Record<string, unknown> | undefined;
204
+ if (!oa || typeof oa.accountUuid !== "string") return null;
205
+ return {
206
+ accountUuid: oa.accountUuid,
207
+ email: typeof oa.emailAddress === "string" ? oa.emailAddress : null,
208
+ };
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ export interface MirrorOptions {
215
+ /** Restrict targets to accounts whose uuid starts with one of these. */
216
+ to?: string[];
217
+ /** Restrict sources to accounts whose uuid starts with one of these. */
218
+ from?: string[];
219
+ /**
220
+ * Selectors: each matches a cliSessionId / sessionId exactly, or a title
221
+ * case-insensitively as a substring. Empty/undefined = every entry.
222
+ */
223
+ sessions?: string[];
224
+ includeArchived?: boolean;
225
+ }
226
+
227
+ export function entryMatchesSelector(entry: DesktopSessionEntry, selector: string): boolean {
228
+ if (entry.cliSessionId === selector || entry.sessionId === selector) return true;
229
+ return entry.title !== null && entry.title.toLowerCase().includes(selector.toLowerCase());
230
+ }
231
+
232
+ /**
233
+ * Plan the copies that would make each target account list the selected
234
+ * sessions. Union semantics: every entry a source account has and a target
235
+ * account lacks (keyed by cliSessionId, falling back to filename) becomes a
236
+ * copy action. Idempotent by construction — planning again after applying
237
+ * yields zero actions.
238
+ */
239
+ export function planMirror(accounts: DesktopAccount[], opts: MirrorOptions = {}): MirrorPlan {
240
+ const matchPrefix = (uuid: string, prefixes?: string[]) =>
241
+ !prefixes || prefixes.length === 0 || prefixes.some((p) => uuid.startsWith(p));
242
+
243
+ const targets = accounts.filter((a) => matchPrefix(a.accountUuid, opts.to));
244
+ const sources = accounts.filter((a) => matchPrefix(a.accountUuid, opts.from));
245
+
246
+ const actions: MirrorAction[] = [];
247
+ let skippedExisting = 0;
248
+ let skippedArchived = 0;
249
+
250
+ for (const target of targets) {
251
+ const have = new Set<string>();
252
+ for (const e of target.entries) {
253
+ if (e.cliSessionId) have.add(e.cliSessionId);
254
+ have.add(basename(e.file));
255
+ }
256
+ for (const source of sources) {
257
+ if (source.accountUuid === target.accountUuid) continue;
258
+ for (const entry of source.entries) {
259
+ if (opts.sessions?.length && !opts.sessions.some((s) => entryMatchesSelector(entry, s))) {
260
+ continue;
261
+ }
262
+ const key = entry.cliSessionId ?? basename(entry.file);
263
+ if (have.has(key) || have.has(basename(entry.file))) {
264
+ skippedExisting++;
265
+ continue;
266
+ }
267
+ if (entry.isArchived && !opts.includeArchived) {
268
+ skippedArchived++;
269
+ continue;
270
+ }
271
+ have.add(key);
272
+ actions.push({
273
+ from: entry.file,
274
+ to: join(target.path, entry.envId, basename(entry.file)),
275
+ entry,
276
+ targetAccountUuid: target.accountUuid,
277
+ });
278
+ }
279
+ }
280
+ }
281
+ return { actions, skippedExisting, skippedArchived };
282
+ }
283
+
284
+ /** Apply a plan: verbatim file copies (never rewrites entry contents). */
285
+ export function applyMirror(plan: MirrorPlan): { copied: number } {
286
+ let copied = 0;
287
+ for (const action of plan.actions) {
288
+ mkdirSync(dirname(action.to), { recursive: true });
289
+ copyFileSync(action.from, action.to);
290
+ copied++;
291
+ }
292
+ return { copied };
293
+ }
@@ -61,8 +61,10 @@ export interface LintOpts {
61
61
  /** Files allowed at a submodule root level. Includes:
62
62
  * - In-repo conventions: README.md, CLAUDE.md, LLM-BRIEFING.md, AGENTS.md
63
63
  * - GitHub OSS conventions: CHANGELOG.md, CONTRIBUTING.md, CODE_OF_CONDUCT.md,
64
- * SECURITY.md, SUPPORT.md, AUTHORS.md, MAINTAINERS.md, PULL_REQUEST_TEMPLATE.md
65
- * (these are recognized by the GitHub UI, and renaming them breaks the integration)
64
+ * LICENSE.md, SECURITY.md, SUPPORT.md, AUTHORS.md, MAINTAINERS.md,
65
+ * PULL_REQUEST_TEMPLATE.md (these are recognized by the GitHub UI, and
66
+ * renaming them breaks the integration; LICENSE.md also appears inside
67
+ * vendored upstream trees, where renaming would damage provenance)
66
68
  */
67
69
  const ROOT_FILE_ALLOWLIST = new Set([
68
70
  "README.md",
@@ -73,6 +75,7 @@ const ROOT_FILE_ALLOWLIST = new Set([
73
75
  "CHANGELOG.md",
74
76
  "CONTRIBUTING.md",
75
77
  "CODE_OF_CONDUCT.md",
78
+ "LICENSE.md",
76
79
  "SECURITY.md",
77
80
  "SUPPORT.md",
78
81
  "AUTHORS.md",
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Bind a live session to a durable agent persona.
3
+ *
4
+ * The operation is serialized because "is this name live?" and "claim it"
5
+ * must be one critical section. Authoritative state is written twice on
6
+ * purpose: `.name-history` heals a missing heartbeat, while the canonical
7
+ * `identity.assumed` event makes replay and derived readers converge.
8
+ *
9
+ * Local collision policy: a fresh heartbeat alone is not enough to block.
10
+ * If the namesake has no live pid-map process (crashed session, healed
11
+ * zombie, or abandoned harness), reclaim that heartbeat and continue.
12
+ * Refuse only when another process is still alive, or when cached remote
13
+ * presence reports the name.
14
+ */
15
+
16
+ import { randomUUID } from "node:crypto";
17
+ import {
18
+ existsSync,
19
+ mkdirSync,
20
+ readdirSync,
21
+ readFileSync,
22
+ statSync,
23
+ unlinkSync,
24
+ writeFileSync,
25
+ } from "node:fs";
26
+ import { join } from "node:path";
27
+
28
+ import { emitAndProject } from "../../core/agents/cli-emit.ts";
29
+ import { emit } from "../../core/agents/events/emit.ts";
30
+ import { type Heartbeat, readHeartbeat } from "../../core/agents/state/heartbeat-writer.ts";
31
+ import { recordNameAssumption } from "../../core/agents/state/names.ts";
32
+ import { instanceHasLivePid, removePidmapRowsForInstance } from "../../core/agents/state/pidmap.ts";
33
+ import { coordFreshnessSeconds } from "../../core/config.ts";
34
+ import { readRemoteMachines } from "../../core/presence/index.ts";
35
+ import { type AgentIdentity, bareName, ensureIdentity, lookupById, lookupByName } from "./index.ts";
36
+
37
+ const ASSUME_LOCK = "identity-assume.lock";
38
+ const ASSUME_LOCK_STALE_MS = 30_000;
39
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
40
+
41
+ export type IdentityAssumeErrorCode =
42
+ | "identity_busy"
43
+ | "identity_in_use"
44
+ | "identity_not_found"
45
+ | "invalid_identity"
46
+ | "no_heartbeat"
47
+ | "not_session"
48
+ | "projection_failed";
49
+
50
+ export class IdentityAssumeError extends Error {
51
+ constructor(
52
+ public readonly code: IdentityAssumeErrorCode,
53
+ message: string,
54
+ ) {
55
+ super(message);
56
+ this.name = "IdentityAssumeError";
57
+ }
58
+ }
59
+
60
+ export interface IdentityConflict {
61
+ instance_id: string;
62
+ name: string;
63
+ scope: "local" | "remote";
64
+ machine?: string;
65
+ }
66
+
67
+ export interface IdentityAssumeResult {
68
+ changed: boolean;
69
+ instance_id: string;
70
+ session_id: string;
71
+ previous_name: string | null;
72
+ previous_agent_id: string | null;
73
+ name: string;
74
+ agent_id: string;
75
+ identity_created: boolean;
76
+ event_id: string | null;
77
+ /** Prior local holder swept because it had no live process. */
78
+ reclaimed_instance_id: string | null;
79
+ }
80
+
81
+ function heartbeatIsFresh(hb: Heartbeat, cutoffMs: number): boolean {
82
+ const ts = Date.parse(hb.last_heartbeat);
83
+ return Number.isFinite(ts) && ts >= cutoffMs;
84
+ }
85
+
86
+ /** Best-effort collision read across local heartbeats and cached remote
87
+ * presence. No network call occurs; absent presence degrades to local-only. */
88
+ export function findIdentityConflict(
89
+ coordRoot: string,
90
+ instanceId: string,
91
+ name: string,
92
+ nowMs = Date.now(),
93
+ ): IdentityConflict | null {
94
+ const wanted = name.toLowerCase();
95
+ const cutoffMs = nowMs - coordFreshnessSeconds(coordRoot) * 1000;
96
+ const activeDir = join(coordRoot, ".harnery", "active");
97
+ if (existsSync(activeDir)) {
98
+ for (const file of readdirSync(activeDir)) {
99
+ if (!file.endsWith(".json")) continue;
100
+ try {
101
+ const hb = JSON.parse(readFileSync(join(activeDir, file), "utf8")) as Heartbeat;
102
+ if (hb.instance_id === instanceId || !heartbeatIsFresh(hb, cutoffMs)) continue;
103
+ if ((hb.name ?? "").toLowerCase() === wanted) {
104
+ return { instance_id: hb.instance_id, name: hb.name ?? name, scope: "local" };
105
+ }
106
+ } catch {
107
+ // Malformed heartbeats are handled by stale sweep, not this command.
108
+ }
109
+ }
110
+ }
111
+
112
+ for (const remote of readRemoteMachines(coordRoot)) {
113
+ for (const agent of remote.agents) {
114
+ if (agent.instance_id === instanceId) continue;
115
+ if ((agent.name ?? "").toLowerCase() === wanted) {
116
+ return {
117
+ instance_id: agent.instance_id,
118
+ name: agent.name ?? name,
119
+ scope: "remote",
120
+ machine: remote.machine,
121
+ };
122
+ }
123
+ }
124
+ }
125
+ return null;
126
+ }
127
+
128
+ /**
129
+ * Drop a local namesake whose harness process is gone. Fresh heartbeats can
130
+ * linger after a crash (or after heartbeat heal without a live pid-map), and
131
+ * `identity assume` is the supported takeover path — operators should not
132
+ * need a separate kill/sweep step.
133
+ */
134
+ export function reclaimAbandonedLocalConflict(
135
+ coordRoot: string,
136
+ conflict: IdentityConflict,
137
+ ): boolean {
138
+ if (conflict.scope !== "local") return false;
139
+ if (instanceHasLivePid(coordRoot, conflict.instance_id)) return false;
140
+
141
+ const hbPath = join(coordRoot, ".harnery", "active", `${conflict.instance_id}.json`);
142
+ let harness: "claude-code" | "cursor" | "codex" = "claude-code";
143
+ let sessionId = conflict.instance_id;
144
+ let ageSecs: number | undefined;
145
+ if (existsSync(hbPath)) {
146
+ try {
147
+ const hb = JSON.parse(readFileSync(hbPath, "utf8")) as Heartbeat;
148
+ harness = harnessOf(hb.platform);
149
+ sessionId = hb.session_id || conflict.instance_id;
150
+ const ts = Date.parse(hb.last_heartbeat);
151
+ if (Number.isFinite(ts)) ageSecs = Math.max(0, Math.floor((Date.now() - ts) / 1000));
152
+ } catch {
153
+ /* content unreadable — still reclaim the file */
154
+ }
155
+ try {
156
+ unlinkSync(hbPath);
157
+ } catch {
158
+ return false;
159
+ }
160
+ }
161
+ removePidmapRowsForInstance(coordRoot, conflict.instance_id);
162
+ try {
163
+ emit(coordRoot, {
164
+ event_type: "health.heartbeat_swept",
165
+ instance_id: conflict.instance_id,
166
+ session_id: sessionId,
167
+ harness,
168
+ source: "agent-coord",
169
+ data: {
170
+ reason: "stale",
171
+ ...(ageSecs !== undefined ? { age_secs: ageSecs } : {}),
172
+ reclaimed_by: "identity.assume",
173
+ },
174
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
175
+ } as Parameters<typeof emit>[1]);
176
+ } catch {
177
+ /* telemetry only */
178
+ }
179
+ return true;
180
+ }
181
+
182
+ function resolveTarget(
183
+ coordRoot: string,
184
+ input: string,
185
+ ): {
186
+ identity: AgentIdentity | null;
187
+ name: string;
188
+ } {
189
+ const trimmed = input.trim();
190
+ if (!trimmed) {
191
+ throw new IdentityAssumeError("invalid_identity", "identity name cannot be empty");
192
+ }
193
+ if (UUID_RE.test(trimmed)) {
194
+ const identity = lookupById(trimmed, coordRoot);
195
+ if (!identity) {
196
+ throw new IdentityAssumeError(
197
+ "identity_not_found",
198
+ `no durable identity matching '${trimmed}'`,
199
+ );
200
+ }
201
+ return { identity, name: identity.name };
202
+ }
203
+
204
+ const name = bareName(trimmed);
205
+ if (!name) {
206
+ throw new IdentityAssumeError("invalid_identity", "identity name cannot be empty");
207
+ }
208
+ const existing = lookupByName(name, coordRoot);
209
+ return { identity: existing, name: existing?.name ?? name };
210
+ }
211
+
212
+ function acquireLock(coordRoot: string): () => void {
213
+ const dir = join(coordRoot, ".harnery");
214
+ const path = join(dir, ASSUME_LOCK);
215
+ mkdirSync(dir, { recursive: true });
216
+ const token = `${process.pid}:${randomUUID()}`;
217
+
218
+ for (let attempt = 0; attempt < 2; attempt++) {
219
+ try {
220
+ writeFileSync(path, token, { encoding: "utf8", flag: "wx" });
221
+ return () => {
222
+ try {
223
+ if (readFileSync(path, "utf8") === token) unlinkSync(path);
224
+ } catch {
225
+ // The critical section completed; a missing lock needs no recovery.
226
+ }
227
+ };
228
+ } catch (error) {
229
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
230
+ try {
231
+ if (Date.now() - statSync(path).mtimeMs > ASSUME_LOCK_STALE_MS) {
232
+ unlinkSync(path);
233
+ continue;
234
+ }
235
+ } catch {
236
+ continue;
237
+ }
238
+ break;
239
+ }
240
+ }
241
+ throw new IdentityAssumeError(
242
+ "identity_busy",
243
+ "another identity assumption is in progress; retry in a moment",
244
+ );
245
+ }
246
+
247
+ function harnessOf(platform: string | undefined): "claude-code" | "cursor" | "codex" {
248
+ if (platform === "cursor") return "cursor";
249
+ if (platform === "codex") return "codex";
250
+ return "claude-code";
251
+ }
252
+
253
+ /** Assume `target` for one live main session. Safe to retry. */
254
+ export function assumeIdentity(
255
+ coordRoot: string,
256
+ instanceId: string,
257
+ target: string,
258
+ ): IdentityAssumeResult {
259
+ const release = acquireLock(coordRoot);
260
+ try {
261
+ const hb = readHeartbeat(coordRoot, instanceId);
262
+ if (!hb) {
263
+ throw new IdentityAssumeError(
264
+ "no_heartbeat",
265
+ `no live heartbeat for instance '${instanceId}'`,
266
+ );
267
+ }
268
+ if (hb.kind && hb.kind !== "session") {
269
+ throw new IdentityAssumeError(
270
+ "not_session",
271
+ `identity assumption is limited to main sessions (current kind: ${hb.kind})`,
272
+ );
273
+ }
274
+
275
+ const targetIdentity = resolveTarget(coordRoot, target);
276
+ let reclaimedInstanceId: string | null = null;
277
+ let conflict = findIdentityConflict(coordRoot, instanceId, targetIdentity.name);
278
+ if (conflict && reclaimAbandonedLocalConflict(coordRoot, conflict)) {
279
+ reclaimedInstanceId = conflict.instance_id;
280
+ conflict = findIdentityConflict(coordRoot, instanceId, targetIdentity.name);
281
+ }
282
+ if (conflict) {
283
+ const where = conflict.scope === "remote" ? ` on ${conflict.machine}` : "";
284
+ throw new IdentityAssumeError(
285
+ "identity_in_use",
286
+ `agent-${targetIdentity.name} is already live${where} (${conflict.instance_id}); end or stale-sweep that session before assuming the identity`,
287
+ );
288
+ }
289
+ const identity = targetIdentity.identity ?? ensureIdentity(targetIdentity.name, coordRoot);
290
+ const created = !targetIdentity.identity;
291
+
292
+ const previousName = hb.name || null;
293
+ const previousAgentId = hb.agent_id || null;
294
+ const history = recordNameAssumption(
295
+ coordRoot,
296
+ instanceId,
297
+ identity.name,
298
+ identity.agent_id,
299
+ "session",
300
+ );
301
+ const changed =
302
+ history.changed || previousName !== identity.name || previousAgentId !== identity.agent_id;
303
+ if (!changed) {
304
+ return {
305
+ changed: false,
306
+ instance_id: instanceId,
307
+ session_id: hb.session_id,
308
+ previous_name: previousName,
309
+ previous_agent_id: previousAgentId,
310
+ name: identity.name,
311
+ agent_id: identity.agent_id,
312
+ identity_created: false,
313
+ event_id: null,
314
+ reclaimed_instance_id: reclaimedInstanceId,
315
+ };
316
+ }
317
+
318
+ const emitted = emitAndProject(
319
+ {
320
+ event_type: "identity.assumed",
321
+ instance_id: instanceId,
322
+ session_id: hb.session_id,
323
+ harness: harnessOf(hb.platform),
324
+ data: {
325
+ name: identity.name,
326
+ agent_id: identity.agent_id,
327
+ ...(previousName ? { previous_name: previousName } : {}),
328
+ ...(previousAgentId ? { previous_agent_id: previousAgentId } : {}),
329
+ ...(reclaimedInstanceId ? { reclaimed_instance_id: reclaimedInstanceId } : {}),
330
+ },
331
+ },
332
+ { coordRoot },
333
+ );
334
+ if (!emitted?.projected) {
335
+ throw new IdentityAssumeError(
336
+ "projection_failed",
337
+ "the durable name binding was recorded but event projection failed; rerun the command to heal it",
338
+ );
339
+ }
340
+
341
+ const projected = readHeartbeat(coordRoot, instanceId);
342
+ if (projected?.name !== identity.name || projected.agent_id !== identity.agent_id) {
343
+ throw new IdentityAssumeError(
344
+ "projection_failed",
345
+ "identity.assumed was emitted but the heartbeat did not converge; rerun the command",
346
+ );
347
+ }
348
+ return {
349
+ changed: true,
350
+ instance_id: instanceId,
351
+ session_id: hb.session_id,
352
+ previous_name: previousName,
353
+ previous_agent_id: previousAgentId,
354
+ name: identity.name,
355
+ agent_id: identity.agent_id,
356
+ identity_created: created,
357
+ event_id: emitted.envelope.event_id,
358
+ reclaimed_instance_id: reclaimedInstanceId,
359
+ };
360
+ } finally {
361
+ release();
362
+ }
363
+ }