viber-channel 0.8.19 → 0.8.21

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.
@@ -0,0 +1,413 @@
1
+ /**
2
+ * runner_registry.ts — what the runner remembers about the agents it launched
3
+ * (#627 step-02).
4
+ *
5
+ * JP's rule: after a crash or a reboot nothing relaunches on its own, but the web
6
+ * offers, per disconnected conversation, "relaunch with the last settings". Those
7
+ * settings must therefore SURVIVE the machine — vibe-master's sidecar lives in
8
+ * tmpdir and dies with the boot (agent_meta.ts, #370), and the runner itself kept
9
+ * nothing per agent.
10
+ *
11
+ * One JSON file per backend, next to the agents' presence records:
12
+ *
13
+ * <presenceDir(web host)>/runner/registry.json
14
+ * <presenceDir(web host)>/runner/runner.json ← the runner's own local state
15
+ *
16
+ * An entry is keyed by `registryKey(project_id, name)`. The NAME (`{prefix}-{role}`,
17
+ * `rosterNames`) is the label the agent registers with, and the server keeps labels
18
+ * unique PER PROJECT — two projects can both run a `627-dev-lead`. So the runner joins
19
+ * an entry to the presence record carrying the same (project_id, label).
20
+ *
21
+ * The file holds each role's `rolePrompt` in clear. It is not a secret (the server
22
+ * validated it and ships it to the agent), but it is written owner-only (0600 — a
23
+ * POSIX mode; on Windows the file inherits the profile's ACL), like the runner's
24
+ * other files. Tokens never go in a role prompt, and never go here.
25
+ *
26
+ * Known limit: two runners on the same machine AND the same backend (the app plus a
27
+ * source `run-runner`) would race on this file and could lose an update. One runner
28
+ * per backend per machine is the supported shape.
29
+ *
30
+ * ⚠ An UNREADABLE registry is kept as it is and blocks every write and every
31
+ * relaunch. Overwriting it would erase the settings of every agent on the machine,
32
+ * which is the one loss JP asked us never to cause.
33
+ */
34
+ import { createHash } from "node:crypto";
35
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
36
+ import { dirname, join } from "node:path";
37
+ import { presenceDir } from "./presence_record.js";
38
+
39
+ export const REGISTRY_SCHEMA_VERSION = 1;
40
+
41
+ /** Closed entries older than this are dropped, so the file cannot grow forever. */
42
+ export const CLOSED_ENTRY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
43
+
44
+ /**
45
+ * The gateway's launch id of one member of one command — a COPY of
46
+ * `attemptLaunchId` (`viber-gateway/lib/team_derivation.ts`): `launch-` + the first 32
47
+ * hex of sha256 over the canonical JSON `{"agentId":…,"cmd":…}` (keys sorted).
48
+ * viber-channel is published alone and cannot import the gateway; the parity test in
49
+ * `test/runner_registry.test.ts` pins the copy to the original.
50
+ */
51
+ export function launchIdFor(commandId: string, name: string): string {
52
+ const canonical = `{"agentId":${JSON.stringify(name)},"cmd":${JSON.stringify(commandId)}}`;
53
+ return `launch-${createHash("sha256").update(canonical).digest("hex").slice(0, 32)}`;
54
+ }
55
+
56
+ /**
57
+ * Does this job name belong to this launch? The gateway mints `vibe-agent-<launchId>-<token>`
58
+ * (`job_name.ts`). A PREFIX test on that published shape, bounded by the trailing `-`,
59
+ * so `launch-ab` never matches `launch-abc…`. Pinned by the same parity test.
60
+ */
61
+ export function jobNameIsLaunch(jobName: string, launchId: string): boolean {
62
+ return jobName.startsWith(`vibe-agent-${launchId}-`);
63
+ }
64
+
65
+ /** The registry key of a member: project + name (a name is unique per project only). */
66
+ export function registryKey(projectId: number | null, name: string): string {
67
+ return `${projectId ?? "-"}:${name}`;
68
+ }
69
+
70
+ /** The launch settings of one role, as the server snapshot validated them. */
71
+ export interface RegistryRole {
72
+ role: string;
73
+ runtime: string;
74
+ permission: string;
75
+ rolePrompt?: string;
76
+ model?: string;
77
+ }
78
+
79
+ export type RegistryEntryState = "active" | "closed";
80
+
81
+ export interface RegistryEntry {
82
+ name: string;
83
+ command_id: string;
84
+ template_name: string;
85
+ prefix: string;
86
+ team_name: string | null;
87
+ env: string | null;
88
+ project_id: number | null;
89
+ workspace: string;
90
+ role: RegistryRole;
91
+ launched_at: number;
92
+ /** The gateway's launch id of this member (`launchIdFor`): the exact join key. */
93
+ launch_id: string;
94
+ /** When the entry became closed; null while active. */
95
+ closed_at: number | null;
96
+ /** The instance that carries this name now; null until its record is seen. */
97
+ instance_id: string | null;
98
+ conversation_id: string | null;
99
+ state: RegistryEntryState;
100
+ /** A relaunch (step-07) names the instance it replaces. */
101
+ replaces: string | null;
102
+ /**
103
+ * The replaced instance, until the server acknowledged it CLOSED: the roster
104
+ * reports it `closed` every beat meanwhile (#627 step-07). Absent in a registry
105
+ * written before this field: read as null.
106
+ */
107
+ pending_close?: string | null;
108
+ /**
109
+ * #627 (decision A, Codex): OLDER replaced instances whose conversations were not
110
+ * handed over yet when this entry was relaunched AGAIN (its replacement died mid-
111
+ * handover). Each still travels as a pair with the entry's current instance.
112
+ */
113
+ handover_from?: string[];
114
+ /**
115
+ * #627 (Codex): every instance this name REPLACED (newest last, bounded) — never
116
+ * resent, only read to find the successor when an old instance's button is used
117
+ * after its handover was acknowledged or refused for good.
118
+ */
119
+ predecessors?: string[];
120
+ }
121
+
122
+ export interface Registry {
123
+ schema_version: number;
124
+ entries: Record<string, RegistryEntry>;
125
+ }
126
+
127
+ export type RegistryRead =
128
+ | { kind: "ok"; registry: Registry }
129
+ | { kind: "absent" }
130
+ | { kind: "unreadable"; error: string };
131
+
132
+ export function runnerStateDir(identityBaseUrl: string, lockDir?: string): string {
133
+ return join(presenceDir(identityBaseUrl, lockDir), "runner");
134
+ }
135
+
136
+ export function registryPath(identityBaseUrl: string, lockDir?: string): string {
137
+ return join(runnerStateDir(identityBaseUrl, lockDir), "registry.json");
138
+ }
139
+
140
+ export function runnerStatePath(identityBaseUrl: string, lockDir?: string): string {
141
+ return join(runnerStateDir(identityBaseUrl, lockDir), "runner.json");
142
+ }
143
+
144
+ function atomicWriteJson(path: string, value: unknown): void {
145
+ mkdirSync(dirname(path), { recursive: true });
146
+ const tmp = `${path}.${process.pid}.tmp`;
147
+ writeFileSync(tmp, JSON.stringify(value, null, 2), { encoding: "utf8", mode: 0o600 });
148
+ try {
149
+ renameSync(tmp, path);
150
+ } catch (err) {
151
+ try {
152
+ unlinkSync(tmp);
153
+ } catch {
154
+ // already gone
155
+ }
156
+ throw err;
157
+ }
158
+ }
159
+
160
+ function isObject(v: unknown): v is Record<string, unknown> {
161
+ return typeof v === "object" && v !== null && !Array.isArray(v);
162
+ }
163
+
164
+ function isStrOrNull(v: unknown): boolean {
165
+ return v === null || typeof v === "string";
166
+ }
167
+
168
+ function isEntry(key: string, v: unknown): v is RegistryEntry {
169
+ if (!isObject(v) || !isObject(v.role)) return false;
170
+ if (typeof v.name !== "string") return false;
171
+ if (!(v.project_id === null || typeof v.project_id === "number")) return false;
172
+ if (registryKey(v.project_id as number | null, v.name) !== key) return false;
173
+ const r = v.role;
174
+ const okRole =
175
+ typeof r.role === "string" && typeof r.runtime === "string" && typeof r.permission === "string";
176
+ const okText =
177
+ typeof v.command_id === "string" &&
178
+ typeof v.template_name === "string" &&
179
+ typeof v.prefix === "string" &&
180
+ typeof v.workspace === "string" &&
181
+ typeof v.launch_id === "string";
182
+ const okNullable =
183
+ isStrOrNull(v.team_name) &&
184
+ isStrOrNull(v.env) &&
185
+ isStrOrNull(v.instance_id) &&
186
+ isStrOrNull(v.conversation_id) &&
187
+ isStrOrNull(v.replaces) &&
188
+ (v.pending_close === undefined || isStrOrNull(v.pending_close)) &&
189
+ (v.handover_from === undefined || (Array.isArray(v.handover_from) && v.handover_from.every((x) => typeof x === "string"))) &&
190
+ (v.predecessors === undefined || (Array.isArray(v.predecessors) && v.predecessors.every((x) => typeof x === "string"))) &&
191
+ (v.project_id === null || typeof v.project_id === "number");
192
+ const okState = v.state === "active" || v.state === "closed";
193
+ const okClosedAt = v.closed_at === null || typeof v.closed_at === "number";
194
+ return okRole && okText && okNullable && okState && okClosedAt && typeof v.launched_at === "number";
195
+ }
196
+
197
+ export function readRegistry(path: string): RegistryRead {
198
+ let raw: string;
199
+ try {
200
+ raw = readFileSync(path, "utf8");
201
+ } catch (err) {
202
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return { kind: "absent" };
203
+ return { kind: "unreadable", error: String(err) };
204
+ }
205
+ let parsed: unknown;
206
+ try {
207
+ parsed = JSON.parse(raw);
208
+ } catch (err) {
209
+ return { kind: "unreadable", error: `invalid JSON: ${String(err)}` };
210
+ }
211
+ if (!isObject(parsed) || parsed.schema_version !== REGISTRY_SCHEMA_VERSION || !isObject(parsed.entries)) {
212
+ return { kind: "unreadable", error: "invalid registry shape" };
213
+ }
214
+ for (const [name, entry] of Object.entries(parsed.entries)) {
215
+ if (!isEntry(name, entry)) return { kind: "unreadable", error: `invalid entry ${JSON.stringify(name)}` };
216
+ }
217
+ return { kind: "ok", registry: parsed as unknown as Registry };
218
+ }
219
+
220
+ /**
221
+ * Apply `change` to the registry on disk. Absent → start empty. Unreadable →
222
+ * REFUSE, leave the file untouched, and say so: the caller logs, never retries by
223
+ * overwriting.
224
+ */
225
+ export function updateRegistry(
226
+ path: string,
227
+ /** Return `false` to skip the write (nothing changed). */
228
+ change: (registry: Registry) => boolean | void,
229
+ ): { ok: true } | { ok: false; error: string } {
230
+ const read = readRegistry(path);
231
+ if (read.kind === "unreadable") return { ok: false, error: `registry unreadable, left untouched: ${read.error}` };
232
+ const registry: Registry =
233
+ read.kind === "ok" ? read.registry : { schema_version: REGISTRY_SCHEMA_VERSION, entries: {} };
234
+ if (change(registry) === false) return { ok: true };
235
+ try {
236
+ atomicWriteJson(path, registry);
237
+ return { ok: true };
238
+ } catch (err) {
239
+ return { ok: false, error: `registry write failed: ${String(err)}` };
240
+ }
241
+ }
242
+
243
+ /** What a spawn command tells the registry, taken from the claimed command. */
244
+ export interface SpawnForRegistry {
245
+ command_id: string;
246
+ template_name: string;
247
+ prefix: string;
248
+ team_name: string | null;
249
+ env: string | null;
250
+ project_id: number | null;
251
+ workspace: string;
252
+ roles: readonly (RegistryRole & { count?: number })[];
253
+ }
254
+
255
+ /**
256
+ * The member names of a roster, each with its role — the same naming as
257
+ * `rosterNames` (`{prefix}-{role}`, `-{i}` suffix when count > 1).
258
+ */
259
+ export function rosterMembers(
260
+ prefix: string,
261
+ roles: readonly (RegistryRole & { count?: number })[],
262
+ ): { name: string; role: RegistryRole }[] {
263
+ const members: { name: string; role: RegistryRole }[] = [];
264
+ for (const r of roles) {
265
+ const { count, ...role } = r;
266
+ const n = count && count > 1 ? count : 1;
267
+ if (n === 1) {
268
+ members.push({ name: `${prefix}-${r.role}`, role });
269
+ continue;
270
+ }
271
+ for (let i = 1; i <= n; i++) members.push({ name: `${prefix}-${r.role}-${i}`, role });
272
+ }
273
+ return members;
274
+ }
275
+
276
+ /**
277
+ * Record a launched team. A member already present is REPLACED: a new launch under
278
+ * the same name supersedes the settings of the old one, and its instance is not
279
+ * known until the new agent's record appears.
280
+ */
281
+ export function recordSpawn(registry: Registry, spawn: SpawnForRegistry, now: number): void {
282
+ pruneClosed(registry, now);
283
+ for (const { name, role } of rosterMembers(spawn.prefix, spawn.roles)) {
284
+ registry.entries[registryKey(spawn.project_id, name)] = {
285
+ name,
286
+ command_id: spawn.command_id,
287
+ template_name: spawn.template_name,
288
+ prefix: spawn.prefix,
289
+ team_name: spawn.team_name,
290
+ env: spawn.env,
291
+ project_id: spawn.project_id,
292
+ workspace: spawn.workspace,
293
+ role,
294
+ launched_at: now,
295
+ launch_id: launchIdFor(spawn.command_id, name),
296
+ closed_at: null,
297
+ instance_id: null,
298
+ conversation_id: null,
299
+ state: "active",
300
+ replaces: null,
301
+ pending_close: null,
302
+ };
303
+ }
304
+ }
305
+
306
+ /** Drop closed entries past `CLOSED_ENTRY_TTL_MS`. */
307
+ export function pruneClosed(registry: Registry, now: number): void {
308
+ for (const [key, entry] of Object.entries(registry.entries)) {
309
+ if (entry.state === "closed" && entry.closed_at !== null && now - entry.closed_at > CLOSED_ENTRY_TTL_MS) {
310
+ delete registry.entries[key];
311
+ }
312
+ }
313
+ }
314
+
315
+ /** What the runner saw in one LIVE agent's presence record. */
316
+ export interface SeenAgent {
317
+ label: string | null;
318
+ project_id: number | null;
319
+ instance_id: string;
320
+ conversation_id: string | null;
321
+ /** The record's `created_at`: when two live records carry the same name, the newest wins. */
322
+ created_at: number;
323
+ /** The record's `job_name`: when present, the EXACT join to an entry's launch. */
324
+ job_name: string | null;
325
+ }
326
+
327
+ /**
328
+ * Keep entries in step with the agents' records: the instance that carries a name
329
+ * and the conversation it is on. Returns whether anything changed, so the caller
330
+ * writes only when needed. A record without a label, or for a name the runner never
331
+ * launched, is ignored here — the runner reports it all the same (step-03).
332
+ */
333
+ export function syncFromRecords(registry: Registry, seen: readonly SeenAgent[]): boolean {
334
+ // 1. EXACT: a record whose job name is an entry's launch belongs to that entry,
335
+ // whatever its label says. 2. ONLY for a record with no job name (an agent the
336
+ // gateway did not launch, or older than #627): (project, name), and when two live records
337
+ // share it — a relaunch overlapping its old process — the NEWEST wins. Never
338
+ // the directory order.
339
+ const winners = new Map<string, { agent: SeenAgent; exact: boolean }>();
340
+ for (const agent of seen) {
341
+ const exactK = exactKey(registry, agent);
342
+ // A record WITH a job name belongs to that launch or to none: an old launch's
343
+ // record must never fill the entry of a newer one by its name (Codex, 2nd round).
344
+ const byName = agent.job_name === null && agent.label !== null;
345
+ const key = exactK ?? (byName ? registryKey(agent.project_id, agent.label as string) : undefined);
346
+ if (key === undefined) continue;
347
+ const exact = exactK !== undefined;
348
+ const held = winners.get(key);
349
+ const wins =
350
+ held === undefined ||
351
+ (exact && !held.exact) ||
352
+ (exact === held.exact && agent.created_at > held.agent.created_at);
353
+ if (wins) winners.set(key, { agent, exact });
354
+ }
355
+ let changed = false;
356
+ for (const [key, { agent }] of winners) {
357
+ const entry = registry.entries[key];
358
+ if (entry === undefined || entry.state === "closed") continue;
359
+ if (entry.instance_id !== agent.instance_id || entry.conversation_id !== agent.conversation_id) {
360
+ entry.instance_id = agent.instance_id;
361
+ entry.conversation_id = agent.conversation_id;
362
+ changed = true;
363
+ }
364
+ }
365
+ return changed;
366
+ }
367
+
368
+ /** The runner's own local presence, read by its agents (step-06). */
369
+ export interface RunnerState {
370
+ schema_version: number;
371
+ runner_id: string;
372
+ /** Epoch ms of the last heartbeat the server answered 2xx; null before the first. */
373
+ last_beat_ok_at: number | null;
374
+ }
375
+
376
+ export function writeRunnerState(path: string, state: Omit<RunnerState, "schema_version">): void {
377
+ atomicWriteJson(path, { schema_version: REGISTRY_SCHEMA_VERSION, ...state });
378
+ }
379
+
380
+ export function readRunnerState(path: string): RunnerState | null {
381
+ try {
382
+ const v: unknown = JSON.parse(readFileSync(path, "utf8"));
383
+ if (!isObject(v) || v.schema_version !== REGISTRY_SCHEMA_VERSION) return null;
384
+ if (typeof v.runner_id !== "string") return null;
385
+ if (!(v.last_beat_ok_at === null || typeof v.last_beat_ok_at === "number")) return null;
386
+ return v as unknown as RunnerState;
387
+ } catch {
388
+ return null;
389
+ }
390
+ }
391
+
392
+ function exactKey(registry: Registry, agent: SeenAgent): string | undefined {
393
+ if (agent.job_name === null) return undefined;
394
+ for (const [key, entry] of Object.entries(registry.entries)) {
395
+ // An empty launch id (a relaunch without a gateway job name) never joins exactly.
396
+ if (entry.launch_id !== "" && jobNameIsLaunch(agent.job_name, entry.launch_id)) return key;
397
+ }
398
+ return undefined;
399
+ }
400
+
401
+ /** A closed instance, acknowledged by the server: its entry will not be relaunched. */
402
+ export function markClosed(registry: Registry, instanceIds: readonly string[], now: number): boolean {
403
+ const ids = new Set(instanceIds);
404
+ let changed = false;
405
+ for (const entry of Object.values(registry.entries)) {
406
+ if (entry.state === "active" && entry.instance_id !== null && ids.has(entry.instance_id)) {
407
+ entry.state = "closed";
408
+ entry.closed_at = now;
409
+ changed = true;
410
+ }
411
+ }
412
+ return changed;
413
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * runner_relaunch.ts — the runner brings back ONE agent (#627 step-07).
3
+ *
4
+ * The web asked (JP, 2026-09-25): per disconnected conversation, "relaunch with the
5
+ * last settings". The server sends a `relaunch` command naming the instance; the
6
+ * runner — the only party that can see the machine — decides:
7
+ *
8
+ * - no registry entry for that instance → refused (only an agent this runner
9
+ * knows is relaunchable, JP 2026-09-26);
10
+ * - its process is still ALIVE → nothing is launched: the runner's next
11
+ * roster re-attaches it (a duplicate
12
+ * agent on one conversation is the one
13
+ * thing the lease exists to prevent);
14
+ * - the runner CANNOT TELL → refused, never a guess;
15
+ * - dead (record gone after an acknowledged
16
+ * down, or dead by pid + start time) → `vibe-master spawn` with the saved
17
+ * settings, on the SAME conversation
18
+ * (Gemma cannot reload one: a new one).
19
+ *
20
+ * After a successful launch the entry remembers the instance it replaces and marks
21
+ * it `pending_close`: the roster reports that old instance `closed` until the server
22
+ * acknowledges it, so no greyed ghost stays behind the new agent.
23
+ */
24
+ import { enumerateStartTimes } from "./process_start.js";
25
+ import { presenceRecordPath, readPresenceRecord } from "./presence_record.js";
26
+ import { type RegistryEntry, readRegistry, registryPath, updateRegistry } from "./runner_registry.js";
27
+ import { classifyRecord, type ProcessTable } from "./runner_roster.js";
28
+
29
+ /** What the web may be told: a typed outcome, like every other spawn report. */
30
+ export type RelaunchOutcome =
31
+ | { kind: "launched"; message: string }
32
+ | { kind: "reattached"; message: string }
33
+ | { kind: "refused"; error: string; policy?: boolean; /** vibe-master's own output, for the LOCAL log only. */ recap?: string };
34
+
35
+ /** vibe-master's runtime ids for the ones a roster carries. */
36
+ function vibeMasterRuntime(runtime: string): string {
37
+ return runtime === "claude-code" ? "claude" : runtime;
38
+ }
39
+
40
+ /** Runtimes that can reload an existing conversation (`vibe-master spawn --conversation`). */
41
+ const RELOADS_CONVERSATION = new Set(["claude", "codex"]);
42
+
43
+ /**
44
+ * The argv for one member, from its saved settings. PURE. `--conversation` only for a
45
+ * runtime that can reload one; Gemma starts a new conversation (JP, 2026-09-25).
46
+ */
47
+ export function relaunchArgs(entry: RegistryEntry): string[] {
48
+ const runtime = vibeMasterRuntime(entry.role.runtime);
49
+ // `--mode terminal`, like the runner's team launch (runner_exec.ts). Measured live (#627
50
+ // step 11): without it a codex relaunch defaults to background mode, which goes through
51
+ // vctl — and the runner's cwd (the app home) is no vctl project, so every relaunch failed.
52
+ const args = ["spawn", "--runtime", runtime, "--permission", entry.role.permission, "--cwd", entry.workspace, "--name", entry.name, "--mode", "terminal"];
53
+ if (entry.team_name) args.push("--team", entry.team_name);
54
+ if (entry.env) args.push("--env", entry.env);
55
+ if (entry.role.model) args.push("--model", entry.role.model);
56
+ if (entry.conversation_id && RELOADS_CONVERSATION.has(runtime)) args.push("--conversation", entry.conversation_id);
57
+ // Last: free text, nothing appended after it (same rule as the gateway's argv).
58
+ if (entry.role.rolePrompt) args.push("--role", entry.role.rolePrompt);
59
+ return args;
60
+ }
61
+
62
+ export interface RelaunchDeps {
63
+ identity: string;
64
+ lockDir?: string;
65
+ enumerate?: () => Promise<ProcessTable | undefined>;
66
+ /** Runs `vibe-master <args>`; ok = exit 0. */
67
+ runSpawn: (args: string[]) => Promise<{ ok: boolean; recap: string }>;
68
+ now?: () => number;
69
+ /** Today's local policy on the saved settings; a string = refused. */
70
+ checkPolicy?: (entry: RegistryEntry) => string | null;
71
+ /** Test seam: runs between the registry read and its preparation (a race). */
72
+ beforePrepare?: () => void;
73
+ }
74
+
75
+ function findEntry(entries: Record<string, RegistryEntry>, instanceId: string): [string, RegistryEntry] | undefined {
76
+ return Object.entries(entries).find(([, e]) => e.instance_id === instanceId);
77
+ }
78
+
79
+ /** Replaced instances remembered per name, only to find their successor (Codex). */
80
+ export const PREDECESSORS_KEPT = 20;
81
+
82
+ /** #627 (decision A, Codex): the entry that REPLACED this instance, if any. */
83
+ function findSuccessor(entries: Record<string, RegistryEntry>, instanceId: string): [string, RegistryEntry] | undefined {
84
+ return Object.entries(entries).find(
85
+ ([, e]) =>
86
+ e.state !== "closed" &&
87
+ (e.pending_close === instanceId || (e.handover_from ?? []).includes(instanceId) || (e.predecessors ?? []).includes(instanceId)),
88
+ );
89
+ }
90
+
91
+ export async function relaunchInstance(requestedId: string, deps: RelaunchDeps): Promise<RelaunchOutcome> {
92
+ const path = registryPath(deps.identity, deps.lockDir);
93
+ const read = readRegistry(path);
94
+ if (read.kind !== "ok") {
95
+ return { kind: "refused", error: read.kind === "absent" ? "this runner launched no agent yet" : `registry unreadable: ${read.error}` };
96
+ }
97
+ // #627 (decision A, Codex): relaunching an instance that was already REPLACED means
98
+ // relaunching its name — the entry now carries its successor, whose settings and
99
+ // handover it inherits. One launch per name: a successor still starting is refused.
100
+ let instanceId = requestedId;
101
+ let found = findEntry(read.registry.entries, instanceId);
102
+ if (found === undefined) {
103
+ const successor = findSuccessor(read.registry.entries, instanceId);
104
+ if (successor !== undefined) {
105
+ const current = successor[1].instance_id;
106
+ if (current === null) {
107
+ return { kind: "refused", error: "a relaunch of this agent is already starting — wait for it to come online" };
108
+ }
109
+ instanceId = current;
110
+ found = successor;
111
+ }
112
+ }
113
+ if (found === undefined) {
114
+ return { kind: "refused", error: "this runner has no saved settings for that agent — only an agent it launched can be relaunched" };
115
+ }
116
+ const [key, entry] = found;
117
+ if (entry.state === "closed") return { kind: "refused", error: "that agent was closed on purpose" };
118
+
119
+ // Alive? The record, when it still exists, is checked like the roster checks it.
120
+ const rec = readPresenceRecord(presenceRecordPath(deps.identity, instanceId, deps.lockDir));
121
+ if (rec.kind === "unreadable") return { kind: "refused", error: "cannot tell whether that agent is still running (its record is unreadable)" };
122
+ if (rec.kind === "ok") {
123
+ const table = await (deps.enumerate ?? (() => enumerateStartTimes()))();
124
+ if (table === undefined) return { kind: "refused", error: "cannot tell whether that agent is still running (process enumeration failed)" };
125
+ const verdict = classifyRecord(rec.record, table);
126
+ if (verdict.kind === "present") {
127
+ return { kind: "reattached", message: "that agent is still running — it is re-attached, nothing was launched" };
128
+ }
129
+ if (verdict.kind === "unknown") return { kind: "refused", error: `cannot tell whether that agent is still running (${verdict.why})` };
130
+ // A DECIDED end not acknowledged yet (revoked, lease lost to another holder,
131
+ // --once done) is a close: relaunching it is what decision 3 excludes (Opus I1).
132
+ if (verdict.kind === "closed") return { kind: "refused", error: "that agent ended on purpose — nothing to relaunch" };
133
+ }
134
+ // Record absent (an acknowledged down, then removed) or dead by pid + start time.
135
+
136
+ const refusal = deps.checkPolicy?.(entry) ?? null;
137
+ if (refusal !== null) return { kind: "refused", error: `local policy: ${refusal}`, policy: true };
138
+
139
+ // The registry is written BEFORE the launch (Codex): a launch whose bookkeeping
140
+ // could not be recorded would leave the old instance greyed for good and the new
141
+ // one unjoinable. A failed launch then restores the entry as it was.
142
+ const now = (deps.now ?? Date.now)();
143
+ const before = { ...entry };
144
+ deps.beforePrepare?.();
145
+ let preparedEntry = false;
146
+ const prepared = updateRegistry(path, (r) => {
147
+ const e = r.entries[key];
148
+ if (e === undefined) return false; // gone since the read: see `preparedEntry` below
149
+ preparedEntry = true;
150
+ e.replaces = instanceId;
151
+ // A predecessor still waiting for its handover is kept, not overwritten: its
152
+ // conversations go to whichever instance runs this name next (Codex).
153
+ if (e.pending_close && e.pending_close !== instanceId) {
154
+ e.handover_from = [...new Set([...(e.handover_from ?? []), e.pending_close])].filter((id) => id !== instanceId);
155
+ }
156
+ e.pending_close = instanceId;
157
+ e.predecessors = [...(e.predecessors ?? []).filter((id) => id !== instanceId), instanceId].slice(-PREDECESSORS_KEPT);
158
+ e.instance_id = null;
159
+ e.launched_at = now;
160
+ e.state = "active";
161
+ // This relaunch has no gateway job name: the old launch id must not let an old
162
+ // process's record join the NEW entry exactly (Opus I2). The new agent joins by
163
+ // (project, name), then its own instance id.
164
+ e.launch_id = "";
165
+ return true;
166
+ });
167
+ if (!prepared.ok) return { kind: "refused", error: `the runner registry could not be updated, nothing launched: ${prepared.error}` };
168
+ // "Nothing written" is not success: the entry vanished between the read and the
169
+ // write, so there is nowhere to record the new agent — never launch (Codex).
170
+ if (!preparedEntry) return { kind: "refused", error: "that agent's saved settings vanished before the relaunch, nothing launched" };
171
+
172
+ const result = await deps.runSpawn(relaunchArgs(entry));
173
+ if (!result.ok) {
174
+ updateRegistry(path, (r) => {
175
+ r.entries[key] = before;
176
+ return true;
177
+ });
178
+ return { kind: "refused", error: "vibe-master could not relaunch that agent — see the runner log", recap: result.recap };
179
+ }
180
+ return { kind: "launched", message: `relaunched ${entry.name} with its last settings` };
181
+ }