@haywoodmarx/agent-pager 0.1.3 → 0.2.1

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 (77) 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 +112 -24
  5. package/dist/src/broker/broker.js +604 -132
  6. package/dist/src/broker/operator-projection.d.ts +22 -0
  7. package/dist/src/broker/operator-projection.js +43 -5
  8. package/dist/src/broker/protocol.d.ts +1 -1
  9. package/dist/src/broker/protocol.js +1 -1
  10. package/dist/src/broker/schema.d.ts +4 -0
  11. package/dist/src/broker/schema.js +20 -8
  12. package/dist/src/broker/snapshot.d.ts +4 -2
  13. package/dist/src/implementations/claude/gateway-cli.js +1 -2
  14. package/dist/src/implementations/claude/gateway.d.ts +11 -4
  15. package/dist/src/implementations/claude/gateway.js +44 -14
  16. package/dist/src/implementations/claude/instructions.js +1 -1
  17. package/dist/src/implementations/model.d.ts +6 -3
  18. package/dist/src/implementations/opencode/plugin.d.ts +7 -2
  19. package/dist/src/implementations/opencode/plugin.js +103 -7
  20. package/dist/src/implementations/pi/broker-client.d.ts +13 -4
  21. package/dist/src/implementations/pi/broker-client.js +71 -9
  22. package/dist/src/implementations/pi/extension.d.ts +7 -2
  23. package/dist/src/implementations/pi/extension.js +15 -3
  24. package/dist/src/index.d.ts +2 -2
  25. package/dist/src/index.js +2 -2
  26. package/dist/src/launch/broker-lock.d.ts +58 -4
  27. package/dist/src/launch/broker-lock.js +140 -7
  28. package/dist/src/launch/launch.d.ts +1 -1
  29. package/dist/src/launch/on-demand.d.ts +8 -8
  30. package/dist/src/launch/on-demand.js +54 -35
  31. package/dist/src/launch/runtime-composition.js +2 -0
  32. package/dist/src/launch/runtime.js +4 -14
  33. package/dist/src/launch/service.d.ts +1 -0
  34. package/dist/src/launch/service.js +23 -1
  35. package/dist/src/launch/workspace-branch.d.ts +3 -3
  36. package/dist/src/launch/workspace-branch.js +7 -15
  37. package/dist/src/launch/workspace-root.d.ts +13 -0
  38. package/dist/src/launch/workspace-root.js +27 -0
  39. package/dist/src/messaging/delivery-client.d.ts +17 -0
  40. package/dist/src/messaging/delivery-client.js +31 -1
  41. package/dist/src/messaging/identity-statement.d.ts +7 -0
  42. package/dist/src/messaging/identity-statement.js +8 -0
  43. package/dist/src/messaging/mcp.d.ts +8 -2
  44. package/dist/src/messaging/mcp.js +54 -1
  45. package/dist/src/messaging/notifications.d.ts +31 -3
  46. package/dist/src/messaging/notifications.js +25 -0
  47. package/dist/src/messaging/push-delivery.d.ts +2 -1
  48. package/dist/src/messaging/push-delivery.js +24 -7
  49. package/dist/src/messaging/tools.js +22 -2
  50. package/dist/src/messaging/wake.js +7 -0
  51. package/dist/src/operator/agent-projection.d.ts +8 -5
  52. package/dist/src/operator/agent-projection.js +29 -9
  53. package/dist/src/operator/commands.d.ts +2 -0
  54. package/dist/src/operator/commands.js +57 -21
  55. package/dist/src/operator/protocol.d.ts +34 -2
  56. package/dist/src/operator/protocol.js +9 -0
  57. package/dist/src/operator/repository-system.d.ts +29 -0
  58. package/dist/src/operator/repository-system.js +39 -12
  59. package/dist/src/operator/repository.d.ts +28 -11
  60. package/dist/src/operator/repository.js +37 -4
  61. package/dist/src/operator/transport.js +10 -5
  62. package/dist/src/operator/updates.js +7 -1
  63. package/dist/src/operator/validation.js +35 -1
  64. package/dist/src/shared/versions.d.ts +18 -0
  65. package/dist/src/shared/versions.js +26 -0
  66. package/dist/src/tui/agent-tree.d.ts +8 -5
  67. package/dist/src/tui/agent-tree.js +195 -22
  68. package/dist/src/tui/app.js +6 -2
  69. package/dist/src/tui/message-views.js +4 -3
  70. package/dist/src/tui/model.d.ts +25 -1
  71. package/dist/src/tui/model.js +33 -9
  72. package/dist/src/tui/presentation.d.ts +11 -2
  73. package/dist/src/tui/presentation.js +26 -4
  74. package/dist/src/version.d.ts +7 -0
  75. package/dist/src/version.js +14 -6
  76. package/package.json +1 -1
  77. package/skills/pager/SKILL.md +35 -45
