@haywoodmarx/agent-pager 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +4 -4
  2. package/dist/src/broker/address.d.ts +7 -0
  3. package/dist/src/broker/address.js +9 -0
  4. package/dist/src/broker/broker.d.ts +91 -23
  5. package/dist/src/broker/broker.js +505 -123
  6. package/dist/src/broker/operator-projection.js +3 -5
  7. package/dist/src/broker/protocol.d.ts +1 -1
  8. package/dist/src/broker/protocol.js +1 -1
  9. package/dist/src/broker/schema.d.ts +4 -0
  10. package/dist/src/broker/schema.js +9 -6
  11. package/dist/src/broker/snapshot.d.ts +4 -2
  12. package/dist/src/implementations/claude/gateway-cli.js +1 -2
  13. package/dist/src/implementations/claude/gateway.d.ts +11 -4
  14. package/dist/src/implementations/claude/gateway.js +44 -14
  15. package/dist/src/implementations/claude/instructions.js +1 -1
  16. package/dist/src/implementations/model.d.ts +6 -3
  17. package/dist/src/implementations/opencode/plugin.d.ts +7 -2
  18. package/dist/src/implementations/opencode/plugin.js +103 -7
  19. package/dist/src/implementations/pi/broker-client.d.ts +13 -4
  20. package/dist/src/implementations/pi/broker-client.js +71 -9
  21. package/dist/src/implementations/pi/extension.d.ts +7 -2
  22. package/dist/src/implementations/pi/extension.js +15 -3
  23. package/dist/src/index.d.ts +1 -1
  24. package/dist/src/index.js +1 -1
  25. package/dist/src/launch/broker-lock.d.ts +58 -4
  26. package/dist/src/launch/broker-lock.js +140 -7
  27. package/dist/src/launch/launch.d.ts +1 -1
  28. package/dist/src/launch/on-demand.d.ts +8 -8
  29. package/dist/src/launch/on-demand.js +54 -35
  30. package/dist/src/launch/runtime-composition.js +2 -0
  31. package/dist/src/launch/runtime.js +4 -14
  32. package/dist/src/launch/service.d.ts +1 -0
  33. package/dist/src/launch/service.js +23 -1
  34. package/dist/src/launch/workspace-branch.d.ts +3 -3
  35. package/dist/src/launch/workspace-branch.js +7 -15
  36. package/dist/src/launch/workspace-root.d.ts +13 -0
  37. package/dist/src/launch/workspace-root.js +27 -0
  38. package/dist/src/messaging/delivery-client.d.ts +17 -0
  39. package/dist/src/messaging/delivery-client.js +31 -1
  40. package/dist/src/messaging/identity-statement.d.ts +7 -0
  41. package/dist/src/messaging/identity-statement.js +8 -0
  42. package/dist/src/messaging/mcp.d.ts +8 -2
  43. package/dist/src/messaging/mcp.js +54 -1
  44. package/dist/src/messaging/notifications.d.ts +31 -3
  45. package/dist/src/messaging/notifications.js +25 -0
  46. package/dist/src/messaging/push-delivery.d.ts +2 -1
  47. package/dist/src/messaging/push-delivery.js +24 -7
  48. package/dist/src/messaging/tools.js +22 -2
  49. package/dist/src/messaging/wake.js +7 -0
  50. package/dist/src/operator/agent-projection.d.ts +8 -5
  51. package/dist/src/operator/agent-projection.js +29 -9
  52. package/dist/src/operator/protocol.d.ts +2 -2
  53. package/dist/src/operator/repository-system.d.ts +29 -0
  54. package/dist/src/operator/repository-system.js +39 -12
  55. package/dist/src/operator/repository.d.ts +28 -11
  56. package/dist/src/operator/repository.js +37 -4
  57. package/dist/src/shared/versions.d.ts +18 -0
  58. package/dist/src/shared/versions.js +26 -0
  59. package/dist/src/tui/agent-tree.js +2 -2
  60. package/dist/src/version.d.ts +7 -0
  61. package/dist/src/version.js +14 -6
  62. package/package.json +1 -1
  63. package/skills/pager/SKILL.md +35 -45
@@ -1,11 +1,11 @@
1
1
  import { createHash } from "node:crypto";
2
- import { resolve } from "node:path";
2
+ import { isAbsolute, resolve } from "node:path";
3
3
  import { openBrokerDatabase, } from "./database.js";
4
4
  import { decodeMessageBody, encodeMessageBody, issueOpaqueToken, assertAgentId, FETCH_LIMIT_DEFAULT, isAgentId, isDirectAgentAddress, mintAgentId, normalizeMessageSubject, normalizeMessageType, ProtocolError, validateImplementationId, validateSessionKey, } from "./protocol.js";
5
5
  import { PAGER_DEFAULTS } from "../configuration-defaults.js";
6
6
  import { publicEpoch } from "./snapshot.js";
7
- import { normalizeAlias, parseAgentAddress, parseWorkspaceName, uniquePathNames, validateRegistrationSessionName, workspaceIdentity, workspaceTeamId, } from "./address.js";
8
- import { ATTACHMENT_CHANNELS, BrokerSchema, MESSAGE_STATES } from "./schema.js";
7
+ import { formatAgentAddress, normalizeAlias, parseAgentAddress, parseWorkspaceName, uniquePathNames, validateRegistrationSessionName, workspaceIdentity, workspaceTeamId, } from "./address.js";
8
+ import { ATTACHMENT_CHANNELS, BrokerSchema, MESSAGE_STATES, } from "./schema.js";
9
9
  import { assertDurableOperatorId, readStoredAgentId, readStoredEnum, BrokerOperatorProjection, } from "./operator-projection.js";
