@haven_ai/connect 0.0.0-dev.202609040858.f4467bb → 0.0.0-dev.202609041540.2214a02

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 CHANGED
@@ -92,7 +92,8 @@ already-running host, which is why the restart guidance below matters.
92
92
 
93
93
  ## Retiring an old agent directory
94
94
 
95
- Re-running setup creates a NEW agent and retires nothing. Long-lived MCP hosts
95
+ Re-running setup without `--replace` creates a NEW agent and retires nothing
96
+ (with `--replace`, see [Running setup again](#running-setup-again)). Long-lived MCP hosts
96
97
  (gateways, TUI workers, editors, desktop apps) load their MCP wiring once, at
97
98
  process start — a host started before your latest setup keeps spawning the OLD
98
99
  agent's signer path forever, and when that directory is later removed the spawn
@@ -173,7 +174,9 @@ dashboard whenever ready and verify later with the read-only `haven_get_agent`
173
174
  tool. The object includes runtime/topology status,
174
175
  probe result, activation and next-action guidance, approval state/expiry (null
175
176
  when the backend does not provide an approval expiry), the two
176
- read-only verification tools, `hosted_mcp_url`, and `superseded_agent_ids`. It
177
+ read-only verification tools, `hosted_mcp_url`, `superseded_agent_ids`, and —
178
+ on a run that replaced existing wiring — `superseded_agents_retired_locally`
179
+ with `retired_agent_ids`. It
177
180
  contains no API key, private key, credential
178
181
  contents, full credential paths, or full delegate address. The same redacted
179
182
  object is available to library callers as `runConnect(...).outcome`; the older
@@ -186,11 +189,26 @@ mismatch. It is non-secret: the same string goes into your own MCP config file,
186
189
  and the API key travels beside it in a header.
187
190
 
188
191
  `superseded_agent_ids` lists the other agent directories on this machine. A
189
- re-run mints a NEW agent and retires nothing, so those older agents still hold
190
- live API and signing keys — revoke them on the Haven agent page if you meant to
191
- replace them. Empty on a clean first run; an empty list is not a guarantee,
192
- since a scan that cannot read the credential root also yields one rather than
193
- failing a completed setup.
192
+ re-run mints a NEW agent, and without `--replace` retires nothing, so those
193
+ older agents still hold live API and signing keys — revoke them on the Haven
194
+ agent page if you meant to replace them. Empty on a clean first run; an empty
195
+ list is not a guarantee, since a scan that cannot read the credential root also
196
+ yields one rather than failing a completed setup. On a `--replace` run, `retired_agent_ids` names the
197
+ directories that were actually tombstoned and had their local key files removed
198
+ — **the collision set only**, never the whole `superseded_agent_ids` list, which
199
+ also names named agents that coexist with the replaced bare pair and are
200
+ untouched — and `superseded_agents_retired_locally` is `true` when that
201
+ retirement covered every collision entry (`false`: the install ended with an
202
+ error code and the retirement was skipped, or one entry failed). Both fields
203
+ are absent on any other run and say nothing about the backend, where nothing is
204
+ revoked.
205
+
206
+ A `wiring_collision` refusal (a non-interactive bare setup over a live
207
+ previous agent, #2551) carries `error.superseded_agent_ids` and
208
+ `error.suggested_name` — the ids the human needs in order to decide, and a
209
+ valid `--name` the run can be re-issued with — with `next_action:
210
+ relay_wiring_collision_to_user`. It is a relay, not a retry hint: do not append
211
+ `--replace` or `--name` on the agent's own initiative.
194
212
 
195
213
  For a recoverable install, configuration, probe, consent, or manual-runtime
196
214
  condition, inspect `error.code` and `error.next_action`, then follow the safe
@@ -247,6 +265,17 @@ adding a security boundary. (The local-signer tool-exposure
247
265
  acknowledgement is a separate, machine-checkable consent about what the local
248
266
  MCP tools expose, not a registration gate.)
249
267
 
268
+ The one question Connect does put before registration is not about the
269
+ agent being created but about the one already here: a **wiring collision**
270
+ ([#2551](https://github.com/d-hinders/Haven-AI/issues/2551)). When the bare
271
+ `haven` / `haven-signer` pair on this machine already belongs to a different
272
+ agent whose directory still holds a live key, proceeding would silently
273
+ re-point that pair — the state `--doctor` reports as a still-spend-capable
274
+ `superseded` directory. That choice (replace it, or install alongside under a
275
+ name) belongs to the user, and it is asked *before* the key is minted or the
276
+ agent registered so that declining leaves no orphaned `pending_approval` agent
277
+ behind. See [Running setup again](#running-setup-again).
278
+
250
279
  ## Running setup again
251
280
 
252
281
  Each setup prompt is one-time and each successful run creates a **new** agent
@@ -264,17 +293,44 @@ already-configured machine behaves as follows (characterized in
264
293
  credentials into its own directory under `~/.haven/agents/<agent-id>/`,
265
294
  alongside the previous agent's directory, which stays byte-identical.
266
295
  Nothing is rotated, revoked, or deleted locally.
267
- - **Runtime MCP entries are replaced, not duplicated**: Connect owns the
268
- `haven` and `haven-signer` entries (and the managed Codex/Hermes
269
- equivalents) and re-points them at the newest agent's credentials.
270
- Unrelated MCP servers and configuration are preserved. Without `--name`, one
271
- runtime is therefore wired to one Haven agent — the newest one. With
296
+ - **A bare re-run over a live previous agent is a decision, not a default
297
+ ([#2551](https://github.com/d-hinders/Haven-AI/issues/2551)).** Before
298
+ minting a key or registering, Connect scans the credential root for a
299
+ bare-pair directory that still holds a usable key — the same reading
300
+ `--doctor` classifies as `wired` or `superseded`; `retired`, `orphaned`,
301
+ `parked` and **named** directories never count. If it finds one:
302
+ - an **interactive terminal** is asked to choose — **replace** (below) or
303
+ **install alongside** under a name Connect proposes from the agent's
304
+ display name (e.g. `payment-agent`), collision-checked against the
305
+ directories already there;
306
+ - a **non-interactive run** (`--json`, CI, an agent tool call, a pipe)
307
+ refuses with `wiring_collision` — nothing written, token still unused —
308
+ naming the superseded agent ids and the two flags that resolve it. The
309
+ refusal is written as a **relay instruction**: an agent running the
310
+ dashboard's command may append only `--json` (and `--runtime` after a
311
+ runtime refusal), so it must hand the choice to its user rather than add
312
+ a flag itself, and re-run only with the flag the user picks.
313
+ - **`--replace`** is the unattended answer "yes, replace". `--name <slug>`
314
+ installs alongside. Passing both is a usage error — they contradict.
315
+ - **Replacing re-points the bare pair, then retires the previous directory
316
+ locally.** Connect owns the `haven` and `haven-signer` entries (and the
317
+ managed Codex/Hermes equivalents) and re-points them at the new agent's
318
+ credentials; unrelated MCP servers and configuration are preserved. Once
319
+ the runtime install has actually completed, each superseded directory is
320
+ tombstoned and its local key files removed — the same teardown `--unwire`
321
+ performs — so `--doctor` reads it as `retired` rather than still
322
+ spend-capable. If the install ends with an error code the retirement is
323
+ **skipped**, because the old wiring may still be the only working one; the
324
+ outcome's `superseded_agents_retired_locally` says which happened and
325
+ `retired_agent_ids` names exactly the directories it reached. With
272
326
  `--name`, each agent owns its own suffixed pair and they coexist; see
273
327
  [Running several agents in one runtime](#running-several-agents-in-one-runtime).
274
- - **The previous agent is not revoked by a re-run.** Its credentials remain on
275
- disk and its authority remains whatever its on-chain rules say. Revoke
276
- agents you no longer use from the Haven dashboard, then delete their
277
- credential directories.
328
+ - **The previous agent is not revoked by a re-run — with or without
329
+ `--replace`.** Local retirement is local: its authority remains whatever
330
+ its on-chain rules and the Haven agent page say. Revoke agents you no
331
+ longer use from the Haven dashboard. Connect never calls revoke — that
332
+ route is owner-authenticated, and an agent credential revoking a sibling
333
+ agent would be an agent editing its own authority.
278
334
  - **A re-run never overwrites an existing credential file.** A write that would
279
335
  collide with an existing `identity.json`/`signer.json`/`agent.json` is
280
336
  refused outright (and a partially failed write rolls itself back), so a
@@ -401,10 +457,14 @@ Add `--json` for a machine-readable report. No secret material is ever
401
457
  printed.
402
458
 
403
459
  `--doctor` also probes every OTHER agent credential directory it did not
404
- select (#1688). A re-run of setup mints a NEW agent and retires nothing, so
405
- a directory from a previous setup can hold an API key that still
406
- authenticates — meaning any host that started before the re-run keeps
407
- spending as the agent you believe you replaced. A superseded directory
460
+ select (#1688). A re-run of setup mints a NEW agent and, unless it ran with
461
+ `--replace` (#2551), retires nothing, so a directory from a previous setup
462
+ can hold an API key that still authenticates — meaning any host that started
463
+ before the re-run keeps spending as the agent you believe you replaced. Since
464
+ #2551 a bare setup no longer reaches that state silently: it asks at a
465
+ terminal and refuses everywhere else, so a `superseded` directory now means
466
+ someone chose it — a `--replace` whose install failed, an older connector, or
467
+ a directory this scan could not classify. A superseded directory
408
468
  whose key is still live is a FAILING check naming the agent id, with the
409
469
  repair spelled out: revoke it on the Haven agent page, then remove the
410
470
  directory. An already-revoked one reports as informational; an unreachable
package/dist/cli.cjs CHANGED
@@ -535,9 +535,9 @@ var init_runtime_manifest = __esm({
535
535
  mcpPackage: "@haven_ai/mcp",
536
536
  mcpVersion: mcp.MCP_VERSION,
537
537
  sdkPackage: "@haven_ai/sdk",
538
- sdkVersion: "0.0.0-dev.202609040858.f4467bb",
538
+ sdkVersion: "0.0.0-dev.202609041540.2214a02",
539
539
  signerPackage: "@haven_ai/signer",
540
- signerVersion: "0.0.0-dev.202609040858.f4467bb",
540
+ signerVersion: "0.0.0-dev.202609041540.2214a02",
541
541
  // Sourced from the SDK, never a literal (#1161). This field read '20.0.0'
542
542
  // while every package's `engines` said `>=24` and the docs said `>=24.0.0`,
543
543
  // so the guard that was supposed to enforce the floor waved Node v23 through
@@ -2913,14 +2913,6 @@ var init_runtime_install = __esm({
2913
2913
  }
2914
2914
  });
2915
2915
 
2916
- // src/rekey-messages.ts
2917
- var REKEY_FINISH_NEEDS_API_KEY;
2918
- var init_rekey_messages = __esm({
2919
- "src/rekey-messages.ts"() {
2920
- REKEY_FINISH_NEEDS_API_KEY = "--rekey-finish needs --api-key <key> \u2014 the one the Haven agent page showed once.";
2921
- }
2922
- });
2923
-
2924
2916
  // src/tombstone.ts
2925
2917
  var tombstone_exports = {};
2926
2918
  __export(tombstone_exports, {
@@ -3038,6 +3030,9 @@ var init_tombstone = __esm({
3038
3030
  // src/unwire.ts
3039
3031
  var unwire_exports = {};
3040
3032
  __export(unwire_exports, {
3033
+ readIdentityFile: () => readIdentityFile,
3034
+ teardownLocalKeyMaterial: () => teardownLocalKeyMaterial,
3035
+ tombstoneDirectoryIfAbsent: () => tombstoneDirectoryIfAbsent,
3041
3036
  unwireAgent: () => unwireAgent
3042
3037
  });
3043
3038
  function identityAt(directory) {
@@ -3077,18 +3072,13 @@ async function unwireAgent(input) {
3077
3072
  const slug = input.slug ?? sidecar?.server_name;
3078
3073
  const names = serverNamesFor(slug);
3079
3074
  const runtimes = [];
3080
- let tombstoned = false;
3081
- const tombstonePath = path.join(input.directory, TOMBSTONE_FILENAME);
3082
- if (await readOptionalText(tombstonePath) === null) {
3083
- await writeAgentTombstone({
3084
- directory: input.directory,
3085
- agentId,
3086
- reason: input.reason ?? "unwired via --unwire",
3087
- replacedBy: input.replacedBy,
3088
- tombstonesDir: input.tombstonesDir
3089
- });
3090
- tombstoned = true;
3091
- }
3075
+ const tombstoned = await tombstoneDirectoryIfAbsent({
3076
+ directory: input.directory,
3077
+ agentId,
3078
+ reason: input.reason ?? "unwired via --unwire",
3079
+ replacedBy: input.replacedBy,
3080
+ tombstonesDir: input.tombstonesDir
3081
+ });
3092
3082
  for (const model of RUNTIMES) {
3093
3083
  const path = runtimeConfigPathFor(model.runtime, homeDir);
3094
3084
  if (path === null) continue;
@@ -3173,16 +3163,27 @@ async function unwireAgent(input) {
3173
3163
  }
3174
3164
  }
3175
3165
  }
3166
+ await teardownLocalKeyMaterial(input.directory, identity);
3167
+ return { directory: input.directory, agentId, slug, tombstoned, runtimes };
3168
+ }
3169
+ async function tombstoneDirectoryIfAbsent(input) {
3170
+ if (await readOptionalText(path.join(input.directory, TOMBSTONE_FILENAME)) !== null) return false;
3171
+ await writeAgentTombstone(input);
3172
+ return true;
3173
+ }
3174
+ async function teardownLocalKeyMaterial(directory, identity) {
3176
3175
  await Promise.all([
3177
- promises.rm(path.join(input.directory, "signer.json"), { force: true }),
3178
- promises.rm(path.join(input.directory, REKEY_PENDING_FILENAME), { force: true })
3176
+ promises.rm(path.join(directory, "signer.json"), { force: true }),
3177
+ promises.rm(path.join(directory, REKEY_PENDING_FILENAME), { force: true })
3179
3178
  ]);
3180
3179
  if (identity && identity.api_key !== void 0) {
3181
3180
  const { api_key: _dropped, ...rest } = identity;
3182
- await promises.writeFile(path.join(input.directory, "identity.json"), `${JSON.stringify(rest, null, 2)}
3181
+ await promises.writeFile(path.join(directory, "identity.json"), `${JSON.stringify(rest, null, 2)}
3183
3182
  `, { mode: 384 });
3184
3183
  }
3185
- return { directory: input.directory, agentId, slug, tombstoned, runtimes };
3184
+ }
3185
+ async function readIdentityFile(directory) {
3186
+ return identityAt(directory);
3186
3187
  }
3187
3188
  var RUNTIMES;
3188
3189
  var init_unwire = __esm({
@@ -3203,6 +3204,14 @@ var init_unwire = __esm({
3203
3204
  }
3204
3205
  });
3205
3206
 
3207
+ // src/rekey-messages.ts
3208
+ var REKEY_FINISH_NEEDS_API_KEY;
3209
+ var init_rekey_messages = __esm({
3210
+ "src/rekey-messages.ts"() {
3211
+ REKEY_FINISH_NEEDS_API_KEY = "--rekey-finish needs --api-key <key> \u2014 the one the Haven agent page showed once.";
3212
+ }
3213
+ });
3214
+
3206
3215
  // src/rekey.ts
3207
3216
  var rekey_exports = {};
3208
3217
  __export(rekey_exports, {
@@ -4267,10 +4276,119 @@ function defaultPromptIo() {
4267
4276
  };
4268
4277
  }
4269
4278
 
4279
+ // src/wiring-collision.ts
4280
+ init_server_names();
4281
+ init_signer_runtime();
4282
+ init_storage();
4283
+ init_tombstone();
4284
+ async function detectWiringCollision(input) {
4285
+ const root = defaultCredentialRoot(input.credentialsDir);
4286
+ let entries = [];
4287
+ try {
4288
+ entries = await promises.readdir(root);
4289
+ } catch {
4290
+ return null;
4291
+ }
4292
+ const superseded = [];
4293
+ const taken = /* @__PURE__ */ new Set();
4294
+ for (const entry of entries) {
4295
+ const directory = path.join(root, entry);
4296
+ let identityRaw;
4297
+ try {
4298
+ identityRaw = await promises.readFile(path.join(directory, "identity.json"), "utf8");
4299
+ } catch {
4300
+ continue;
4301
+ }
4302
+ taken.add(entry);
4303
+ let identity;
4304
+ try {
4305
+ identity = JSON.parse(identityRaw);
4306
+ } catch {
4307
+ identity = void 0;
4308
+ }
4309
+ if (!identity?.api_key) continue;
4310
+ if (await pathExists2(path.join(directory, TOMBSTONE_FILENAME))) continue;
4311
+ const sidecar = await readRuntimeSidecar(directory);
4312
+ if (sidecar?.server_name) continue;
4313
+ superseded.push({ directory, agentId: identity.agent_id ?? entry });
4314
+ }
4315
+ if (superseded.length === 0) return null;
4316
+ return {
4317
+ superseded,
4318
+ suggestedServerName: proposeServerSlug(input.agentName, taken)
4319
+ };
4320
+ }
4321
+ function proposeServerSlug(agentName, taken) {
4322
+ let base = agentName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-").slice(0, 32).replace(/-+$/g, "");
4323
+ if (!slugIsValid(base)) base = "agent";
4324
+ if (!taken.has(base)) return base;
4325
+ for (let n = 2; n < 1e3; n += 1) {
4326
+ const suffix = `-${n}`;
4327
+ const candidate = `${base.slice(0, 32 - suffix.length).replace(/-+$/g, "")}${suffix}`;
4328
+ if (slugIsValid(candidate) && !taken.has(candidate)) return candidate;
4329
+ }
4330
+ throw new Error(`Could not propose an unused server name for ${JSON.stringify(agentName)}.`);
4331
+ }
4332
+ function slugIsValid(slug) {
4333
+ try {
4334
+ assertValidServerSlug(slug);
4335
+ return true;
4336
+ } catch {
4337
+ return false;
4338
+ }
4339
+ }
4340
+ var MAX_PROMPT_ATTEMPTS2 = 3;
4341
+ async function promptWiringCollisionResolution(collision, agentName, io) {
4342
+ const ids = collision.superseded.map((entry) => entry.agentId).join(", ");
4343
+ io.write(`This machine is already wired to a Haven agent with a live key: ${ids}.
4344
+ `);
4345
+ io.write(`Setting up "${agentName}" on the bare haven / haven-signer pair would replace that wiring.
4346
+ `);
4347
+ io.write(" r) replace \u2014 re-point haven / haven-signer at the new agent and retire the previous directory locally\n");
4348
+ io.write(" (tombstoned, local key files removed; you still revoke it on the Haven agent page)\n");
4349
+ io.write(` a) alongside \u2014 install as a named agent (suggested: ${collision.suggestedServerName}) with its own
4350
+ `);
4351
+ io.write(` haven-<name> / haven-signer-<name> pair, leaving the current wiring untouched
4352
+ `);
4353
+ io.write(" q) quit \u2014 nothing is written and the setup token stays unused\n");
4354
+ for (let attempt = 0; attempt < MAX_PROMPT_ATTEMPTS2; attempt += 1) {
4355
+ const answer = await io.question("Replace, install alongside, or quit? [r/a/q]: ");
4356
+ if (answer === null) return { action: "abort" };
4357
+ const trimmed = answer.trim().toLowerCase();
4358
+ if (trimmed === "r" || trimmed === "replace") return { action: "replace" };
4359
+ if (trimmed === "q" || trimmed === "quit") return { action: "abort" };
4360
+ if (trimmed === "a" || trimmed === "alongside") {
4361
+ const typed = await io.question(`Server name [${collision.suggestedServerName}]: `);
4362
+ if (typed === null) return { action: "abort" };
4363
+ const serverName = typed.trim() === "" ? collision.suggestedServerName : typed.trim();
4364
+ try {
4365
+ assertValidServerSlug(serverName);
4366
+ } catch (err) {
4367
+ io.write(`${err instanceof Error ? err.message : String(err)}
4368
+ `);
4369
+ continue;
4370
+ }
4371
+ return { action: "alongside", serverName };
4372
+ }
4373
+ io.write(`"${trimmed}" is not one of r, a, q.
4374
+ `);
4375
+ }
4376
+ return { action: "abort" };
4377
+ }
4378
+ async function pathExists2(path) {
4379
+ try {
4380
+ await promises.stat(path);
4381
+ return true;
4382
+ } catch {
4383
+ return false;
4384
+ }
4385
+ }
4386
+
4270
4387
  // src/runtime.ts
4388
+ init_unwire();
4271
4389
  init_local_mcp_runtime();
4272
4390
  init_runtime_manifest();
4273
- var CONNECTOR_VERSION = "0.0.0-dev.202609040858.f4467bb";
4391
+ var CONNECTOR_VERSION = "0.0.0-dev.202609041540.2214a02";
4274
4392
  var CONNECT_OUTCOME_SCHEMA_VERSION = 1;
4275
4393
  var failureOutcomesByError = /* @__PURE__ */ new WeakMap();
4276
4394
  function failureOutcomeFor(runtimeHint, error) {
@@ -4375,6 +4493,28 @@ async function executeConnect(options, deps, trace) {
4375
4493
  await assertServerSlugAvailable(options.serverName, options.credentialsDir);
4376
4494
  }
4377
4495
  log("Checked local credential storage \u2014 all clear.");
4496
+ let serverName = options.serverName;
4497
+ let replacing;
4498
+ if (!serverName) {
4499
+ const collision = await detectWiringCollision({
4500
+ credentialsDir: options.credentialsDir,
4501
+ agentName: setup.agent.name
4502
+ });
4503
+ if (collision) {
4504
+ const resolution = await resolveWiringCollision(collision, setup.agent.name, options, deps);
4505
+ if (resolution.action === "replace") {
4506
+ replacing = collision;
4507
+ log(
4508
+ `Replacing the existing haven / haven-signer wiring (previous agent(s): ${supersededIds(collision)}). The previous directory is retired locally once the new wiring is written; nothing is revoked.`
4509
+ );
4510
+ } else {
4511
+ serverName = resolution.serverName;
4512
+ assertValidServerSlug(serverName);
4513
+ await assertServerSlugAvailable(serverName, options.credentialsDir);
4514
+ log(`Installing alongside the existing wiring as a named agent: ${serverNamesFor(serverName).hosted} / ${serverNamesFor(serverName).signer}.`);
4515
+ }
4516
+ }
4517
+ }
4378
4518
  const localKey = generateKey();
4379
4519
  const localApiKey = generateLocalApiKey();
4380
4520
  log("Minting a fresh signing key and API key \u2014 both stay on this machine.");
@@ -4395,7 +4535,7 @@ async function executeConnect(options, deps, trace) {
4395
4535
  // the dashboard can name it. Derived here rather than sent as the raw
4396
4536
  // slug — `serverNamesFor` is the one place the naming rule lives, and
4397
4537
  // the hosted name is what a user pastes into an MCP config.
4398
- mcpServerName: serverNamesFor(options.serverName).hosted,
4538
+ mcpServerName: serverNamesFor(serverName).hosted,
4399
4539
  connectorContext: {
4400
4540
  environment_label: options.environmentLabel ?? "Local workspace",
4401
4541
  config_target: installCapabilities.canWriteRuntimeConfig ? "agent runtime MCP config" : "local credential files"
@@ -4417,7 +4557,7 @@ async function executeConnect(options, deps, trace) {
4417
4557
  const credentialPaths = await writeCredentials({
4418
4558
  baseDir: options.credentialsDir,
4419
4559
  agentId: registration.agent_id,
4420
- serverName: options.serverName,
4560
+ serverName,
4421
4561
  apiKey: localApiKey,
4422
4562
  delegateKey: localKey.privateKey,
4423
4563
  delegateAddress: localKey.address,
@@ -4456,7 +4596,7 @@ async function executeConnect(options, deps, trace) {
4456
4596
  ackSigner: options.ackSigner,
4457
4597
  ackLocalTools: options.ackLocalTools,
4458
4598
  localMcp: options.localMcp,
4459
- serverName: options.serverName
4599
+ serverName
4460
4600
  }, {
4461
4601
  onProgress: log,
4462
4602
  // #1543: report "runtime configured" the moment the config write settles,
@@ -4495,10 +4635,43 @@ async function executeConnect(options, deps, trace) {
4495
4635
  } else {
4496
4636
  log("Haven setup on this machine is complete.");
4497
4637
  }
4638
+ let supersededAgentsRetiredLocally;
4639
+ const retiredAgentIds = [];
4640
+ if (replacing) {
4641
+ if (runtimeInstall.errorCode) {
4642
+ supersededAgentsRetiredLocally = false;
4643
+ log(
4644
+ `Previous agent(s) ${supersededIds(replacing)} were NOT retired: the runtime install did not complete, so their wiring may still be the only working one. Resolve the install, then retire them with ${RERUN_HINT} --unwire <dir>.`
4645
+ );
4646
+ } else {
4647
+ supersededAgentsRetiredLocally = true;
4648
+ for (const entry of replacing.superseded) {
4649
+ try {
4650
+ await tombstoneDirectoryIfAbsent({
4651
+ directory: entry.directory,
4652
+ agentId: entry.agentId,
4653
+ reason: "replaced by a new setup (--replace)",
4654
+ replacedBy: registration.agent_id
4655
+ });
4656
+ await teardownLocalKeyMaterial(entry.directory, await readIdentityFile(entry.directory));
4657
+ retiredAgentIds.push(entry.agentId);
4658
+ log(`Retired previous agent ${entry.agentId} locally: tombstoned, local key files removed.`);
4659
+ } catch (err) {
4660
+ supersededAgentsRetiredLocally = false;
4661
+ log(`Could not retire previous agent ${entry.agentId} locally: ${err instanceof Error ? err.message : String(err)}`);
4662
+ }
4663
+ }
4664
+ }
4665
+ }
4498
4666
  let supersededAgentIds = [];
4499
4667
  try {
4500
4668
  supersededAgentIds = await listOtherAgentIds(options.credentialsDir, credentialPaths.directory);
4501
- if (supersededAgentIds.length > 0) {
4669
+ if (supersededAgentsRetiredLocally === true) {
4670
+ log("");
4671
+ log(
4672
+ `Replaced: previous agent(s) ${supersededIds(replacing)} are retired on this machine but NOT revoked \u2014 revoke them on the Haven agent page, then restart EVERY long-lived host (gateways, TUI workers, editors): each holds the MCP wiring snapshot from its own start time, and the tombstone speaks only when a stale host next probes the old path.`
4673
+ );
4674
+ } else if (supersededAgentIds.length > 0) {
4502
4675
  log("");
4503
4676
  log(
4504
4677
  `Heads-up: this setup created a NEW agent. Your previous agent(s) \u2014 ${supersededAgentIds.join(", ")} \u2014 still exist with their own keys, and any host that was already running keeps acting as them.`
@@ -4542,6 +4715,8 @@ async function executeConnect(options, deps, trace) {
4542
4715
  delegateAddress: registration.delegate_address,
4543
4716
  hostedMcpUrl: registration.hosted_mcp_url,
4544
4717
  supersededAgentIds,
4718
+ supersededAgentsRetiredLocally,
4719
+ ...replacing ? { retiredAgentIds } : {},
4545
4720
  setupChallengeExpiresAt: setup.challenge.expires_at,
4546
4721
  approvalRequired: registration.agent_status === "pending_approval"
4547
4722
  });
@@ -4594,6 +4769,8 @@ function completionOutcome(input) {
4594
4769
  // agents" is a fact a caller needs, and an omitted key would be
4595
4770
  // indistinguishable from an older connector that never reported it.
4596
4771
  superseded_agent_ids: input.supersededAgentIds ?? [],
4772
+ ...input.supersededAgentsRetiredLocally !== void 0 ? { superseded_agents_retired_locally: input.supersededAgentsRetiredLocally } : {},
4773
+ ...input.retiredAgentIds ? { retired_agent_ids: input.retiredAgentIds } : {},
4597
4774
  ...input.setupChallengeExpiresAt ? { setup_challenge_expires_at: input.setupChallengeExpiresAt } : {},
4598
4775
  ...runtimeInstall.errorCode ? { error: { code: runtimeInstall.errorCode, next_action: nextAction2 } } : {}
4599
4776
  };
@@ -4615,10 +4792,38 @@ function installedClientProse(hint) {
4615
4792
  return `Haven can see these agent clients installed here, likeliest first: ${found.join(", ")}.${suggestion} `;
4616
4793
  }
4617
4794
  function runtimeSelectionPrompt(options, deps) {
4618
- if (options.interactive !== true) return void 0;
4619
- if (!(deps.isTty ?? Boolean(process.stdin.isTTY))) return void 0;
4795
+ if (!interactivePromptAllowed(options, deps)) return void 0;
4620
4796
  return deps.promptRuntime ?? (() => resolveRuntimeByInstalledClientPrompt());
4621
4797
  }
4798
+ function interactivePromptAllowed(options, deps) {
4799
+ if (options.interactive !== true) return false;
4800
+ return deps.isTty ?? Boolean(process.stdin.isTTY);
4801
+ }
4802
+ function supersededIds(collision) {
4803
+ return collision.superseded.map((entry) => entry.agentId).join(", ");
4804
+ }
4805
+ async function resolveWiringCollision(collision, agentName, options, deps) {
4806
+ if (options.replaceExistingWiring) return { action: "replace" };
4807
+ if (interactivePromptAllowed(options, deps)) {
4808
+ const prompt = deps.promptWiringCollision ?? ((c, name) => promptWiringCollisionResolution(c, name, defaultPromptIo()));
4809
+ const resolution = await prompt(collision, agentName);
4810
+ if (resolution.action === "abort") {
4811
+ throw new ConnectError(
4812
+ "wiring_collision_declined",
4813
+ `Setup stopped at your request: this machine is already wired to ${supersededIds(collision)} and you chose neither to replace that wiring nor to install alongside it. Nothing was written: no agent was created, no credentials were stored, and the Haven setup token is still unused. Run the setup command again with --replace to re-point haven / haven-signer at the new agent, or with --name <slug> (e.g. --name ${collision.suggestedServerName}) to install alongside.`,
4814
+ "rerun_connect_with_replace_or_name",
4815
+ { supersededAgentIds: collision.superseded.map((e) => e.agentId), suggestedServerName: collision.suggestedServerName }
4816
+ );
4817
+ }
4818
+ return resolution;
4819
+ }
4820
+ throw new ConnectError(
4821
+ "wiring_collision",
4822
+ `This machine is already wired to a Haven agent with a live key (${supersededIds(collision)}), and setting up "${agentName}" on the bare haven / haven-signer pair would replace that wiring. Nothing was written and the Haven setup token is still unused. If you are an AI agent running this command: do NOT add a flag yourself \u2014 relay this to your user and stop. Your user decides: REPLACE the existing wiring (re-run the same command with --replace added, which re-points the pair at the new agent and retires the previous directory locally \u2014 they still revoke the old agent on the Haven agent page), or install ALONGSIDE it (re-run with --name <slug> added, e.g. --name ${collision.suggestedServerName}, which gives the new agent its own haven-<slug> / haven-signer-<slug> pair). Re-run only with the flag your user chooses.`,
4823
+ "relay_wiring_collision_to_user",
4824
+ { supersededAgentIds: collision.superseded.map((e) => e.agentId), suggestedServerName: collision.suggestedServerName }
4825
+ );
4826
+ }
4622
4827
  function failedConnectOutcome(runtimeHint, error) {
4623
4828
  const message = error instanceof Error ? error.message : "";
4624
4829
  const code = error instanceof ConnectError ? error.code : /Node\.js >=/i.test(message) ? "unsupported_node_version" : /setup challenge.*expired|expired or invalid/i.test(message) ? "setup_challenge_expired_or_invalid" : /only available for Claude Code and Codex/i.test(message) ? "local_mcp_unsupported_runtime" : "connect_failed";
@@ -4649,7 +4854,9 @@ function failedConnectOutcome(runtimeHint, error) {
4649
4854
  ...error instanceof ConnectError && message ? { message: redactForAutomation(message) } : {},
4650
4855
  ...error instanceof ConnectError && error.details.allowedRuntimes ? { allowed_runtimes: error.details.allowedRuntimes } : {},
4651
4856
  ...error instanceof ConnectError && error.details.installedClients?.length ? { installed_clients: error.details.installedClients } : {},
4652
- ...error instanceof ConnectError && error.details.suggestedRuntime ? { suggested_runtime: error.details.suggestedRuntime } : {}
4857
+ ...error instanceof ConnectError && error.details.suggestedRuntime ? { suggested_runtime: error.details.suggestedRuntime } : {},
4858
+ ...error instanceof ConnectError && error.details.supersededAgentIds ? { superseded_agent_ids: error.details.supersededAgentIds } : {},
4859
+ ...error instanceof ConnectError && error.details.suggestedServerName ? { suggested_name: error.details.suggestedServerName } : {}
4653
4860
  }
4654
4861
  };
4655
4862
  }
@@ -4886,6 +5093,7 @@ function parseArgs(argv, env = process.env) {
4886
5093
  let tombstoneReplacedBy;
4887
5094
  let unwire;
4888
5095
  let unwireDir;
5096
+ let replace = false;
4889
5097
  for (let i = 0; i < argv.length; i += 1) {
4890
5098
  const arg = argv[i];
4891
5099
  if (arg === "--help" || arg === "-h") {
@@ -4925,6 +5133,8 @@ function parseArgs(argv, env = process.env) {
4925
5133
  options.runtimeForce = requireValue(argv, ++i, arg);
4926
5134
  } else if (arg === "--credentials-dir") {
4927
5135
  options.credentialsDir = requireValue(argv, ++i, arg);
5136
+ } else if (arg === "--replace") {
5137
+ replace = true;
4928
5138
  } else if (arg === "--name") {
4929
5139
  options.serverName = requireValue(argv, ++i, arg);
4930
5140
  assertValidServerSlug(options.serverName);
@@ -4950,6 +5160,17 @@ function parseArgs(argv, env = process.env) {
4950
5160
  if (help) {
4951
5161
  return { options, help, json, doctor, repair, tombstone, rekey };
4952
5162
  }
5163
+ if (replace) {
5164
+ if (rekeyPhase || tombstoneDir || unwire || doctor || repair) {
5165
+ throw new Error("--replace belongs to a --setup run: it says what to do when the bare haven / haven-signer pair is already wired to another agent.");
5166
+ }
5167
+ if (options.serverName) {
5168
+ throw new Error(
5169
+ "--replace and --name contradict each other: --replace overwrites the bare haven / haven-signer wiring, --name installs alongside it under its own pair. Pass one of them."
5170
+ );
5171
+ }
5172
+ options.replaceExistingWiring = true;
5173
+ }
4953
5174
  if (rekey) {
4954
5175
  if (options.setupToken) {
4955
5176
  throw new Error("--rekey replaces an existing agent's key; it does not take --setup. Drop one of them.");
@@ -5028,6 +5249,12 @@ function helpText() {
5028
5249
  " --runtime-force <name> Escape hatch: use exactly this runtime, ignoring environment detection.",
5029
5250
  " --credentials-dir <path> Credential directory fallback. Defaults to ~/.haven/agents.",
5030
5251
  " --environment-label <text> Non-sensitive label shown in Haven setup review.",
5252
+ " --replace When this machine is already wired to a different Haven agent on the bare",
5253
+ " haven / haven-signer pair, re-point that pair at the new agent and retire the",
5254
+ " previous agent directory locally (tombstoned, local key files removed).",
5255
+ " Nothing is revoked \u2014 revoke the old agent on the Haven agent page. Without",
5256
+ " this flag a non-interactive run REFUSES such a collision (wiring_collision)",
5257
+ " and an interactive terminal is asked; --name installs alongside instead.",
5031
5258
  " --name <slug> Wiring slug for a NAMED agent: writes haven-<slug> / haven-signer-<slug>",
5032
5259
  " MCP entries and stores credentials at ~/.haven/agents/<slug>/, so several",
5033
5260
  " agents can run side by side in one runtime. 1-32 lowercase letters, digits,",
@@ -5131,13 +5358,13 @@ async function runCli(argv, io = {
5131
5358
  }
5132
5359
  if (parsed.tombstone) {
5133
5360
  const { writeAgentTombstone: writeAgentTombstone2 } = await Promise.resolve().then(() => (init_tombstone(), tombstone_exports));
5134
- const { readFile: readFile13 } = await import('fs/promises');
5135
- const { join: join11 } = await import('path');
5361
+ const { readFile: readFile14 } = await import('fs/promises');
5362
+ const { join: join12 } = await import('path');
5136
5363
  try {
5137
5364
  let agentId = "unknown";
5138
5365
  try {
5139
5366
  const identity = JSON.parse(
5140
- await readFile13(join11(parsed.tombstone.directory, "identity.json"), "utf8")
5367
+ await readFile14(join12(parsed.tombstone.directory, "identity.json"), "utf8")
5141
5368
  );
5142
5369
  agentId = identity.agent_id ?? "unknown";
5143
5370
  } catch {
@@ -5176,10 +5403,10 @@ async function runCli(argv, io = {
5176
5403
  if (parsed.unwire) {
5177
5404
  const { unwireAgent: unwireAgent2 } = await Promise.resolve().then(() => (init_unwire(), unwire_exports));
5178
5405
  const { homedir: homedir10 } = await import('os');
5179
- const { join: join11 } = await import('path');
5406
+ const { join: join12 } = await import('path');
5180
5407
  const homeDir = homedir10();
5181
- const root = parsed.options.credentialsDir ?? join11(homeDir, ".haven", "agents");
5182
- const directory = parsed.unwireDir ?? (parsed.options.serverName ? join11(root, parsed.options.serverName) : root);
5408
+ const root = parsed.options.credentialsDir ?? join12(homeDir, ".haven", "agents");
5409
+ const directory = parsed.unwireDir ?? (parsed.options.serverName ? join12(root, parsed.options.serverName) : root);
5183
5410
  try {
5184
5411
  const result = await unwireAgent2({
5185
5412
  directory,