@@ -1,12 +1,12 @@
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";
9
- import { assertDurableOperatorId, readStoredAgentId, readStoredEnum, BrokerOperatorProjection, } from "./operator-projection.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
+ import { assertDurableOperatorId, encodePurgeTargets, samePurgeTargets, readStoredAgentId, readStoredEnum, BrokerOperatorProjection, } from "./operator-projection.js";
10
10
  const notificationDisplay = {
11
11
  delivered: { text: "notify delivered", tone: "ok" },
12
12
  shed: { text: "notify shed — budget exceeded", tone: "warn" },
@@ -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 };
@@ -629,15 +842,20 @@ export class Broker {
629
842
  const settled = this.#db
630
843
  .prepare("select id from messages where state in ('acknowledged', 'expired', 'dead_lettered', 'failed') and created_at <= ?")
631
844
  .all(cutoff);
632
- for (const row of settled) {
633
- const messageId = String(row.id);
634
- this.#write("delete from message_events where message_id = ?", messageId);
635
- this.#write("delete from claims where message_id = ?", messageId);
636
- this.#write("delete from idempotency where message_id = ?", messageId);
637
- this.#write("delete from messages where id = ?", messageId);
638
- }
845
+ for (const row of settled)
846
+ this.#deleteMessage(String(row.id));
639
847
  });
640
848
  }
849
+ /**
850
+ * Delete one message and every row that belongs to it.
851
+ * @param messageId - Message whose events, claims and idempotency keys go with it.
852
+ */
853
+ #deleteMessage(messageId) {
854
+ this.#write("delete from message_events where message_id = ?", messageId);
855
+ this.#write("delete from claims where message_id = ?", messageId);
856
+ this.#write("delete from idempotency where message_id = ?", messageId);
857
+ this.#write("delete from messages where id = ?", messageId);
858
+ }
641
859
  /**
642
860
  * Resume an MCP attachment while rotating a lapsed or prior-epoch session generation.
643
861
  * @param request - Session, fencing proof, and last-seen generation the caller resumes with.
@@ -728,12 +946,17 @@ export class Broker {
728
946
  * Name why a known session no longer resolves.
729
947
  * @param row - The session row the caller presented, inactive or past its lease.
730
948
  * @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`.
949
+ * operator archive or purge or by a newer attachment of the same identity and channel, otherwise
950
+ * `expired`, which includes a lineage whose end cause is a claim of its identity.
732
951
  */
733
952
  #sessionEndedError(row) {
734
953
  const newest = this.#newestGeneration(row);
735
954
  if (Number(newest.active) === 1)
736
955
  return new ProtocolError("expired", SESSION_EXPIRED_MESSAGE);
956
+ if (newest.end_cause === "claimed")
957
+ return new ProtocolError("expired", SESSION_EXPIRED_MESSAGE);
958
+ if (newest.end_cause === "purged")
959
+ return new ProtocolError("ended", "MCP session ended because an operator purged this agent");
737
960
  const archived = this.#db.prepare("select archived_at from agents where id = ?").get(String(newest.agent_id));
738
961
  if (typeof archived?.archived_at === "number")
739
962
  return new ProtocolError("ended", "MCP session ended because an operator archived this agent");
@@ -1317,12 +1540,15 @@ export class Broker {
1317
1540
  }
1318
1541
  /**
1319
1542
  * Append an operational event recording the outcome of a delivery notification attempt.
1320
- * @param messageId - Message the notification outcome is recorded against.
1543
+ * @param messageId - Message the notification outcome is recorded against; an outcome for a
1544
+ * message deleted while its notification was in flight is dropped.
1321
1545
  * @param outcome - Observed notification result recorded for diagnostics.
1322
1546
  */