10
10
  const notificationDisplay = {
11
11
  delivered: { text: "notify delivered", tone: "ok" },
@@ -104,6 +104,7 @@ export class Broker {
104
104
  #epochOwner;
105
105
  #lifecycleSynchronization;
106
106
  #workspaceBranch;
107
+ #workspaceRoot;
107
108
  #report;
108
109
  #projection;
109
110
  #commitListeners = new Set();
@@ -113,12 +114,13 @@ export class Broker {
113
114
  /**
114
115
  * Opens a durable broker database with the supplied timing and routing policies.
115
116
  * @param path - SQLite database location, or memory sentinel, for durable state.
116
- * @param options - Clock, workspace-branch lookup, and diagnostics sink are required; see {@link BrokerOptions} for its optional fields.
117
+ * @param options - Clock, workspace-branch and workspace-root lookups, and diagnostics sink are required; see {@link BrokerOptions} for its optional fields.
117
118
  */
118
119
  constructor(path, options) {
119
120
  this.#db = (options.openDatabase ?? openBrokerDatabase)(path);
120
121
  this.#now = options.now;
121
122
  this.#workspaceBranch = options.workspaceBranch;
123
+ this.#workspaceRoot = options.workspaceRoot;
122
124
  this.#report = options.report;
123
125
  this.#visibilityTimeoutMs = options.visibilityTimeoutMs ?? PAGER_DEFAULTS.visibilityTimeoutMs;
124
126
  this.#redeliveryBaseDelayMs = options.redeliveryBaseDelayMs ?? PAGER_DEFAULTS.redeliveryBaseDelayMs;
@@ -196,34 +198,34 @@ export class Broker {
196
198
  }
197
199
  /**
198
200
  * Register a concrete agent under one workspace-unique session name.
199
- * @param args - Caller-supplied identity, topology, and implementation values.
201
+ * @param args - Caller-supplied identity, topology, and implementation values. `workspacePath` is
202
+ * the directory the agent works in, which registers it in the workspace that directory belongs to.
200
203
  * @returns - Stable agent identity registered in the requested team.
201
204
  */
202
205
  registerAgent(args) {
203
- const workspacePath = resolve(args.workspacePath);
204
206
  const identity = this.#registrationIdentity(args.agentId, args.sessionName, args.implementation);
205
- // Validated before the port runs, so a rejected registration never pays for a git subprocess.
206
- const branch = this.#currentWorkspaceBranch(workspacePath);
207
- return this.#transaction(() => this.#registerAgent(args, identity, branch));
207
+ // Validated before the ports run, so a rejected registration never pays for a git subprocess.
208
+ const location = this.#locate(resolve(args.workspacePath));
209
+ return this.#transaction(() => this.#registerAgent(args, identity, location));
208
210
  }
209
211
  /**
210
212
  * Register or rejoin an implementation identity without a prior launch reservation.
211
213
  *
212
- * A `sessionKey` makes the identity resumable: the same harness conversation returning after a
213
- * restart resolves to the identity it already owns, keeping its alias, inbox, and traffic, whatever
214
- * the caller passes as `sessionName`. Without one, rejoining still falls back to matching the alias.
214
+ * A `sessionKey` makes the identity resumable: the same harness conversation returning to the same
215
+ * working directory after a restart resolves to the identity it already owns, keeping its alias,
216
+ * inbox, and traffic, whatever the caller passes as `sessionName`. Without one, rejoining still
217
+ * falls back to matching the alias.
215
218
  *
216
219
  * A `continuesSessionKey` is the key a conversation was known by before it continued under
217
220
  * `sessionKey`. When `sessionKey` owns no identity yet, the identity the continued key owns moves
218
221
  * to `sessionKey`, its holder displaced, so the alias, inbox, and traffic follow the conversation.
219
- * @param args - Validated workspace, implementation, optional alias, optional resumable key, and
220
- * the key of the conversation that one continues, which requires the resumable key.
222
+ * @param args - Directory the agent works in, which registers it in the workspace that directory
223
+ * belongs to; implementation; optional alias; optional resumable key; and the key of the
224
+ * conversation that one continues, which requires the resumable key.
221
225
  * @returns - Stable agent identity, its current alias, and a fresh single-use attachment reservation.
222
226
  */
223
227
  registerAgentWithoutReservation(args) {
224
- const workspacePath = resolve(args.workspacePath);
225
- const workspaceId = workspaceIdentity(workspacePath);
226
- const teamId = workspaceTeamId(workspacePath);
228
+ const workingDirectory = resolve(args.workspacePath);
227
229
  const ephemeral = args.sessionName === undefined;
228
230
  validateImplementationId(args.implementation.id);
229
231
  const sessionKey = args.sessionKey === undefined ? undefined : validateSessionKey(args.sessionKey);
@@ -231,32 +233,38 @@ export class Broker {
231
233
  if (continuesSessionKey !== undefined && sessionKey === undefined) {
232
234
  throw new ProtocolError("validation_failed", "A continued session key requires the conversation's own session key");
233
235
  }
234
- // A cheap read, ahead of the branch lookup below and outside its transaction, so a session key
235
- // that will resume an existing identity can skip that lookup entirely: the resumable path below
236
- // never writes a workspace row, so a branch read for it would only pay for a discarded answer.
237
- // Re-read inside the transaction, since this and the branch lookup can both race a row that
238
- // starts or stops resolving between here and there; harmless either way, per #ensureWorkspaceAndTeam.
239
- const resumableAhead = sessionKey
240
- ? this.#agentForSessionKey(workspaceId, args.implementation.id, sessionKey)
236
+ const keys = { implementationId: args.implementation.id, sessionKey, continuesSessionKey };
237
+ // Read ahead of the workspace lookup, which a resume skips when it keeps the workspace it registered in.
238
+ // Re-read inside the transaction, where a registration that loses the race to resume takes the
239
+ // location this identity registered from.
240
+ const resumableAhead = this.#resumableIdentity(workingDirectory, keys);
241
+ const registered = resumableAhead && this.#registeredLocation(resumableAhead.agent.agentId);
242
+ const registeredAhead = resumableAhead && registered
243
+ ? { agentId: resumableAhead.agent.agentId, workspaceRoot: registered.workspaceRoot }
241
244
  : undefined;
242
- // Read before the transaction opens: the port's real implementation is a subprocess, and holding
243
- // SQLite's write lock across it stalls every other connection for as long as git takes.
244
- const branch = resumableAhead ? null : this.#currentWorkspaceBranch(workspacePath);
245
+ const location = registered
246
+ ? {
247
+ ...registered,
248
+ workspaceRoot: this.#confirmedWorkspaceRoot(registered),
249
+ branch: this.#workspaceBranch(workingDirectory),
250
+ }
251
+ : this.#locate(workingDirectory);
252
+ const workspaceId = workspaceIdentity(location.workspaceRoot);
253
+ const teamId = workspaceTeamId(location.workspaceRoot);
245
254
  return this.#transaction(() => {
246
- const owned = sessionKey ? this.#agentForSessionKey(workspaceId, args.implementation.id, sessionKey) : undefined;
247
- const continued = sessionKey && !owned && continuesSessionKey
248
- ? this.#agentForSessionKey(workspaceId, args.implementation.id, continuesSessionKey)
249
- : undefined;
250
- if (sessionKey && continued)
251
- this.#bindSessionKey(continued.agentId, sessionKey, false);
252
- const resumable = owned ?? continued;
255
+ const resumable = this.#resumableIdentity(workingDirectory, keys);
253
256
  if (resumable) {
254
- this.#displaceHolder(resumable.agentId);
257
+ const { agent } = resumable;
258
+ if (sessionKey && resumable.byContinuedKey)
259
+ this.#bindSessionKey(agent.agentId, sessionKey, false);
260
+ this.#displaceHolder(agent.agentId);
261
+ this.#recordBranch(agent.agentId, location.branch);
262
+ this.#followWorkspace(agent, this.#workspaceRootToFollow(agent.agentId, registeredAhead, location));
255
263
  return {
256
- agentId: resumable.agentId,
257
- reservation: this.#reserveAttachment(resumable.agentId, "messaging"),
258
- sessionName: resumable.sessionName,
259
- ephemeral: resumable.ephemeral,
264
+ agentId: agent.agentId,
265
+ reservation: this.#reserveAttachment(agent.agentId, "messaging"),
266
+ sessionName: agent.sessionName,
267
+ ephemeral: agent.ephemeral,
260
268
  resumed: true,
261
269
  };
262
270
  }
@@ -265,13 +273,12 @@ export class Broker {
265
273
  // request over a name it will not be given would cost it that identity, its inbox and its traffic.
266
274
  const sessionName = validateRegistrationSessionName(args.sessionName ?? this.#nextImplementationAlias(workspaceId, args.implementation.id));
267
275
  const agentId = this.#registerAgentWithoutReservation({
268
- workspacePath,
276
+ location,
269
277
  workspaceId,
270
278
  teamId,
271
279
  sessionName,
272
280
  ephemeral,
273
281
  implementation: args.implementation,
274
- branch,
275
282
  });
276
283
  if (sessionKey)
277
284
  this.#bindSessionKey(agentId, sessionKey, true);
@@ -291,66 +298,272 @@ export class Broker {
291
298
  * @returns - Current identity and alias history after the replacement.
292
299
  */
293
300
  updateAgentAlias(agentId, requestedAlias) {
294
- const sessionName = validateRegistrationSessionName(requestedAlias.replace(/^@/u, ""), "agent alias");
301
+ return this.relocateAgent(agentId, { alias: requestedAlias }).identity;
302
+ }
303
+ /**
304
+ * Move an agent to another workspace, rename it, or both, in one step.
305
+ *
306
+ * The alias must be free in the target workspace: held by nobody else and reserved as nobody
307
+ * else's retired name. Without `force` a taken alias fails with `alias_conflict` naming its holder
308
+ * and changes nothing. With `force` the caller takes the holder's identity over: its id, alias,
309
+ * inbox and alias history become the caller's, and the identity joins the target workspace, which
310
+ * is the caller's own when the request names none; the caller's own connections and
311
+ * conversation key move onto it, and the caller's previous identity is retired with its messages
312
+ * moved across and its aliases reserved as redirects to the claimed identity. The holder's own
313
+ * sessions and unconsumed attach reservations end, so a session or resume token it presents is
314
+ * refused as expired, and a claim token it held acknowledges nothing.
315
+ *
316
+ * A move keeps every alias the agent held reserved in the workspace it leaves, redirecting to its
317
+ * current address, and carries its alias history into the workspace it joins.
318
+ * @param agentId - Authenticated identity asking to move or rename.
319
+ * @param request - Target workspace, alias, and whether to take the alias over from its holder.
320
+ * @returns - The identity the caller now acts as, and the takeover when one was performed.
321
+ */
322
+ relocateAgent(agentId, request) {
323
+ const sessionName = request.alias === undefined
324
+ ? undefined
325
+ : validateRegistrationSessionName(request.alias.replace(/^@/u, ""), "agent alias");
326
+ const names = this.workspaceNames();
327
+ // Resolved before the write transaction opens: a directory lookup may block on a subprocess.
328
+ const requestedRoot = request.workspace === undefined ? undefined : this.#requestedWorkspaceRoot(request.workspace, names);
295
329
  return this.#transaction(() => {
296
330
  const agent = this.#activeAgent(agentId);
297
331
  this.#lifecycleSynchronization?.checkpoint("agent_alias_active");
298
- const workspaceId = String(agent.workspace_id);
299
- this.#assertNameAvailable(workspaceId, sessionName, agentId);
300
- const currentAlias = normalizeAlias(String(agent.registration_name));
301
- const nextAlias = normalizeAlias(sessionName);
302
- if (currentAlias !== nextAlias) {
303
- this.#write("insert or ignore into alias_history(workspace_id, alias, agent_id) values (?, ?, ?)", workspaceId, currentAlias, agentId);
304
- this.#write("delete from alias_history where workspace_id = ? and alias = ? and agent_id = ?", workspaceId, nextAlias, agentId);
305
- }
306
- this.#write("update agents set registration_name = ?, registration_ephemeral = 0 where id = ?", sessionName, agentId);
332
+ const targetName = sessionName ?? String(agent.registration_name);
333
+ const workspaceRoot = requestedRoot ?? this.#workspacePath(String(agent.workspace_id));
334
+ const workspaceId = workspaceIdentity(workspaceRoot);
335
+ const holder = this.#aliasHolder(workspaceId, targetName, agentId);
336
+ if (holder && request.force !== true)
337
+ throw this.#aliasHeldError(targetName, holder, names);
338
+ const claim = holder ? this.#claimIdentity(agentId, holder.agentId, names) : null;
339
+ const actingId = claim ? claim.agentId : agentId;
340
+ this.#moveToWorkspace(actingId, workspaceRoot, targetName);
341
+ this.#renameAgent(actingId, workspaceId, targetName);
307
342
  this.#lifecycleSynchronization?.checkpoint("agent_alias_updated");
308
- const previous = this.#db
309
- .prepare("select alias from alias_history where workspace_id = ? and agent_id = ? order by id")
310
- .all(workspaceId, agentId);
311
- return {
312
- id: agentId,
313
- alias: normalizeAlias(sessionName),
314
- previousAliases: previous.map((row) => String(row.alias)),
315
- };
343
+ // A trigger on the rename can reap the agent within the transaction, so its row is not read back.
344
+ // Joining a workspace no agent had registered in can change the names of the others.
345
+ const namesNow = requestedRoot === undefined ? names : this.workspaceNames();
346
+ return { identity: this.#identity(actingId, workspaceId, targetName, namesNow), claim };
316
347
  });
317
348
  }
318
349
  /**
319
- * Return the authenticated identity, current alias, and prior routable names.
350
+ * Resolve the workspace a relocation names to the path that identifies it.
351
+ *
352
+ * A name a listing reports wins over a directory of the same spelling; the two agree whenever
353
+ * both apply, since a name spanning a whole path is that path.
354
+ * @param requested - Workspace name, or absolute directory.
355
+ * @param names - Workspace names as {@link Broker.workspaceNames} reports them.
356
+ * @returns - Path of the named workspace, or the path naming the workspace the directory belongs to.
357
+ */
358
+ #requestedWorkspaceRoot(requested, names) {
359
+ const named = this.#workspacePathForName(requested, names);
360
+ if (named !== undefined)
361
+ return named;
362
+ if (isAbsolute(requested))
363
+ return this.#workspaceRootOf(resolve(requested));
364
+ throw new ProtocolError("validation_failed", `Unknown workspace ${requested}; name one a listing reports, or give an absolute directory`);
365
+ }
366
+ #workspacePathForName(requested, names) {
367
+ let name;
368
+ try {
369
+ name = parseWorkspaceName(requested);
370
+ }
371
+ catch {
372
+ return undefined;
373
+ }
374
+ for (const [workspaceId, workspaceName] of names) {
375
+ if (workspaceName === name)
376
+ return this.#workspacePath(workspaceId);
377
+ }
378
+ return undefined;
379
+ }
380
+ #workspacePath(workspaceId) {
381
+ const row = this.#db.prepare("select path from workspaces where id = ?").get(workspaceId);
382
+ if (row === undefined)
383
+ throw new Error(`Workspace ${workspaceId} is not recorded`);
384
+ return String(row.path);
385
+ }
386
+ /**
387
+ * Find who stands between an agent and an alias in a workspace.
388
+ * @param workspaceId - Workspace the alias would be held in.
389
+ * @param sessionName - Alias asked for, without its at-sign.
390
+ * @param owner - Agent asking, whose own registration and retired names are not in its way, or
391
+ * nothing for an agent not yet registered.
392
+ * @returns - The identity registered under the alias or holding it as a retired name, or nothing.
393
+ */
394
+ #aliasHolder(workspaceId, sessionName, owner) {
395
+ const registration = this.#db
396
+ .prepare("select id from agents where workspace_id = ? and registration_name = ? and state != 'reaped'")
397
+ .get(workspaceId, sessionName);
398
+ if (registration && registration.id !== owner) {
399
+ return { agentId: readStoredAgentId(registration.id, "agents.id"), retired: false };
400
+ }
401
+ const historical = this.#db
402
+ .prepare("select h.agent_id from alias_history h join agents a on a.id = h.agent_id where h.workspace_id = ? and h.alias = ? and a.state != 'reaped'")
403
+ .get(workspaceId, normalizeAlias(sessionName));
404
+ if (historical && historical.agent_id !== owner) {
405
+ return { agentId: readStoredAgentId(historical.agent_id, "alias_history.agent_id"), retired: true };
406
+ }
407
+ return undefined;
408
+ }
409
+ #aliasHeldError(sessionName, holder, names) {
410
+ const described = this.#describeAgent(holder.agentId, names);
411
+ return new ProtocolError("alias_conflict", holder.retired
412
+ ? `Agent alias ${normalizeAlias(sessionName)} is a retired name that stays reserved in this workspace for ${described}; pass force to claim it`
413
+ : `Agent alias ${normalizeAlias(sessionName)} is already in use in this workspace by ${described}; pass force to claim it`);
414
+ }
415
+ /**
416
+ * Name an identity as an error does: its address, then its implementation and id in parentheses.
417
+ * @param agentId - Identity to describe.
418
+ * @param names - Workspace names as {@link Broker.workspaceNames} reports them.
419
+ * @returns - `address (implementation, id)`, or `address (id)` when no implementation is recorded.
420
+ */
421
+ #describeAgent(agentId, names) {
422
+ const row = this.#db.prepare("select implementation_id from agents where id = ?").get(agentId);
423
+ const implementation = typeof row?.implementation_id === "string" ? `${row.implementation_id}, ` : "";
424
+ return `${this.#agentAddress(agentId, names)} (${implementation}${agentId})`;
425
+ }
426
+ /**
427
+ * Give an identity a new registration name in the workspace it is in, retiring the one it had.
428
+ *
429
+ * The retired name stays reserved as a redirect to this identity. A reservation this identity held
430
+ * on the new name is released, since the name is current again.
431
+ * @param agentId - Identity being renamed.
432
+ * @param workspaceId - Workspace the identity is in.
433
+ * @param sessionName - Name to register, already validated.
434
+ */
435
+ #renameAgent(agentId, workspaceId, sessionName) {
436
+ const row = this.#db.prepare("select registration_name from agents where id = ?").get(agentId);
437
+ const currentAlias = normalizeAlias(String(row?.registration_name));
438
+ const nextAlias = normalizeAlias(sessionName);
439
+ if (currentAlias !== nextAlias)
440
+ this.#reserveAlias(workspaceId, currentAlias, agentId);
441
+ this.#releaseOwnReservation(agentId, workspaceId, nextAlias);
442
+ this.#write("update agents set registration_name = ?, registration_ephemeral = 0 where id = ?", sessionName, agentId);
443
+ }
444
+ /**
445
+ * Drop the redirect an identity holds from an alias it now registers under, since the name is current again.
446
+ * @param agentId - Identity registered under the alias.
447
+ * @param workspaceId - Workspace the identity is in.
448
+ * @param alias - Alias with its at-sign.
449
+ */
450
+ #releaseOwnReservation(agentId, workspaceId, alias) {
451
+ this.#write("delete from alias_history where workspace_id = ? and alias = ? and agent_id = ?", workspaceId, alias, agentId);
452
+ }
453
+ /**
454
+ * Reserve a retired alias in a workspace as a redirect to an identity, unless another identity is
455
+ * registered under it there or already holds it as a retired name.
456
+ * @param workspaceId - Workspace the alias is reserved in.
457
+ * @param alias - Alias with its at-sign.
458
+ * @param agentId - Identity a send to the alias is redirected to.
459
+ */
460
+ #reserveAlias(workspaceId, alias, agentId) {
461
+ this.#write("insert or ignore into alias_history(workspace_id, alias, agent_id) select ?, ?, ? where not exists (select 1 from agents where workspace_id = ? and registration_name = ? and state != 'reaped' and id != ?)", workspaceId, alias, agentId, workspaceId, alias.slice(1), agentId);
462
+ }
463
+ /**
464
+ * Take over another identity for the caller's process.
465
+ *
466
+ * The claimed identity keeps its id, alias, inbox, history and workspace, and takes the caller's
467
+ * working directory, branch, implementation, parent and conversation key. The holder's sessions
468
+ * and unconsumed attach reservations end, and no identity is created for it.
469
+ * @param claimantId - Active identity giving itself up.
470
+ * @param holderId - Identity being taken over.
471
+ * @param names - Workspace names as {@link Broker.workspaceNames} reports them, as the addresses are read.
472
+ * @returns - What was taken and what was given up.
473
+ */
474
+ #claimIdentity(claimantId, holderId, names) {
475
+ const claimant = this.#activeAgent(claimantId);
476
+ const holder = this.#agent(holderId);
477
+ const address = this.#agentAddress(holderId, names);
478
+ const retiredAddress = this.#agentAddress(claimantId, names);
479
+ this.#endHolderSessions(holderId);
480
+ const claimantWorkspaceId = String(claimant.workspace_id);
481
+ const holderWorkspaceId = String(holder.workspace_id);
482
+ // The claimant's row is retired before the claimed one takes its conversation key: the key is
483
+ // unique among active identities of one directory and implementation.
484
+ this.#write("update agents set state = 'reaped', session_key = null, archived_at = null where id = ?", claimantId);
485
+ this.#write("update alias_history set agent_id = ? where agent_id = ?", holderId, claimantId);
486
+ this.#reserveAlias(claimantWorkspaceId, normalizeAlias(String(claimant.registration_name)), holderId);
487
+ if (claimantWorkspaceId !== holderWorkspaceId) {
488
+ this.#copyAliasHistory(holderId, claimantWorkspaceId, holderWorkspaceId);
489
+ }
490
+ this.#moveTraffic(claimantId, holderId);
491
+ this.#write("update sessions set agent_id = ? where agent_id = ?", holderId, claimantId);
492
+ this.#write("update reservations set agent_id = ? where agent_id = ?", holderId, claimantId);
493
+ this.#write("update agents set state = 'active', archived_at = null, registration_ephemeral = 0, working_directory = ?, branch = ?, parent_id = ?, implementation_id = ?, implementation_version = ?, session_key = ? where id = ?", String(claimant.working_directory), textOrNull(claimant.branch), textOrNull(claimant.parent_id), textOrNull(claimant.implementation_id), textOrNull(claimant.implementation_version), textOrNull(claimant.session_key), holderId);
494
+ return { address, agentId: holderId, retiredId: claimantId, retiredAddress };
495
+ }
496
+ /**
497
+ * End every session and unconsumed attach reservation of an identity being claimed, recording
498
+ * the claim as the cause on each session it ends, so every session or resume token its process
499
+ * presents is refused as expired.
500
+ * @param holderId - Identity being claimed.
501
+ */
502
+ #endHolderSessions(holderId) {
503
+ const cause = "claimed";
504
+ this.#write("update sessions set end_cause = ? where agent_id = ? and active = 1", cause, holderId);
505
+ this.#displaceHolder(holderId);
506
+ }
507
+ /**
508
+ * Move every message one identity sent or was sent, with the claims and send keys on them, to
509
+ * another, so the second can acknowledge, read status and repeat sends the first could.
510
+ * @param fromId - Identity giving its traffic up.
511
+ * @param toId - Identity receiving it.
512
+ */
513
+ #moveTraffic(fromId, toId) {
514
+ // A send key names one sender, recipient and key; the receiving identity's own record wins a tie.
515
+ this.#write("delete from idempotency where agent_id = ? and exists (select 1 from idempotency i where i.agent_id = ? and i.recipient_id = idempotency.recipient_id and i.key = idempotency.key)", fromId, toId);
516
+ this.#write("delete from idempotency where recipient_id = ? and exists (select 1 from idempotency i where i.recipient_id = ? and i.agent_id = idempotency.agent_id and i.key = idempotency.key)", fromId, toId);
517
+ this.#write("update idempotency set agent_id = ? where agent_id = ?", toId, fromId);
518
+ this.#write("update idempotency set recipient_id = ? where recipient_id = ?", toId, fromId);
519
+ this.#write("update messages set sender_id = ? where sender_id = ?", toId, fromId);
520
+ this.#write("update messages set recipient_id = ? where recipient_id = ?", toId, fromId);
521
+ this.#write("update claims set agent_id = ? where agent_id = ?", toId, fromId);
522
+ this.#write("delete from wakes where agent_id = ?", fromId);
523
+ this.#syncRedelivery(toId);
524
+ }
525
+ /**
526
+ * Return an agent's identity as reported to that agent.
320
527
  * @param agentId - Stable identity to inspect.
321
528
  * @returns - Identity details safe to return to the owning agent.
322
529
  */
