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.
- package/README.md +1 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +6 -0
- package/dist/commands/agents.d.ts +24 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +244 -13
- package/dist/commands/backup.d.ts +5 -4
- package/dist/commands/backup.d.ts.map +1 -1
- package/dist/commands/backup.js +15 -14
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +27 -1
- package/dist/commands/claude-desktop.d.ts +19 -0
- package/dist/commands/claude-desktop.d.ts.map +1 -0
- package/dist/commands/claude-desktop.js +168 -0
- package/dist/commands/context.d.ts.map +1 -1
- package/dist/commands/context.js +149 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +2 -3
- package/dist/commands/eml.d.ts +32 -0
- package/dist/commands/eml.d.ts.map +1 -1
- package/dist/commands/eml.js +16 -3
- package/dist/commands/harness.d.ts +11 -0
- package/dist/commands/harness.d.ts.map +1 -0
- package/dist/commands/harness.js +118 -0
- package/dist/commands/presence.d.ts +9 -4
- package/dist/commands/presence.d.ts.map +1 -1
- package/dist/commands/presence.js +88 -5
- package/dist/commands/relay.d.ts +9 -0
- package/dist/commands/relay.d.ts.map +1 -0
- package/dist/commands/relay.js +143 -0
- package/dist/commands/sync.d.ts.map +1 -1
- package/dist/commands/sync.js +5 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +53 -17
- package/dist/core/agents/canonical-emit.d.ts +15 -0
- package/dist/core/agents/canonical-emit.d.ts.map +1 -1
- package/dist/core/agents/canonical-emit.js +41 -3
- package/dist/core/agents/cli.js +4 -2
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +44 -3
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +26 -1
- package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/commit-conflict.js +4 -37
- package/dist/core/agents/session-events.d.ts.map +1 -1
- package/dist/core/agents/session-events.js +16 -43
- package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-projector.js +11 -2
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +3 -3
- package/dist/core/agents/state/names.d.ts +38 -3
- package/dist/core/agents/state/names.d.ts.map +1 -1
- package/dist/core/agents/state/names.js +46 -7
- package/dist/core/agents/state/pidmap.d.ts +5 -1
- package/dist/core/agents/state/pidmap.d.ts.map +1 -1
- package/dist/core/agents/state/pidmap.js +58 -2
- package/dist/core/agents/state/stale-sweep.d.ts +3 -2
- package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
- package/dist/core/agents/state/stale-sweep.js +6 -6
- package/dist/core/config.d.ts +68 -11
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +191 -25
- package/dist/core/context/index.d.ts +144 -0
- package/dist/core/context/index.d.ts.map +1 -0
- package/dist/core/context/index.js +380 -0
- package/dist/core/harnesses/bench.d.ts +29 -0
- package/dist/core/harnesses/bench.d.ts.map +1 -0
- package/dist/core/harnesses/bench.js +151 -0
- package/dist/core/harnesses/index.d.ts +9 -0
- package/dist/core/harnesses/index.d.ts.map +1 -0
- package/dist/core/harnesses/index.js +4 -0
- package/dist/core/harnesses/profiles.d.ts +62 -0
- package/dist/core/harnesses/profiles.d.ts.map +1 -0
- package/dist/core/harnesses/profiles.js +115 -0
- package/dist/core/harnesses/registry.d.ts +15 -0
- package/dist/core/harnesses/registry.d.ts.map +1 -0
- package/dist/core/harnesses/registry.js +113 -0
- package/dist/core/harnesses/types.d.ts +64 -0
- package/dist/core/harnesses/types.d.ts.map +1 -0
- package/dist/core/harnesses/types.js +20 -0
- package/dist/core/hooks/cli.js +280 -63
- package/dist/core/hooks/events/schema.d.ts +38 -1
- package/dist/core/hooks/events/schema.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.js +3 -0
- package/dist/core/hooks/harness/parse.d.ts +1 -1
- package/dist/core/hooks/harness/parse.d.ts.map +1 -1
- package/dist/core/hooks/harness/parse.js +4 -0
- package/dist/core/presence/blob.d.ts +40 -0
- package/dist/core/presence/blob.d.ts.map +1 -0
- package/dist/core/presence/blob.js +91 -0
- package/dist/core/presence/git.d.ts +68 -0
- package/dist/core/presence/git.d.ts.map +1 -0
- package/dist/core/presence/git.js +173 -0
- package/dist/core/presence/index.d.ts +74 -0
- package/dist/core/presence/index.d.ts.map +1 -0
- package/dist/core/presence/index.js +224 -0
- package/dist/core/presence/relay-client.d.ts +39 -0
- package/dist/core/presence/relay-client.d.ts.map +1 -0
- package/dist/core/presence/relay-client.js +295 -0
- package/dist/core/presence/relay-protocol.d.ts +94 -0
- package/dist/core/presence/relay-protocol.d.ts.map +1 -0
- package/dist/core/presence/relay-protocol.js +169 -0
- package/dist/core/workflow/billing.d.ts +1 -1
- package/dist/core/workflow/billing.d.ts.map +1 -1
- package/dist/core/workflow/billing.js +7 -1
- package/dist/core/workflow/engine.d.ts +5 -0
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +164 -16
- package/dist/core/workflow/harnesses.d.ts +3 -3
- package/dist/core/workflow/harnesses.d.ts.map +1 -1
- package/dist/core/workflow/harnesses.js +8 -17
- package/dist/core/workflow/index.d.ts +5 -0
- package/dist/core/workflow/index.d.ts.map +1 -0
- package/dist/core/workflow/index.js +3 -0
- package/dist/core/workflow/proof.d.ts +42 -0
- package/dist/core/workflow/proof.d.ts.map +1 -0
- package/dist/core/workflow/proof.js +342 -0
- package/dist/core/workflow/spawn-claude.d.ts +4 -1
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
- package/dist/core/workflow/spawn-claude.js +39 -18
- package/dist/core/workflow/spawn-codex.d.ts +4 -1
- package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
- package/dist/core/workflow/spawn-codex.js +44 -22
- package/dist/core/workflow/spawn-cursor.d.ts +4 -1
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
- package/dist/core/workflow/spawn-cursor.js +35 -16
- package/dist/core/workflow/types.d.ts +156 -2
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/types.js +1 -1
- package/dist/lib/browser/client.d.ts +16 -0
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +22 -0
- package/dist/lib/browser/index.d.ts +1 -0
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/index.js +1 -0
- package/dist/lib/browser/launch-args.d.ts +21 -0
- package/dist/lib/browser/launch-args.d.ts.map +1 -0
- package/dist/lib/browser/launch-args.js +32 -0
- package/dist/lib/claude-desktop.d.ts +105 -0
- package/dist/lib/claude-desktop.d.ts.map +1 -0
- package/dist/lib/claude-desktop.js +217 -0
- package/dist/lib/docs-lint.d.ts.map +1 -1
- package/dist/lib/docs-lint.js +5 -2
- package/dist/lib/identities/assume.d.ts +51 -0
- package/dist/lib/identities/assume.d.ts.map +1 -0
- package/dist/lib/identities/assume.js +274 -0
- package/dist/lib/identities/index.d.ts +7 -7
- package/dist/lib/identities/index.d.ts.map +1 -1
- package/dist/lib/identities/index.js +24 -24
- package/dist/lib/instructions/templates.d.ts.map +1 -1
- package/dist/lib/instructions/templates.js +15 -2
- package/package.json +22 -6
- package/schemas/config.schema.json +88 -19
- package/src/commander.ts +6 -0
- package/src/commands/agents.ts +280 -14
- package/src/commands/backup.ts +17 -16
- package/src/commands/browse.ts +32 -0
- package/src/commands/claude-desktop.ts +215 -0
- package/src/commands/context.ts +174 -1
- package/src/commands/doctor.ts +2 -3
- package/src/commands/eml.ts +22 -4
- package/src/commands/harness.ts +147 -0
- package/src/commands/presence.ts +111 -5
- package/src/commands/relay.ts +160 -0
- package/src/commands/sync.ts +4 -0
- package/src/commands/workflow.ts +62 -19
- package/src/core/agents/canonical-emit.ts +41 -3
- package/src/core/agents/cli.ts +4 -2
- package/src/core/agents/render/prompt-context.ts +48 -3
- package/src/core/agents/render/session-context.ts +27 -1
- package/src/core/agents/rules/commit-conflict.ts +4 -33
- package/src/core/agents/session-events.ts +15 -39
- package/src/core/agents/state/heartbeat-projector.ts +10 -1
- package/src/core/agents/state/heartbeat-writer.ts +3 -2
- package/src/core/agents/state/names.ts +68 -9
- package/src/core/agents/state/pidmap.ts +59 -2
- package/src/core/agents/state/stale-sweep.ts +6 -11
- package/src/core/config.ts +248 -24
- package/src/core/context/index.ts +575 -0
- package/src/core/harnesses/bench.ts +214 -0
- package/src/core/harnesses/index.ts +31 -0
- package/src/core/harnesses/profiles.ts +141 -0
- package/src/core/harnesses/registry.ts +140 -0
- package/src/core/harnesses/types.ts +90 -0
- package/src/core/hooks/cli.ts +331 -62
- package/src/core/hooks/events/schema.ts +68 -0
- package/src/core/hooks/harness/events.ts +3 -0
- package/src/core/hooks/harness/parse.ts +7 -1
- package/src/core/presence/blob.ts +125 -0
- package/src/core/presence/git.ts +191 -0
- package/src/core/presence/index.ts +274 -0
- package/src/core/presence/relay-client.ts +322 -0
- package/src/core/presence/relay-protocol.ts +246 -0
- package/src/core/workflow/billing.ts +8 -2
- package/src/core/workflow/engine.ts +182 -16
- package/src/core/workflow/harnesses.ts +14 -19
- package/src/core/workflow/index.ts +43 -0
- package/src/core/workflow/proof.ts +434 -0
- package/src/core/workflow/spawn-claude.ts +39 -20
- package/src/core/workflow/spawn-codex.ts +47 -28
- package/src/core/workflow/spawn-cursor.ts +37 -18
- package/src/core/workflow/types.ts +176 -2
- package/src/lib/browser/client.ts +37 -0
- package/src/lib/browser/index.ts +1 -0
- package/src/lib/browser/launch-args.ts +33 -0
- package/src/lib/claude-desktop.ts +293 -0
- package/src/lib/docs-lint.ts +5 -2
- package/src/lib/identities/assume.ts +363 -0
- package/src/lib/identities/index.ts +28 -24
- 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
|
+
}
|
package/src/lib/docs-lint.ts
CHANGED
|
@@ -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,
|
|
65
|
-
* (these are recognized by the GitHub UI, and
|
|
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
|
+
}
|