1323
1547
  recordNotification(messageId, outcome) {
1324
1548
  const display = notificationDisplay[outcome];
1325
1549
  this.#transaction(() => {
1550
+ if (this.#db.prepare("select 1 as present from messages where id = ?").get(messageId) === undefined)
1551
+ return;
1326
1552
  this.#appendEvent(messageId, "notify", `notify_${outcome}`, display.text, display.tone);
1327
1553
  });
1328
1554
  }
@@ -1444,6 +1670,86 @@ export class Broker {
1444
1670
  });
1445
1671
  return acceptance;
1446
1672
  }
1673
+ /**
1674
+ * Accept one operator purge request, deleting the named identities and their records.
1675
+ *
1676
+ * A message is deleted with its events and claims only when neither end is an identity that
1677
+ * outlives the purge. A message exchanged with one that does stays in that identity's history,
1678
+ * naming the purged end by its raw id, and is dead-lettered if it was still waiting in a purged
1679
+ * inbox. Operator command records keep the ids they targeted, so a retried command is still
1680
+ * answered from its record, and the identity's sessions are kept ended, so a process still
1681
+ * attached to it is told it was ended. Claims a purged identity made on a kept message stay as
1682
+ * that message's delivery attempts, and operator stream events already published about the
1683
+ * identity stay in the replay journal until it trims them. Every other row naming a purged identity
1684
+ * is deleted.
1685
+ * Workspace rows are kept even when no identity remains in them, because previous addresses
1686
+ * into a workspace and the short names of other workspaces are resolved from them.
1687
+ * @param commandId - Correlates retries of the same operator request.
1688
+ * @param agentIds - Distinct agents the operator asked to purge, in any lifecycle state.
1689
+ * @returns - Durable acceptance, reporting whether this call created it.
1690
+ */
1691
+ acceptOperatorPurge(commandId, agentIds) {
1692
+ assertDurableOperatorId(commandId, "command");
1693
+ const targetId = encodePurgeTargets(agentIds);
1694
+ return this.#transaction(() => {
1695
+ const prior = this.operatorCommand(commandId);
1696
+ if (prior) {
1697
+ if (prior.type !== "purge_agents" || !samePurgeTargets(prior.targetIds, agentIds)) {
1698
+ throw new ProtocolError("conflict", "Operator command already exists");
1699
+ }
1700
+ return { status: "existing", record: prior };
1701
+ }
1702
+ const targets = new Set(agentIds);
1703
+ if (agentIds.length === 0 || targets.size !== agentIds.length)
1704
+ throw new ProtocolError("validation_failed", "Purge targets must be distinct and non-empty");
1705
+ const present = this.#db.prepare("select 1 as present from agents where id = ?");
1706
+ for (const agentId of targets) {
1707
+ if (present.get(agentId) === undefined)
1708
+ throw new ProtocolError("validation_failed", "Unknown agent");
1709
+ }
1710
+ const survivors = new Map();
1711
+ const outlives = (agentId) => {
1712
+ if (typeof agentId !== "string" || targets.has(agentId))
1713
+ return false;
1714
+ let known = survivors.get(agentId);
1715
+ if (known === undefined) {
1716
+ known = present.get(agentId) !== undefined;
1717
+ survivors.set(agentId, known);
1718
+ }
1719
+ return known;
1720
+ };
1721
+ const involving = this.#db.prepare("select id, sender_id, recipient_id, state from messages where sender_id = ? or recipient_id = ? order by sequence");
1722
+ for (const agentId of targets) {
1723
+ const waiting = [];
1724
+ for (const row of involving.all(agentId, agentId)) {
1725
+ if (!outlives(row.sender_id) && !outlives(row.recipient_id))
1726
+ this.#deleteMessage(String(row.id));
1727
+ else if (row.recipient_id === agentId && (row.state === "queued" || row.state === "claimed"))
1728
+ waiting.push(row);
1729
+ }
1730
+ this.#deadLetter(waiting, "dead-lettered — recipient was purged");
1731
+ }
1732
+ for (const agentId of targets) {
1733
+ this.#write("delete from idempotency where agent_id = ? or recipient_id = ?", agentId, agentId);
1734
+ this.#write("delete from alias_history where agent_id = ?", agentId);
1735
+ // Kept, ended, so a process still attached is told it was ended rather than that its session
1736
+ // lapsed, which a connector answers by registering again under the same alias. A session a
1737
+ // claim already ended keeps that cause, which tells its process to register again.
1738
+ this.#write("update sessions set active = 0, end_cause = coalesce(end_cause, 'purged') where agent_id = ?", agentId);
1739
+ this.#write("delete from reservations where agent_id = ?", agentId);
1740
+ this.#write("delete from wakes where agent_id = ?", agentId);
1741
+ this.#write("delete from operator_ping_audits where target_agent_id = ?", agentId);
1742
+ this.#write("update agents set parent_id = null where parent_id = ?", agentId);
1743
+ this.#write("delete from agents where id = ?", agentId);
1744
+ }
1745
+ const acceptedAt = this.#now();
1746
+ this.#write("insert into operator_commands(command_id, type, target_id, accepted_at, control_id) values (?, 'purge_agents', ?, ?, null)", commandId, targetId, acceptedAt);
1747
+ return {
1748
+ status: "created",
1749
+ record: { type: "purge_agents", commandId, targetIds: [...agentIds], acceptedAt },
1750
+ };
1751
+ });
1752
+ }
1447
1753
  /**
1448
1754
  * Returns the broker snapshot, optionally scoped to one workspace.
1449
1755
  *
@@ -1520,7 +1826,7 @@ export class Broker {
1520
1826
  return this.#readableAgentRows(agents).map(({ id, row }) => {
1521
1827
  const workspace = names.get(String(row.workspace_id)) ?? String(row.workspace_id);
1522
1828
  const alias = normalizeAlias(String(row.registration_name));
1523
- return { id, alias, workspace, address: `${alias}@${workspace}` };
1829
+ return { id, alias, workspace, address: formatAgentAddress(alias, workspace) };
1524
1830
  });
1525
1831
  }
1526
1832
  /**
@@ -1547,18 +1853,17 @@ export class Broker {
1547
1853
  sessionName: validateRegistrationSessionName(requestedName ?? defaultAgentSessionName(agentId)),
1548
1854
  };
1549
1855
  }
1550
- #registerAgent(args, identity, branch) {
1856
+ #registerAgent(args, identity, location) {
1551
1857
  const { agentId, sessionName } = identity;
1552
1858
  const registeredAt = this.#now();
1553
- const workspacePath = resolve(args.workspacePath);
1554
- const workspaceId = workspaceIdentity(workspacePath);
1555
- this.#ensureWorkspaceAndTeam(workspaceId, workspacePath, args.teamId, registeredAt, branch);
1859
+ const workspaceId = workspaceIdentity(location.workspaceRoot);
1860
+ this.#ensureWorkspaceAndTeam(workspaceId, location.workspaceRoot, args.teamId, registeredAt);
1556
1861
  const existing = this.#db.prepare("select state from agents where id = ?").get(agentId);
1557
1862
  if (existing && existing.state !== "active") {
1558
1863
  throw new ProtocolError("conflict", "Only an active agent identity can be reused");
1559
1864
  }
1560
1865
  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);
1866
+ 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
1867
  return agentId;
1563
1868
  }
1564
1869
  /**
@@ -1578,39 +1883,186 @@ export class Broker {
1578
1883
  }
1579
1884
  }
1580
1885
  /**
1581
- * Read the branch checked out at a workspace path, for every registration that reaches it.
1886
+ * Locate the directory a registering agent works in.
1887
+ *
1888
+ * Call it before the write transaction opens: both ports may block on a subprocess, and holding the
1889
+ * write lock across them stalls every other connection.
1890
+ * @param workingDirectory - Absolute directory the registering agent works in.
1891
+ * @returns - That directory, the path of the workspace it belongs to, and the branch checked out
1892
+ * there, or a null branch when it is not a repository.
1893
+ */
1894
+ #locate(workingDirectory) {
1895
+ return {
1896
+ workingDirectory,
1897
+ workspaceRoot: this.#workspaceRootOf(workingDirectory),
1898
+ branch: this.#workspaceBranch(workingDirectory),
1899
+ };
1900
+ }
1901
+ /**
1902
+ * Ask the workspace-root port which directory names a working directory's workspace.
1582
1903
  *
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.
1904
+ * A port that throws is reported, and the working directory stands in as its own workspace, so a
1905
+ * registration is never refused over a lookup. A worktree registered this way shares its repository's
1906
+ * workspace once a later registration or resume from it finds the repository.
1907
+ * @param workingDirectory - Absolute directory the registering agent works in.
1908
+ * @returns - The directory naming its workspace, resolved.
1591
1909
  */