323
530
  agentIdentity(agentId) {
324
531
  const agent = this.#agent(agentId);
532
+ return this.#identity(agentId, String(agent.workspace_id), String(agent.registration_name));
533
+ }
534
+ #identity(agentId, workspaceId, registrationName, names = this.workspaceNames()) {
325
535
  const previous = this.#db
326
536
  .prepare("select alias from alias_history where workspace_id = ? and agent_id = ? order by id")
327
- .all(String(agent.workspace_id), agentId);
537
+ .all(workspaceId, agentId);
538
+ const alias = normalizeAlias(registrationName);
539
+ const workspace = names.get(workspaceId) ?? workspaceId;
328
540
  return {
329
541
  id: agentId,
330
- alias: normalizeAlias(String(agent.registration_name)),
542
+ alias,
543
+ workspace,
544
+ address: formatAgentAddress(alias, workspace),
331
545
  previousAliases: previous.map((row) => String(row.alias)),
332
546
  };
333
547
  }
334
548
  /**
335
549
  * Reserve an identity and single-use attach reservation for a future process.
336
- * @param args - Registration name, team, and workspace the reservation is created under.
550
+ * @param args - Registration name, team, and the directory the future process works in, which
551
+ * creates the reservation in the workspace that directory belongs to.
337
552
  * @returns - Reservation identity and one-time reservation for the future agent.
338
553
  */
339
554
  reserveAgent(args) {
340
- const workspacePath = resolve(args.workspacePath);
341
555
  // A reservation is made for a process that has not started, so a caller with no name to give may
342
556
  // send an empty one and mean "derive it"; only a name with content is held to the alias rule.
343
557
  const requestedName = args.sessionName === undefined || args.sessionName === "" ? undefined : args.sessionName;
344
558
  const identity = this.#registrationIdentity(undefined, requestedName, args.implementation);
345
- // Validated before the port runs, so a rejected reservation never pays for a git subprocess.
346
- const branch = this.#currentWorkspaceBranch(workspacePath);
559
+ // Validated before the ports run, so a rejected reservation never pays for a git subprocess.
560
+ const location = this.#locate(resolve(args.workspacePath));
347
561
  return this.#transaction(() => {
348
562
  const agentId = this.#registerAgent({
349
563
  teamId: args.teamId,
350
564
  parentId: args.parentId,
351
- workspacePath: args.workspacePath,
352
565
  ...(args.implementation !== undefined ? { implementation: args.implementation } : {}),
353
- }, identity, branch);
566
+ }, identity, location);
354
567
  return { agentId, reservation: this.#reserveAttachment(agentId, "messaging") };
355
568
  });
356
569
  }
@@ -429,14 +642,14 @@ export class Broker {
429
642
  const key = validateSessionKey(sessionKey);
430
643
  return this.#transaction(() => {
431
644
  const agent = this.#db
432
- .prepare("select workspace_id, implementation_id from agents where id = ? and state = 'active'")
645
+ .prepare("select working_directory, implementation_id from agents where id = ? and state = 'active'")
433
646
  .get(agentId);
434
647
  if (!agent)
435
648
  throw new ProtocolError("conflict", "Only an active agent identity can claim a session key");
436
649
  if (typeof agent.implementation_id !== "string") {
437
650
  throw new ProtocolError("validation_failed", "A session key requires a registered implementation");
438
651
  }
439
- const owner = this.#agentForSessionKey(String(agent.workspace_id), agent.implementation_id, key);
652
+ const owner = this.#agentForSessionKey(String(agent.working_directory), agent.implementation_id, key);
440
653
  if (!owner || owner.agentId === agentId) {
441
654
  this.#bindSessionKey(agentId, key, false);
442
655
  return { agentId, adopted: false };
@@ -728,12 +941,15 @@ export class Broker {
728
941
  * Name why a known session no longer resolves.
729
942
  * @param row - The session row the caller presented, inactive or past its lease.
730
943
  * @returns - `ended` when the newest generation of the row's rotation lineage was deactivated by an
731
- * operator archive or by a newer attachment of the same identity and channel, otherwise `expired`.
944
+ * operator archive or by a newer attachment of the same identity and channel, otherwise `expired`,
945
+ * which includes a lineage whose end cause is a claim of its identity.
732
946
  */
733
947
  #sessionEndedError(row) {
734
948
  const newest = this.#newestGeneration(row);
735
949
  if (Number(newest.active) === 1)
736
950
  return new ProtocolError("expired", SESSION_EXPIRED_MESSAGE);
951
+ if (newest.end_cause === "claimed")
952
+ return new ProtocolError("expired", SESSION_EXPIRED_MESSAGE);
737
953
  const archived = this.#db.prepare("select archived_at from agents where id = ?").get(String(newest.agent_id));
738
954
  if (typeof archived?.archived_at === "number")
739
955
  return new ProtocolError("ended", "MCP session ended because an operator archived this agent");
@@ -1520,7 +1736,7 @@ export class Broker {
1520
1736
  return this.#readableAgentRows(agents).map(({ id, row }) => {
1521
1737
  const workspace = names.get(String(row.workspace_id)) ?? String(row.workspace_id);
1522
1738
  const alias = normalizeAlias(String(row.registration_name));
1523
- return { id, alias, workspace, address: `${alias}@${workspace}` };
1739
+ return { id, alias, workspace, address: formatAgentAddress(alias, workspace) };
1524
1740
  });
1525
1741
  }
1526
1742
  /**
@@ -1547,18 +1763,17 @@ export class Broker {
1547
1763
  sessionName: validateRegistrationSessionName(requestedName ?? defaultAgentSessionName(agentId)),
1548
1764
  };
1549
1765
  }
1550
- #registerAgent(args, identity, branch) {
1766
+ #registerAgent(args, identity, location) {
1551
1767
  const { agentId, sessionName } = identity;
1552
1768
  const registeredAt = this.#now();
1553
- const workspacePath = resolve(args.workspacePath);
1554
- const workspaceId = workspaceIdentity(workspacePath);
1555
- this.#ensureWorkspaceAndTeam(workspaceId, workspacePath, args.teamId, registeredAt, branch);
1769
+ const workspaceId = workspaceIdentity(location.workspaceRoot);
1770
+ this.#ensureWorkspaceAndTeam(workspaceId, location.workspaceRoot, args.teamId, registeredAt);
1556
1771
  const existing = this.#db.prepare("select state from agents where id = ?").get(agentId);
1557
1772
  if (existing && existing.state !== "active") {
1558
1773
  throw new ProtocolError("conflict", "Only an active agent identity can be reused");
1559
1774
  }
1560
1775
  this.#assertNameAvailable(workspaceId, sessionName, agentId);
1561
- this.#write("insert or ignore into agents(id, team_id, workspace_id, parent_id, state, created_at, implementation_id, implementation_version, registration_name, registration_ephemeral) values (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)", agentId, args.teamId, workspaceId, args.parentId ?? null, registeredAt, args.implementation?.id ?? null, args.implementation?.version ?? null, sessionName, args.ephemeral ? 1 : 0);
1776
+ this.#write("insert or ignore into agents(id, team_id, workspace_id, working_directory, branch, parent_id, state, created_at, implementation_id, implementation_version, registration_name, registration_ephemeral) values (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)", agentId, args.teamId, workspaceId, location.workingDirectory, location.branch, args.parentId ?? null, registeredAt, args.implementation?.id ?? null, args.implementation?.version ?? null, sessionName, args.ephemeral ? 1 : 0);
1562
1777
  return agentId;
1563
1778
  }
1564
1779
  /**
@@ -1578,39 +1793,186 @@ export class Broker {
1578
1793
  }
1579
1794
  }
1580
1795
  /**
1581
- * Read the branch checked out at a workspace path, for every registration that reaches it.
1796
+ * Locate the directory a registering agent works in.
1582
1797
  *
1583
- * Read before the transaction opens, same as for a new workspace: the port's real implementation is
1584
- * a subprocess, and holding the write lock across it stalls every other connection. A resumed
1585
- * session never reaches this — `registerAgentWithoutReservation`'s resumable branch returns before
1586
- * any workspace write — so the hottest registration path still pays no I/O; only a genuinely new
1587
- * registration does, which is also the only moment a later `git checkout` on that workspace could
1588
- * otherwise go unseen forever.
1589
- * @param workspacePath - Absolute path the port inspects.
1590
- * @returns - Branch currently checked out at that path, or null when it is not a repository.
1798
+ * Call it before the write transaction opens: both ports may block on a subprocess, and holding the
1799
+ * write lock across them stalls every other connection.
1800
+ * @param workingDirectory - Absolute directory the registering agent works in.
1801
+ * @returns - That directory, the path of the workspace it belongs to, and the branch checked out
1802
+ * there, or a null branch when it is not a repository.
1591
1803
  */
1592
- #currentWorkspaceBranch(workspacePath) {
1593
- return this.#workspaceBranch(workspacePath);
1804
+ #locate(workingDirectory) {
1805
+ return {
1806
+ workingDirectory,
1807
+ workspaceRoot: this.#workspaceRootOf(workingDirectory),
1808
+ branch: this.#workspaceBranch(workingDirectory),
1809
+ };
1594
1810
  }