1592
- #currentWorkspaceBranch(workspacePath) {
1593
- return this.#workspaceBranch(workspacePath);
1910
+ #workspaceRootOf(workingDirectory) {
1911
+ try {
1912
+ return resolve(this.#workspaceRoot(workingDirectory));
1913
+ }
1914
+ catch (error) {
1915
+ const detail = error instanceof Error ? error.message : String(error);
1916
+ this.#reportSafely(`Workspace lookup for ${workingDirectory} failed (${detail}); registering it as its own workspace`);
1917
+ return workingDirectory;
1918
+ }
1919
+ }
1920
+ /**
1921
+ * Read the location an identity last registered from.
1922
+ * @param agentId - Identity whose recorded location is read.
1923
+ * @returns - Its working directory and its workspace's path, or nothing once its row is gone.
1924
+ */
1925
+ #registeredLocation(agentId) {
1926
+ const row = this.#db
1927
+ .prepare("select a.working_directory, w.path from agents a join workspaces w on w.id = a.workspace_id where a.id = ?")
1928
+ .get(agentId);
1929
+ if (!row)
1930
+ return undefined;
1931
+ return { workingDirectory: String(row.working_directory), workspaceRoot: String(row.path) };
1594
1932
  }
1595
1933
  /**
1596
- * Record the workspace and team a registration lands in, and refresh a known workspace's branch.
1934
+ * Choose the workspace a resume inside the write transaction follows.
1935
+ *
1936
+ * The location settled before the transaction applies only to the identity, still in the workspace,
1937
+ * that the lookup before the transaction saw. Any other identity, or one moved since, keeps the
1938
+ * workspace it is registered in.
1939
+ * @param agentId - Identity being resumed.
1940
+ * @param registeredAhead - The identity the lookup before the transaction found and the location it
1941
+ * was registered at then, or nothing when that lookup found none.
1942
+ * @param location - Location settled before the transaction opened.
1943
+ * @returns - Path of the workspace to follow.
1944
+ */
1945
+ #workspaceRootToFollow(agentId, registeredAhead, location) {
1946
+ const registeredNow = this.#registeredLocation(agentId)?.workspaceRoot;
1947
+ const unmovedSinceLookup = registeredAhead?.agentId === agentId && registeredAhead.workspaceRoot === registeredNow;
1948
+ return unmovedSinceLookup || registeredNow === undefined ? location.workspaceRoot : registeredNow;
1949
+ }
1950
+ /**
1951
+ * Settle the workspace a resuming identity belongs to.
1597
1952
  *
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.
1953
+ * An identity whose workspace is its own working directory may be a stand-in from a lookup that
1954
+ * failed when it registered, which looks exactly like a directory outside any repository or a
1955
+ * repository's own root. The port is asked again for all three; only a different answer moves it.
1956
+ * @param registered - Location the identity last registered from.
1957
+ * @returns - The workspace path the identity belongs to now.
1958
+ */
1959
+ #confirmedWorkspaceRoot(registered) {
1960
+ if (registered.workspaceRoot !== registered.workingDirectory)
1961
+ return registered.workspaceRoot;
1962
+ return this.#workspaceRootOf(registered.workingDirectory);
1963
+ }
1964
+ /**
1965
+ * Move a resuming identity into the workspace its directory now resolves to, when it can take its
1966
+ * alias there; otherwise it keeps the workspace it has and the conflict is reported.
1967
+ * @param agent - Identity being resumed.
1968
+ * @param workspaceRoot - Path of the workspace it belongs to now.
1969
+ */
1970
+ #followWorkspace(agent, workspaceRoot) {
1971
+ try {
1972
+ const workspaceId = workspaceIdentity(workspaceRoot);
1973
+ const current = this.#db.prepare("select workspace_id from agents where id = ?").get(agent.agentId);
1974
+ if (!current || String(current.workspace_id) === workspaceId)
1975
+ return;
1976
+ this.#assertNameAvailable(workspaceId, agent.sessionName, agent.agentId);
1977
+ this.#moveToWorkspace(agent.agentId, workspaceRoot, agent.sessionName);
1978
+ }
1979
+ catch (error) {
1980
+ if (!(error instanceof ProtocolError && error.code === "alias_conflict"))
1981
+ throw error;
1982
+ this.#reportSafely(`Workspace move of ${normalizeAlias(agent.sessionName)} (${agent.agentId}) to ${workspaceRoot} refused (${error.message}); it keeps its current workspace`);
1983
+ }
1984
+ }
1985
+ /**
1986
+ * Move an identity into another workspace under the alias it will hold there.
1987
+ *
1988
+ * Every alias it held in the workspace it leaves, its current one included, stays reserved there
1989
+ * as a redirect to it, and its retired aliases, and the alias it held before the move when that
1990
+ * differs from the one it takes, are reserved in the workspace it joins. A reservation the identity
1991
+ * itself holds on the alias it takes is released. Messages it has already sent or received keep the
1992
+ * workspace they were recorded under. The caller has settled that the alias is free in the target.
1993
+ * @param agentId - Identity to move.
1994
+ * @param workspaceRoot - Path naming the target workspace, created when no agent has registered in it yet.
1995
+ * @param sessionName - Name the identity registers under in the target workspace, already validated.
1996
+ */
1997
+ #moveToWorkspace(agentId, workspaceRoot, sessionName) {
1998
+ const workspaceId = workspaceIdentity(workspaceRoot);
1999
+ const current = this.#db.prepare("select workspace_id, registration_name from agents where id = ?").get(agentId);
2000
+ if (!current || String(current.workspace_id) === workspaceId)
2001
+ return;
2002
+ const previousWorkspaceId = String(current.workspace_id);
2003
+ const previousAlias = normalizeAlias(String(current.registration_name));
2004
+ const nextAlias = normalizeAlias(sessionName);
2005
+ const teamId = workspaceTeamId(workspaceRoot);
2006
+ this.#ensureWorkspaceAndTeam(workspaceId, workspaceRoot, teamId, this.#now());
2007
+ // A single update, so the unique alias index sees only the final workspace and alias.
2008
+ this.#write("update agents set workspace_id = ?, team_id = ?, registration_name = ? where id = ?", workspaceId, teamId, sessionName, agentId);
2009
+ this.#copyAliasHistory(agentId, previousWorkspaceId, workspaceId);
2010
+ this.#reserveAlias(previousWorkspaceId, previousAlias, agentId);
2011
+ if (previousAlias !== nextAlias)
2012
+ this.#reserveAlias(workspaceId, previousAlias, agentId);
2013
+ this.#releaseOwnReservation(agentId, workspaceId, nextAlias);
2014
+ }
2015
+ /**
2016
+ * Reserve, in one workspace, every retired alias an identity holds in another.
2017
+ * @param agentId - Identity whose retired aliases are copied.
2018
+ * @param fromWorkspaceId - Workspace the aliases are held in.
2019
+ * @param toWorkspaceId - Workspace they are reserved in as well.
2020
+ */
2021
+ #copyAliasHistory(agentId, fromWorkspaceId, toWorkspaceId) {
2022
+ const rows = this.#db
2023
+ .prepare("select alias from alias_history where agent_id = ? and workspace_id = ? order by id")
2024
+ .all(agentId, fromWorkspaceId);
2025
+ for (const row of rows)
2026
+ this.#reserveAlias(toWorkspaceId, String(row.alias), agentId);
2027
+ }
2028
+ /**
2029
+ * Record the branch an identity's working directory has checked out.
2030
+ *
2031
+ * A `null` branch keeps the one already stored: the lookup collapses "not a repository" and "the git
2032
+ * subprocess failed" into the same answer, and a transient failure must not blank out a branch an
2033
+ * earlier registration actually observed.
2034
+ * @param agentId - Identity whose branch is recorded.
2035
+ * @param branch - Branch read before this transaction opened, or null when none was observed.
2036
+ */
2037
+ #recordBranch(agentId, branch) {
2038
+ this.#write("update agents set branch = coalesce(?, branch) where id = ?", branch, agentId);
2039
+ }
2040
+ /**
2041
+ * Record that a rejoined identity now works where its new holder does.
2042
+ *
2043
+ * The identity gives up its conversation key when the directory changes: the key names a
2044
+ * conversation in the directory it was bound in, and the holder that displaced it brings its own.
2045
+ * A `null` branch keeps the stored one only while the directory is unchanged.
2046
+ * @param agentId - Identity being rejoined.
2047
+ * @param location - Where the rejoining holder works, read before this transaction opened.
2048
+ */
2049
+ #rejoinFromLocation(agentId, location) {
2050
+ 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);
2051
+ }
2052
+ /**
2053
+ * Record the workspace and team a registration lands in.
1601
2054
  * @param workspaceId - Identity derived from the workspace path.
1602
2055
  * @param workspacePath - Absolute path the workspace identity was derived from.
1603
2056
  * @param teamId - Team the registering agent joins.
1604
2057
  * @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
2058
  */
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);
2059
+ #ensureWorkspaceAndTeam(workspaceId, workspacePath, teamId, registeredAt) {
2060
+ this.#write("insert or ignore into workspaces(id, path, created_at) values (?, ?, ?)", workspaceId, workspacePath, registeredAt);
1609
2061
  this.#write("insert or ignore into teams(id, workspace_id, created_at) values (?, ?, ?)", teamId, workspaceId, registeredAt);