1595
1811
  /**
1596
- * Record the workspace and team a registration lands in, and refresh a known workspace's branch.
1812
+ * Ask the workspace-root port which directory names a working directory's workspace.
1597
1813
  *
1598
- * A `null` branch never overwrites one already stored: the caller's lookup collapses "not a
1599
- * repository" and "the git subprocess failed" into the same answer, and a transient failure must
1600
- * not blank out a branch a prior registration actually observed.
1814
+ * A port that throws is reported, and the working directory stands in as its own workspace, so a
1815
+ * registration is never refused over a lookup. A worktree registered this way shares its repository's
1816
+ * workspace once a later registration or resume from it finds the repository.
1817
+ * @param workingDirectory - Absolute directory the registering agent works in.
1818
+ * @returns - The directory naming its workspace, resolved.
1819
+ */
1820
+ #workspaceRootOf(workingDirectory) {
1821
+ try {
1822
+ return resolve(this.#workspaceRoot(workingDirectory));
1823
+ }
1824
+ catch (error) {
1825
+ const detail = error instanceof Error ? error.message : String(error);
1826
+ this.#reportSafely(`Workspace lookup for ${workingDirectory} failed (${detail}); registering it as its own workspace`);
1827
+ return workingDirectory;
1828
+ }
1829
+ }
1830
+ /**
1831
+ * Read the location an identity last registered from.
1832
+ * @param agentId - Identity whose recorded location is read.
1833
+ * @returns - Its working directory and its workspace's path, or nothing once its row is gone.
1834
+ */
1835
+ #registeredLocation(agentId) {
1836
+ const row = this.#db
1837
+ .prepare("select a.working_directory, w.path from agents a join workspaces w on w.id = a.workspace_id where a.id = ?")
1838
+ .get(agentId);
1839
+ if (!row)
1840
+ return undefined;
1841
+ return { workingDirectory: String(row.working_directory), workspaceRoot: String(row.path) };
1842
+ }
1843
+ /**
1844
+ * Choose the workspace a resume inside the write transaction follows.
1845
+ *
1846
+ * The location settled before the transaction applies only to the identity, still in the workspace,
1847
+ * that the lookup before the transaction saw. Any other identity, or one moved since, keeps the
1848
+ * workspace it is registered in.
1849
+ * @param agentId - Identity being resumed.
1850
+ * @param registeredAhead - The identity the lookup before the transaction found and the location it
1851
+ * was registered at then, or nothing when that lookup found none.
1852
+ * @param location - Location settled before the transaction opened.
1853
+ * @returns - Path of the workspace to follow.
1854
+ */
1855
+ #workspaceRootToFollow(agentId, registeredAhead, location) {
1856
+ const registeredNow = this.#registeredLocation(agentId)?.workspaceRoot;
1857
+ const unmovedSinceLookup = registeredAhead?.agentId === agentId && registeredAhead.workspaceRoot === registeredNow;
1858
+ return unmovedSinceLookup || registeredNow === undefined ? location.workspaceRoot : registeredNow;
1859
+ }
1860
+ /**
1861
+ * Settle the workspace a resuming identity belongs to.
1862
+ *
1863
+ * An identity whose workspace is its own working directory may be a stand-in from a lookup that
1864
+ * failed when it registered, which looks exactly like a directory outside any repository or a
1865
+ * repository's own root. The port is asked again for all three; only a different answer moves it.
1866
+ * @param registered - Location the identity last registered from.
1867
+ * @returns - The workspace path the identity belongs to now.
1868
+ */
1869
+ #confirmedWorkspaceRoot(registered) {
1870
+ if (registered.workspaceRoot !== registered.workingDirectory)
1871
+ return registered.workspaceRoot;
1872
+ return this.#workspaceRootOf(registered.workingDirectory);
1873
+ }
1874
+ /**
1875
+ * Move a resuming identity into the workspace its directory now resolves to, when it can take its
1876
+ * alias there; otherwise it keeps the workspace it has and the conflict is reported.
1877
+ * @param agent - Identity being resumed.
1878
+ * @param workspaceRoot - Path of the workspace it belongs to now.
1879
+ */
1880
+ #followWorkspace(agent, workspaceRoot) {
1881
+ try {
1882
+ const workspaceId = workspaceIdentity(workspaceRoot);
1883
+ const current = this.#db.prepare("select workspace_id from agents where id = ?").get(agent.agentId);
1884
+ if (!current || String(current.workspace_id) === workspaceId)
1885
+ return;
1886
+ this.#assertNameAvailable(workspaceId, agent.sessionName, agent.agentId);
1887
+ this.#moveToWorkspace(agent.agentId, workspaceRoot, agent.sessionName);
1888
+ }
1889
+ catch (error) {
1890
+ if (!(error instanceof ProtocolError && error.code === "alias_conflict"))
1891
+ throw error;
1892
+ this.#reportSafely(`Workspace move of ${normalizeAlias(agent.sessionName)} (${agent.agentId}) to ${workspaceRoot} refused (${error.message}); it keeps its current workspace`);
1893
+ }
1894
+ }
1895
+ /**
1896
+ * Move an identity into another workspace under the alias it will hold there.
1897
+ *
1898
+ * Every alias it held in the workspace it leaves, its current one included, stays reserved there
1899
+ * as a redirect to it, and its retired aliases, and the alias it held before the move when that
1900
+ * differs from the one it takes, are reserved in the workspace it joins. A reservation the identity
1901
+ * itself holds on the alias it takes is released. Messages it has already sent or received keep the
1902
+ * workspace they were recorded under. The caller has settled that the alias is free in the target.
1903
+ * @param agentId - Identity to move.
1904
+ * @param workspaceRoot - Path naming the target workspace, created when no agent has registered in it yet.
1905
+ * @param sessionName - Name the identity registers under in the target workspace, already validated.
1906
+ */
1907
+ #moveToWorkspace(agentId, workspaceRoot, sessionName) {
1908
+ const workspaceId = workspaceIdentity(workspaceRoot);
1909
+ const current = this.#db.prepare("select workspace_id, registration_name from agents where id = ?").get(agentId);
1910
+ if (!current || String(current.workspace_id) === workspaceId)
1911
+ return;
1912
+ const previousWorkspaceId = String(current.workspace_id);
1913
+ const previousAlias = normalizeAlias(String(current.registration_name));
1914
+ const nextAlias = normalizeAlias(sessionName);
1915
+ const teamId = workspaceTeamId(workspaceRoot);
1916
+ this.#ensureWorkspaceAndTeam(workspaceId, workspaceRoot, teamId, this.#now());
1917
+ // A single update, so the unique alias index sees only the final workspace and alias.
1918
+ this.#write("update agents set workspace_id = ?, team_id = ?, registration_name = ? where id = ?", workspaceId, teamId, sessionName, agentId);
1919
+ this.#copyAliasHistory(agentId, previousWorkspaceId, workspaceId);
1920
+ this.#reserveAlias(previousWorkspaceId, previousAlias, agentId);
1921
+ if (previousAlias !== nextAlias)
1922
+ this.#reserveAlias(workspaceId, previousAlias, agentId);
1923
+ this.#releaseOwnReservation(agentId, workspaceId, nextAlias);
1924
+ }
1925
+ /**
1926
+ * Reserve, in one workspace, every retired alias an identity holds in another.
1927
+ * @param agentId - Identity whose retired aliases are copied.
1928
+ * @param fromWorkspaceId - Workspace the aliases are held in.
1929
+ * @param toWorkspaceId - Workspace they are reserved in as well.
1930
+ */
1931
+ #copyAliasHistory(agentId, fromWorkspaceId, toWorkspaceId) {
1932
+ const rows = this.#db
1933
+ .prepare("select alias from alias_history where agent_id = ? and workspace_id = ? order by id")
1934
+ .all(agentId, fromWorkspaceId);
1935
+ for (const row of rows)
1936
+ this.#reserveAlias(toWorkspaceId, String(row.alias), agentId);
1937
+ }
1938
+ /**
1939
+ * Record the branch an identity's working directory has checked out.
1940
+ *
1941
+ * A `null` branch keeps the one already stored: the lookup collapses "not a repository" and "the git
1942
+ * subprocess failed" into the same answer, and a transient failure must not blank out a branch an
1943
+ * earlier registration actually observed.
1944
+ * @param agentId - Identity whose branch is recorded.
1945
+ * @param branch - Branch read before this transaction opened, or null when none was observed.
1946
+ */
1947
+ #recordBranch(agentId, branch) {
1948
+ this.#write("update agents set branch = coalesce(?, branch) where id = ?", branch, agentId);
1949
+ }
1950
+ /**
1951
+ * Record that a rejoined identity now works where its new holder does.
1952
+ *
1953
+ * The identity gives up its conversation key when the directory changes: the key names a
1954
+ * conversation in the directory it was bound in, and the holder that displaced it brings its own.
1955
+ * A `null` branch keeps the stored one only while the directory is unchanged.
1956
+ * @param agentId - Identity being rejoined.
1957
+ * @param location - Where the rejoining holder works, read before this transaction opened.
1958
+ */
1959
+ #rejoinFromLocation(agentId, location) {
1960
+ this.#write("update agents set session_key = case when working_directory = ? then session_key end, branch = coalesce(?, case when working_directory = ? then branch end), working_directory = ? where id = ?", location.workingDirectory, location.branch, location.workingDirectory, location.workingDirectory, agentId);
1961
+ }
1962
+ /**
1963
+ * Record the workspace and team a registration lands in.
1601
1964
  * @param workspaceId - Identity derived from the workspace path.
1602
1965
  * @param workspacePath - Absolute path the workspace identity was derived from.
1603
1966
  * @param teamId - Team the registering agent joins.
1604
1967
  * @param registeredAt - Broker-clock timestamp shared with the agent row this registration writes.
1605
- * @param branch - Branch checked out at that path, read before this transaction opened.
1606
1968
  */