1610
2062
  }
1611
2063
  #registerAgentWithoutReservation(args) {
1612
2064
  const registeredAt = this.#now();
1613
- this.#ensureWorkspaceAndTeam(args.workspaceId, args.workspacePath, args.teamId, registeredAt, args.branch);
2065
+ this.#ensureWorkspaceAndTeam(args.workspaceId, args.location.workspaceRoot, args.teamId, registeredAt);
1614
2066
  const existing = this.#db
1615
2067
  .prepare("select id, implementation_id from agents where workspace_id = ? and registration_name = ? and state = 'active'")
1616
2068
  .get(args.workspaceId, args.sessionName);
@@ -1622,11 +2074,12 @@ export class Broker {
1622
2074
  this.#assertNameAvailable(args.workspaceId, args.sessionName, agentId);
1623
2075
  this.#assertHolderDetached(agentId, args.sessionName);
1624
2076
  this.#displaceHolder(agentId);
2077
+ this.#rejoinFromLocation(agentId, args.location);
1625
2078
  return agentId;
1626
2079
  }
1627
2080
  this.#assertNameAvailable(args.workspaceId, args.sessionName);
1628
2081
  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);
2082
+ 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
2083
  return agentId;
1631
2084
  }
1632
2085
  /**
@@ -1644,15 +2097,32 @@ export class Broker {
1644
2097
  .get(holderId, this.#now());
1645
2098
  if (!live)
1646
2099
  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`);
2100
+ 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
2101
  }
1652
- #agentForSessionKey(workspaceId, implementationId, sessionKey) {
2102
+ /**
2103
+ * Find the identity a conversation registering from a directory resumes.
2104
+ *
2105
+ * The conversation's own key is looked up first. Only when it owns nothing is the key of the
2106
+ * conversation it continues tried.
2107
+ * @param workingDirectory - Absolute directory the conversation registers from.
2108
+ * @param keys - Implementation and the conversation's keys; no key means nothing is resumable.
2109
+ * @returns - The identity and whether the continued key, rather than the conversation's own, found it.
2110
+ */
2111
+ #resumableIdentity(workingDirectory, keys) {
2112
+ if (keys.sessionKey === undefined)
2113
+ return undefined;
2114
+ const owned = this.#agentForSessionKey(workingDirectory, keys.implementationId, keys.sessionKey);
2115
+ if (owned)
2116
+ return { agent: owned, byContinuedKey: false };
2117
+ if (keys.continuesSessionKey === undefined)
2118
+ return undefined;
2119
+ const continued = this.#agentForSessionKey(workingDirectory, keys.implementationId, keys.continuesSessionKey);
2120
+ return continued && { agent: continued, byContinuedKey: true };
2121
+ }
2122
+ #agentForSessionKey(workingDirectory, implementationId, sessionKey) {
1653
2123
  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);