1607
- #ensureWorkspaceAndTeam(workspaceId, workspacePath, teamId, registeredAt, branch) {
1608
- this.#write("insert into workspaces(id, path, branch, created_at) values (?, ?, ?, ?) on conflict(id) do update set branch = coalesce(excluded.branch, workspaces.branch)", workspaceId, workspacePath, branch, registeredAt);
1969
+ #ensureWorkspaceAndTeam(workspaceId, workspacePath, teamId, registeredAt) {
1970
+ this.#write("insert or ignore into workspaces(id, path, created_at) values (?, ?, ?)", workspaceId, workspacePath, registeredAt);
1609
1971
  this.#write("insert or ignore into teams(id, workspace_id, created_at) values (?, ?, ?)", teamId, workspaceId, registeredAt);
1610
1972
  }
1611
1973
  #registerAgentWithoutReservation(args) {
1612
1974
  const registeredAt = this.#now();
1613
- this.#ensureWorkspaceAndTeam(args.workspaceId, args.workspacePath, args.teamId, registeredAt, args.branch);
1975
+ this.#ensureWorkspaceAndTeam(args.workspaceId, args.location.workspaceRoot, args.teamId, registeredAt);
1614
1976
  const existing = this.#db
1615
1977
  .prepare("select id, implementation_id from agents where workspace_id = ? and registration_name = ? and state = 'active'")