2124
+ .prepare("select id, registration_name, registration_ephemeral from agents where working_directory = ? and implementation_id = ? and session_key = ? and state = 'active'")
2125
+ .get(workingDirectory, implementationId, sessionKey);
1656
2126
  if (!row)
1657
2127
  return undefined;
1658
2128
  return {
@@ -1683,14 +2153,14 @@ export class Broker {
1683
2153
  }
1684
2154
  #resolveSessionKeyAtAttach(reserved, sessionKey) {
1685
2155
  const reservedAgent = this.#db
1686
- .prepare("select workspace_id, implementation_id, session_key from agents where id = ?")
2156
+ .prepare("select working_directory, implementation_id, session_key from agents where id = ?")
1687
2157
  .get(reserved);
1688
2158
  if (!reservedAgent)
1689
2159
  throw new ProtocolError("unknown_recipient", "Agent identity is unknown");
1690
2160
  if (typeof reservedAgent.implementation_id !== "string") {
1691
2161
  throw new ProtocolError("validation_failed", "A session key requires a registered implementation");
1692
2162
  }
1693
- const owner = this.#agentForSessionKey(String(reservedAgent.workspace_id), reservedAgent.implementation_id, sessionKey);
2163
+ const owner = this.#agentForSessionKey(String(reservedAgent.working_directory), reservedAgent.implementation_id, sessionKey);
1694
2164
  if (!owner || owner.agentId === reserved) {
1695
2165
  this.#bindSessionKey(reserved, sessionKey, true);
1696
2166
  return reserved;
@@ -1828,19 +2298,12 @@ export class Broker {
1828
2298
  return Boolean(row);
1829
2299
  }
1830
2300
  #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
- }
2301
+ const holder = this.#aliasHolder(workspaceId, sessionName, owner);
2302
+ if (!holder)
2303
+ return;
2304
+ throw new ProtocolError("alias_conflict", holder.retired
2305
+ ? "Agent alias is a retired name that stays reserved in this workspace"
2306
+ : "Agent alias is already in use in this workspace");
1844
2307
  }