1616
1978
  .get(args.workspaceId, args.sessionName);
@@ -1622,11 +1984,12 @@ export class Broker {
1622
1984
  this.#assertNameAvailable(args.workspaceId, args.sessionName, agentId);
1623
1985
  this.#assertHolderDetached(agentId, args.sessionName);
1624
1986
  this.#displaceHolder(agentId);
1987
+ this.#rejoinFromLocation(agentId, args.location);
1625
1988
  return agentId;
1626
1989
  }
1627
1990
  this.#assertNameAvailable(args.workspaceId, args.sessionName);
1628
1991
  const agentId = mintAgentId(issueOpaqueToken("agent"));
1629
- this.#write("insert into agents(id, team_id, workspace_id, parent_id, state, created_at, implementation_id, implementation_version, registration_name, registration_ephemeral) values (?, ?, ?, null, 'active', ?, ?, ?, ?, ?)", agentId, args.teamId, args.workspaceId, registeredAt, args.implementation.id, args.implementation.version ?? null, args.sessionName, args.ephemeral ? 1 : 0);
1992
+ this.#write("insert into agents(id, team_id, workspace_id, working_directory, branch, parent_id, state, created_at, implementation_id, implementation_version, registration_name, registration_ephemeral) values (?, ?, ?, ?, ?, null, 'active', ?, ?, ?, ?, ?)", agentId, args.teamId, args.workspaceId, args.location.workingDirectory, args.location.branch, registeredAt, args.implementation.id, args.implementation.version ?? null, args.sessionName, args.ephemeral ? 1 : 0);
1630
1993
  return agentId;
1631
1994
  }
1632
1995
  /**
@@ -1644,15 +2007,32 @@ export class Broker {
1644
2007
  .get(holderId, this.#now());
1645
2008
  if (!live)
1646
2009
  return;
1647
- const holder = this.#db.prepare("select implementation_id from agents where id = ?").get(holderId);
1648
- const address = this.#agentAddress(holderId, this.workspaceNames());
1649
- const implementation = typeof holder?.implementation_id === "string" ? `${holder.implementation_id}, ` : "";
1650
- throw new ProtocolError("alias_conflict", `Session name ${sessionName} is held by ${address} (${implementation}${holderId}), whose attachment is live; choose another name, or end that session first`);
2010
+ throw new ProtocolError("alias_conflict", `Session name ${sessionName} is held by ${this.#describeAgent(holderId, this.workspaceNames())}, whose attachment is live; choose another name, or end that session first`);
1651
2011
  }
1652
- #agentForSessionKey(workspaceId, implementationId, sessionKey) {
2012
+ /**
2013
+ * Find the identity a conversation registering from a directory resumes.
2014
+ *
2015
+ * The conversation's own key is looked up first. Only when it owns nothing is the key of the
2016
+ * conversation it continues tried.
2017
+ * @param workingDirectory - Absolute directory the conversation registers from.
2018
+ * @param keys - Implementation and the conversation's keys; no key means nothing is resumable.
2019
+ * @returns - The identity and whether the continued key, rather than the conversation's own, found it.
2020
+ */
2021
+ #resumableIdentity(workingDirectory, keys) {
2022
+ if (keys.sessionKey === undefined)
2023
+ return undefined;
2024
+ const owned = this.#agentForSessionKey(workingDirectory, keys.implementationId, keys.sessionKey);
2025
+ if (owned)
2026
+ return { agent: owned, byContinuedKey: false };
2027
+ if (keys.continuesSessionKey === undefined)
2028
+ return undefined;
2029
+ const continued = this.#agentForSessionKey(workingDirectory, keys.implementationId, keys.continuesSessionKey);
2030
+ return continued && { agent: continued, byContinuedKey: true };
2031
+ }
2032
+ #agentForSessionKey(workingDirectory, implementationId, sessionKey) {
1653
2033
  const row = this.#db
1654
- .prepare("select id, registration_name, registration_ephemeral from agents where workspace_id = ? and implementation_id = ? and session_key = ? and state = 'active'")
1655
- .get(workspaceId, implementationId, sessionKey);
2034
+ .prepare("select id, registration_name, registration_ephemeral from agents where working_directory = ? and implementation_id = ? and session_key = ? and state = 'active'")
2035
+ .get(workingDirectory, implementationId, sessionKey);
1656
2036
  if (!row)
1657
2037
  return undefined;
1658
2038
  return {
@@ -1683,14 +2063,14 @@ export class Broker {
1683
2063
  }
1684
2064
  #resolveSessionKeyAtAttach(reserved, sessionKey) {
1685
2065
  const reservedAgent = this.#db
1686
- .prepare("select workspace_id, implementation_id, session_key from agents where id = ?")
2066
+ .prepare("select working_directory, implementation_id, session_key from agents where id = ?")
1687
2067
  .get(reserved);
1688
2068
  if (!reservedAgent)
1689
2069
  throw new ProtocolError("unknown_recipient", "Agent identity is unknown");
1690
2070
  if (typeof reservedAgent.implementation_id !== "string") {
1691
2071
  throw new ProtocolError("validation_failed", "A session key requires a registered implementation");
1692
2072
  }
1693
- const owner = this.#agentForSessionKey(String(reservedAgent.workspace_id), reservedAgent.implementation_id, sessionKey);
2073
+ const owner = this.#agentForSessionKey(String(reservedAgent.working_directory), reservedAgent.implementation_id, sessionKey);
1694
2074
  if (!owner || owner.agentId === reserved) {
1695
2075
  this.#bindSessionKey(reserved, sessionKey, true);
1696
2076
  return reserved;
@@ -1828,19 +2208,12 @@ export class Broker {
1828
2208
  return Boolean(row);
1829
2209
  }
1830
2210
  #assertNameAvailable(workspaceId, sessionName, owner) {
1831
- const alias = normalizeAlias(sessionName);
1832
- const registration = this.#db
1833
- .prepare("select id from agents where workspace_id = ? and registration_name = ? and state != 'reaped'")
1834
- .get(workspaceId, sessionName);
1835
- const historical = this.#db
1836
- .prepare("select agent_id from alias_history where workspace_id = ? and alias = ?")
1837
- .get(workspaceId, alias);
1838
- if (registration && registration.id !== owner) {
1839
- throw new ProtocolError("alias_conflict", "Agent alias is already in use in this workspace");
1840
- }
1841
- if (historical && historical.agent_id !== owner) {
1842
- throw new ProtocolError("alias_conflict", "Agent alias is a retired name that stays reserved in this workspace");
1843
- }
2211
+ const holder = this.#aliasHolder(workspaceId, sessionName, owner);
2212
+ if (!holder)
2213
+ return;
2214
+ throw new ProtocolError("alias_conflict", holder.retired
2215
+ ? "Agent alias is a retired name that stays reserved in this workspace"
2216
+ : "Agent alias is already in use in this workspace");
1844
2217
  }
1845
2218
  #pairSendCount(from, to) {
1846
2219
  const row = this.#db
@@ -2119,10 +2492,11 @@ export class Broker {
2119
2492
  if (current)
2120
2493
  return readStoredAgentId(current.id, "agents.id");
2121
2494
  const historical = this.#db
2122
- .prepare("select a.registration_name from alias_history h join agents a on a.id = h.agent_id where h.workspace_id = ? and h.alias = ? and a.state != 'reaped'")
2495
+ .prepare("select a.id from alias_history h join agents a on a.id = h.agent_id where h.workspace_id = ? and h.alias = ? and a.state != 'reaped'")
2123
2496
  .get(targetWorkspace, normalizeAlias(address.sessionName));
2124
- if (typeof historical?.registration_name === "string") {
2125
- throw new ProtocolError("recipient_alias_changed", `Agent alias changed to ${normalizeAlias(historical.registration_name)}; try that alias instead`);
2497
+ if (historical) {
2498
+ const current = this.#agentAddress(readStoredAgentId(historical.id, "alias_history.agent_id"), this.workspaceNames());
2499
+ throw new ProtocolError("recipient_alias_changed", `Agent alias changed to ${current}; try that address instead`);
2126
2500
  }
2127
2501
  throw new ProtocolError("unknown_recipient", "Unknown recipient");
2128
2502
  }
@@ -2142,7 +2516,7 @@ export class Broker {
2142
2516
  if (typeof alias !== "string" || alias === "")
2143
2517
  return agentId;
2144
2518
  const workspace = workspaces.get(String(row.workspace_id)) ?? String(row.workspace_id);
2145
- return `${normalizeAlias(alias)}@${workspace}`;
2519
+ return formatAgentAddress(alias, workspace);
2146
2520
  }
2147
2521
  #workspaceForName(name, names) {
2148
2522
  for (const [workspaceId, workspaceName] of names) {
@@ -2273,6 +2647,14 @@ function mutatedTable(sql) {
2273
2647
  throw new Error(`Broker write statement names no table: ${sql}`);
2274
2648
  return match[1];
2275
2649
  }
2650
+ /**
2651
+ * Read a nullable text column.
2652
+ * @param value - Column value as the driver returned it.
2653
+ * @returns - The text, or null for any other value.
2654
+ */
2655
+ function textOrNull(value) {
2656
+ return typeof value === "string" ? value : null;
2657
+ }
2276
2658
  function contentType(body) {
2277
2659
  return typeof body === "string" ? "text" : "json";
2278
2660
  }