1845
2308
  #pairSendCount(from, to) {
1846
2309
  const row = this.#db
@@ -2119,10 +2582,11 @@ export class Broker {
2119
2582
  if (current)
2120
2583
  return readStoredAgentId(current.id, "agents.id");
2121
2584
  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'")
2585
+ .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
2586
  .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`);
2587
+ if (historical) {
2588
+ const current = this.#agentAddress(readStoredAgentId(historical.id, "alias_history.agent_id"), this.workspaceNames());
2589
+ throw new ProtocolError("recipient_alias_changed", `Agent alias changed to ${current}; try that address instead`);
2126
2590
  }
2127
2591
  throw new ProtocolError("unknown_recipient", "Unknown recipient");
2128
2592
  }
@@ -2142,7 +2606,7 @@ export class Broker {
2142
2606
  if (typeof alias !== "string" || alias === "")
2143
2607
  return agentId;
2144
2608
  const workspace = workspaces.get(String(row.workspace_id)) ?? String(row.workspace_id);
2145
- return `${normalizeAlias(alias)}@${workspace}`;
2609
+ return formatAgentAddress(alias, workspace);
2146
2610
  }
2147
2611
  #workspaceForName(name, names) {
2148
2612
  for (const [workspaceId, workspaceName] of names) {
@@ -2273,6 +2737,14 @@ function mutatedTable(sql) {
2273
2737
  throw new Error(`Broker write statement names no table: ${sql}`);
2274
2738
  return match[1];
2275
2739
  }
2740
+ /**
2741
+ * Read a nullable text column.
2742
+ * @param value - Column value as the driver returned it.
2743
+ * @returns - The text, or null for any other value.
2744
+ */
2745
+ function textOrNull(value) {
2746
+ return typeof value === "string" ? value : null;
2747
+ }
2276
2748
  function contentType(body) {
2277
2749
  return typeof body === "string" ? "text" : "json";
2278
2750
  }