@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
@@ -0,0 +1,27 @@
1
+ import { existsSync } from "node:fs";
2
+ import { basename, dirname } from "node:path";
3
+ import { discoverRepositoryCommonDirectory } from "../operator/repository-system.js";
4
+ /** Bounds the repository lookup; a lookup that runs longer is treated as having failed. */
5
+ const WORKSPACE_ROOT_TIMEOUT_MS = 2_000;
6
+ /** Git directory names whose containing directory is the checkout or project they belong to. */
7
+ const REPOSITORY_DIRECTORY_NAMES = new Set([".git", ".bare"]);
8
+ /**
9
+ * Report the directory that names the workspace a working directory belongs to.
10
+ *
11
+ * Inside a Git repository that is one directory for every worktree of the repository, so the main
12
+ * worktree, every linked worktree, and every subdirectory of any of them share one workspace: the
13
+ * directory holding the repository's `.git` or `.bare` directory.
14
+ * @param path - Absolute working directory to inspect.
15
+ * @returns - Canonical directory naming the repository containing the path, or the path itself when
16
+ * it is not inside a repository, its Git directory is not named `.git` or `.bare`, or it does not exist.
17
+ * @throws {RepositoryDiscoveryError} When Git cannot answer: it is missing, failed, or did not answer
18
+ * within {@link WORKSPACE_ROOT_TIMEOUT_MS}.
19
+ */
20
+ export function systemWorkspaceRoot(path) {
21
+ if (!existsSync(path))
22
+ return path;
23
+ const commonDirectory = discoverRepositoryCommonDirectory(path, { gitTimeoutMs: WORKSPACE_ROOT_TIMEOUT_MS });
24
+ if (commonDirectory === null)
25
+ return path;
26
+ return REPOSITORY_DIRECTORY_NAMES.has(basename(commonDirectory)) ? dirname(commonDirectory) : path;
27
+ }
@@ -90,6 +90,23 @@ export declare class BrokerDeliveryClient {
90
90
  */
91
91
  call(tool: string, args: Record<string, unknown>): Promise<unknown>;
92
92
  }
93
+ /** A failure the broker answered with its own error code. */
94
+ export declare class BrokerRefusal extends Error {
95
+ /** The broker's error code, such as `expired`. */
96
+ readonly code: string;
97
+ /**
98
+ * Construct a refusal carrying the broker's message and code.
99
+ * @param message - The broker's own explanation.
100
+ * @param code - The broker's error code for the refusal.
101
+ */
102
+ constructor(message: string, code: string);
103
+ }
104
+ /**
105
+ * Report whether a failure is the broker refusing the session presented as expired.
106
+ * @param error - Rejection from a broker request.
107
+ * @returns - True only for a refusal the broker coded `expired`.
108
+ */
109
+ export declare function isExpiredRefusal(error: unknown): boolean;
93
110
  /**
94
111
  * Build one compatibility-path tool call, without session headers.
95
112
  * @param tool - Tool the broker dispatches on; connector-only tools are accepted on this path.
@@ -118,6 +118,29 @@ export class BrokerDeliveryClient {
118
118
  return compatibilityPayload(response, `Broker ${tool}`);
119
119
  }
120
120
  }
121
+ /** A failure the broker answered with its own error code. */
122
+ export class BrokerRefusal extends Error {
123
+ /** The broker's error code, such as `expired`. */
124
+ code;
125
+ /**
126
+ * Construct a refusal carrying the broker's message and code.
127
+ * @param message - The broker's own explanation.
128
+ * @param code - The broker's error code for the refusal.
129
+ */
130
+ constructor(message, code) {
131
+ super(message);
132
+ this.name = "BrokerRefusal";
133
+ this.code = code;
134
+ }
135
+ }
136
+ /**
137
+ * Report whether a failure is the broker refusing the session presented as expired.
138
+ * @param error - Rejection from a broker request.
139
+ * @returns - True only for a refusal the broker coded `expired`.
140
+ */
141
+ export function isExpiredRefusal(error) {
142
+ return error instanceof BrokerRefusal && error.code === "expired";
143
+ }
121
144
  /**
122
145
  * Build one compatibility-path tool call, without session headers.
123
146
  * @param tool - Tool the broker dispatches on; connector-only tools are accepted on this path.
@@ -227,7 +250,14 @@ async function parseEnvelopeBody(response, operation) {
227
250
  function envelopeFailure(response, payload, operation) {
228
251
  const error = asRecord(payload?.error);
229
252
  const message = typeof error?.message === "string" ? error.message : `${operation} failed (${String(response.status)})`;
230
- return new Error(message);
253
+ const code = refusalCode(error);
254
+ return code === undefined ? new Error(message) : new BrokerRefusal(message, code);
255
+ }
256
+ function refusalCode(error) {
257
+ const rpcData = asRecord(error?.data);
258
+ if (typeof rpcData?.code === "string")
259
+ return rpcData.code;
260
+ return typeof error?.code === "string" ? error.code : undefined;
231
261
  }
232
262
  /**
233
263
  * Accept an object carrying only permitted fields, rejecting anything additional.
@@ -0,0 +1,7 @@
1
+ import type { AgentIdentity } from "../broker/broker.js";
2
+ /**
3
+ * State the identity a session acts as, in the words the broker uses for it.
4
+ * @param identity - Identity as `whoami` reports it.
5
+ * @returns - Text naming the alias, workspace, address and agent id, and nothing else.
6
+ */
7
+ export declare function agentIdentityStatement(identity: AgentIdentity): string;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * State the identity a session acts as, in the words the broker uses for it.
3
+ * @param identity - Identity as `whoami` reports it.
4
+ * @returns - Text naming the alias, workspace, address and agent id, and nothing else.
5
+ */
6
+ export function agentIdentityStatement(identity) {
7
+ return `You are ${identity.alias} in workspace ${identity.workspace}, reachable from any workspace at ${identity.address}. Your agent id is ${identity.id}.`;
8
+ }
@@ -1,4 +1,4 @@
1
- import type { Broker } from "../broker/broker.js";
1
+ import type { AliasClaim, Broker } from "../broker/broker.js";
2
2
  import { type AgentId } from "../broker/protocol.js";
3
3
  import type { NotificationScheduler } from "./notifications.js";
4
4
  /** Supplies notification and push-address operations to the MCP transport. */
@@ -17,11 +17,17 @@ export type McpNotificationPort = {
17
17
  * @param agentId - Identity whose address is no longer answered.
18
18
  */
19
19
  forgetDeliveryAddress?: (agentId: AgentId) => void;
20
+ /**
21
+ * Tells the process displaced from an identity that another process took the identity over,
22
+ * at the address that process registered for it. Called after the takeover committed.
23
+ * @param claim - The identity taken and what the claiming process gave up.
24
+ */
25
+ notifySessionClaimed?: (claim: AliasClaim) => void;
20
26
  };
21
27
  /** Names the request header an attached session presents its host delivery address in. */
22
28
  export declare const DELIVERY_ADDRESS_HEADER = "x-pager-delivery-address";
23
29
  /** Broker operations the MCP server reaches. */
24
- export type McpBrokerPort = Pick<Broker, "ack" | "adoptSessionKey" | "agentIdentity" | "attach" | "broadcast" | "fetch" | "listAgents" | "listTeamMembers" | "registerAgentWithoutReservation" | "releaseAttachments" | "reserveAttachment" | "resolveSession" | "resumeSession" | "send" | "sessionLeaseTtlMs" | "status" | "updateAgentAlias">;
30
+ export type McpBrokerPort = Pick<Broker, "ack" | "adoptSessionKey" | "agentIdentity" | "attach" | "broadcast" | "fetch" | "listAgents" | "listTeamMembers" | "registerAgentWithoutReservation" | "releaseAttachments" | "relocateAgent" | "reserveAttachment" | "resolveSession" | "resumeSession" | "send" | "sessionLeaseTtlMs" | "status">;
25
31
  /** Represents a running MCP server and the operation that stops it. */
26
32
  export type McpServerHandle = {
27
33
  /** Locates the MCP endpoint the server is bound to. */
@@ -4,6 +4,7 @@ import { LOOPBACK_HOST } from "../configuration-defaults.js";
4
4
  import { closeServer, listenServer, urlHost, writeJson } from "../shared/http.js";
5
5
  import { isPlainObject } from "../shared/guards.js";
6
6
  import { assertProtocolVersion, FETCH_LIMIT_DEFAULT, FETCH_LIMIT_MAX, normalizeMessageSubject, normalizeMessageType, ATTACH_HEADER, ProtocolError, validateImplementationId, validateSessionKey, } from "../broker/protocol.js";
7
+ import { agentIdentityStatement } from "./identity-statement.js";
7
8
  import { MCP_TOOLS } from "./tools.js";
8
9
  import { PAGER_VERSION } from "../version.js";
9
10
  /**
@@ -234,7 +235,19 @@ function runTool(context, attached, tool, args, surface) {
234
235
  case "update_agent_alias":
235
236
  if (typeof args.alias !== "string")
236
237
  throw new ProtocolError("validation_failed", "Agent alias is required");
237
- return broker.updateAgentAlias(attached.agentId, args.alias);
238
+ return relocate(context, attached.agentId, { alias: args.alias, force: forceFlag(args.force) });
239
+ case "move_to_workspace":
240
+ if (typeof args.workspace !== "string" || args.workspace === "") {
241
+ throw new ProtocolError("validation_failed", "Target workspace is required");
242
+ }
243
+ if (args.alias !== undefined && typeof args.alias !== "string") {
244
+ throw new ProtocolError("validation_failed", "Agent alias must be a string");
245
+ }
246
+ return relocate(context, attached.agentId, {
247
+ workspace: args.workspace,
248
+ ...(typeof args.alias === "string" ? { alias: args.alias } : {}),
249
+ force: forceFlag(args.force),
250
+ });
238
251
  case "adopt_session_key": {
239
252
  return broker.adoptSessionKey(attached.agentId, validateSessionKey(args.sessionKey));
240
253
  }
@@ -283,6 +296,32 @@ function runTool(context, attached, tool, args, surface) {
283
296
  throw new ProtocolError("validation_failed", "Unknown tool");
284
297
  }
285
298
  }
299
+ /**
300
+ * Move or rename the caller, and tell a displaced process once the takeover is durable.
301
+ *
302
+ * The claiming process's own address is still registered under the identity it gave up; its next
303
+ * request registers it under the identity it now acts as.
304
+ * @param context - Server context carrying the broker and the optional notification port.
305
+ * @param agentId - Identity the caller authenticated as.
306
+ * @param request - Target workspace, alias, and whether to take the alias over.
307
+ * @returns - The identity the caller now acts as, as `whoami` reports it.
308
+ */
309
+ function relocate(context, agentId, request) {
310
+ const { broker, notifications } = context;
311
+ const relocation = broker.relocateAgent(agentId, request);
312
+ if (relocation.claim) {
313
+ notifications?.notifySessionClaimed?.(relocation.claim);
314
+ notifications?.forgetDeliveryAddress?.(relocation.claim.retiredId);
315
+ }
316
+ return relocation.identity;
317
+ }
318
+ function forceFlag(value) {
319
+ if (value === undefined)
320
+ return false;
321
+ if (typeof value !== "boolean")
322
+ throw new ProtocolError("validation_failed", "force must be a boolean");
323
+ return value;
324
+ }
286
325
  /**
287
326
  * Report whether a decoded body claims to be a JSON-RPC request.
288
327
  *
@@ -302,6 +341,17 @@ function isMcpRequest(value) {
302
341
  function namedParameter(params, name) {
303
342
  return isPlainObject(params) ? params[name] : undefined;
304
343
  }
344
+ /**
345
+ * Answer one JSON-RPC request on the broker's MCP endpoint.
346
+ *
347
+ * An `initialize` request attaches a new session, or resumes the one it presents, and its result's
348
+ * `instructions` is {@link agentIdentityStatement} for the identity the session now acts as, the
349
+ * same text for an attach and a resume.
350
+ * @param context - Broker, notification and diagnostics collaborators the server runs with.
351
+ * @param request - HTTP request carrying the JSON-RPC body and the session headers.
352
+ * @param response - HTTP response the result or error is written to.
353
+ * @param rpc - Decoded JSON-RPC request.
354
+ */
305
355
  function handleMcpRequest(context, request, response, rpc) {
306
356
  const { broker } = context;
307
357
  if (rpc.method === "initialize") {
@@ -324,8 +374,10 @@ function handleMcpRequest(context, request, response, rpc) {
324
374
  // The session above is already established by the time this can throw, so an internal failure here
325
375
  // must still carry its headers — otherwise a client that just attached or transparently resumed is
326
376
  // left presenting credentials the server has already superseded.
377
+ let instructions;
327
378
  try {
328
379
  registerDeliveryAddress(context, request, attached.agentId);
380
+ instructions = agentIdentityStatement(broker.agentIdentity(attached.agentId));
329
381
  }
330
382
  catch (error) {
331
383
  if (error instanceof ProtocolError) {
@@ -340,6 +392,7 @@ function handleMcpRequest(context, request, response, rpc) {
340
392
  protocolVersion: typeof declared === "string" ? declared : DEFAULT_MCP_PROTOCOL_VERSION,
341
393
  capabilities: { tools: {} },
342
394
  serverInfo: { name: "pager", version: PAGER_VERSION },
395
+ instructions,
343
396
  }, headers);
344
397
  return;
345
398
  }
@@ -43,10 +43,24 @@ export type OperatorPingChanged = InboxChangedBase & {
43
43
  /** Preserves every additional command correlation represented by a coalesced wake. */
44
44
  coalescedControlIds: string[];
45
45
  };
46
- /** Represents exactly one mutually exclusive message or operator-control wake. */
47
- export type InboxChanged = InboxMessageChanged | OperatorPingChanged;
46
+ /**
47
+ * Tells the process connected to an identity that another process took the identity over, and that
48
+ * its session has ended.
49
+ */
50
+ export type SessionClaimedChanged = InboxChangedBase & {
51
+ /** Records that the identity's alias was claimed by another process. */
52
+ reason: "session_claimed";
53
+ /** Address that was taken, as it reached the identity. */
54
+ claimedAddress: string;
55
+ /** Address the claiming process held before the claim. */
56
+ claimantAddress: string;
57
+ /** Identity the claiming process held before the claim. */
58
+ claimantId: AgentId;
59
+ };
60
+ /** Represents exactly one mutually exclusive message, operator-control, or session-claimed wake. */
61
+ export type InboxChanged = InboxMessageChanged | OperatorPingChanged | SessionClaimedChanged;
48
62
  /** Records a message-related reason that may optionally carry one durable message identity. */
49
- export type InboxMessageReason = Exclude<InboxChanged["reason"], "operator_ping">;
63
+ export type InboxMessageReason = Exclude<InboxChanged["reason"], "operator_ping" | "session_claimed">;
50
64
  /** Classifies scheduler admission without claiming adapter delivery. */
51
65
  export type OperatorPingAdmission = {
52
66
  /** Reports an exact durable retry for which no second wake was admitted. */
@@ -150,6 +164,20 @@ export declare class NotificationHub {
150
164
  * @returns - Copy of the published event; changing it does not alter what subscribers received.
151
165
  */
152
166
  publishOperatorPing(agentId: AgentId, controlId: string, coalescedControlIds?: readonly string[]): OperatorPingChanged;
167
+ /**
168
+ * Publish the one wake a process receives when another process has taken its identity over.
169
+ *
170
+ * Published to the identity's address as the displaced process registered it; the scheduler's
171
+ * budget does not apply, since nothing is offered again later.
172
+ * @param agentId - Identity that was taken over, which the displaced process still addresses.
173
+ * @param claim - Address taken, and the address and identity the claiming process held before.
174
+ * @returns - Copy of the published event; changing it does not alter what subscribers received.
175
+ */
176
+ publishSessionClaimed(agentId: AgentId, claim: {
177
+ claimedAddress: string;
178
+ claimantAddress: string;
179
+ claimantId: AgentId;
180
+ }): SessionClaimedChanged;
153
181
  /**
154
182
  * Subscribe an adapter to body-free notification events.
155
183
  * @param agentId - Agent whose operational events the listener receives.
@@ -71,6 +71,31 @@ export class NotificationHub {
71
71
  this.#fanOut(agentId, event);
72
72
  return cloneInboxChanged(event);
73
73
  }
74
+ /**
75
+ * Publish the one wake a process receives when another process has taken its identity over.
76
+ *
77
+ * Published to the identity's address as the displaced process registered it; the scheduler's
78
+ * budget does not apply, since nothing is offered again later.
79
+ * @param agentId - Identity that was taken over, which the displaced process still addresses.
80
+ * @param claim - Address taken, and the address and identity the claiming process held before.
81
+ * @returns - Copy of the published event; changing it does not alter what subscribers received.
82
+ */
83
+ publishSessionClaimed(agentId, claim) {
84
+ const sequence = (this.#sequences.get(agentId) ?? 0) + 1;
85
+ this.#sequences.set(agentId, sequence);
86
+ const event = {
87
+ type: "inbox_changed",
88
+ agentId,
89
+ sequence,
90
+ epoch: this.#epoch,
91
+ reason: "session_claimed",
92
+ claimedAddress: claim.claimedAddress,
93
+ claimantAddress: claim.claimantAddress,
94
+ claimantId: claim.claimantId,
95
+ };
96
+ this.#fanOut(agentId, event);
97
+ return cloneInboxChanged(event);
98
+ }
74
99
  /**
75
100
  * Subscribe an adapter to body-free notification events.
76
101
  * @param agentId - Agent whose operational events the listener receives.
@@ -50,7 +50,8 @@ export declare class PushDeliveryService {
50
50
  /**
51
51
  * Hold the address a session registered, and deliver anything already waiting for it.
52
52
  *
53
- * An unchanged address is a no-op.
53
+ * An unchanged address is a no-op. A session-claimed wake is the last thing written to an address,
54
+ * which is dropped once it is written.
54
55
  * @param agentId - Recipient whose session supplied the address.
55
56
  * @param raw - Untrusted address text exactly as the session supplied it.
56
57
  * @returns - What the registration changed.
@@ -36,7 +36,8 @@ export class PushDeliveryService {
36
36
  /**
37
37
  * Hold the address a session registered, and deliver anything already waiting for it.
38
38
  *
39
- * An unchanged address is a no-op.
39
+ * An unchanged address is a no-op. A session-claimed wake is the last thing written to an address,
40
+ * which is dropped once it is written.
40
41
  * @param agentId - Recipient whose session supplied the address.
41
42
  * @param raw - Untrusted address text exactly as the session supplied it.
42
43
  * @returns - What the registration changed.
@@ -67,7 +68,7 @@ export class PushDeliveryService {
67
68
  const unsubscribe = this.#dependencies.hub.subscribe(agentId, (event) => {
68
69
  this.#enqueue(agentId, event);
69
70
  });
70
- this.#registrations.set(agentId, { raw, address: parsed.value, capability, unsubscribe });
71
+ this.#registrations.set(agentId, { open: true, raw, address: parsed.value, capability, unsubscribe });
71
72
  this.#dependencies.scheduler.declareAdapter(agentId, { coalescing: true, maxProtocolVersion: 1 });
72
73
  if (this.#dependencies.broker.hasQueuedMessages(agentId))
73
74
  this.#dependencies.scheduler.notify(agentId, "resync");
@@ -78,7 +79,11 @@ export class PushDeliveryService {
78
79
  * @param agentId - Recipient whose address is no longer usable.
79
80
  */
80
81
  forget(agentId) {
81
- this.#registrations.get(agentId)?.unsubscribe();
82
+ const registration = this.#registrations.get(agentId);
83
+ if (registration) {
84
+ registration.open = false;
85
+ registration.unsubscribe();
86
+ }
82
87
  this.#registrations.delete(agentId);
83
88
  }
84
89
  /**
@@ -104,16 +109,19 @@ export class PushDeliveryService {
104
109
  }
105
110
  #enqueue(agentId, event) {
106
111
  const previous = this.#chains.get(agentId) ?? Promise.resolve();
107
- const next = previous.then(() => this.#deliver(agentId, event));
112
+ const registeredAtPublish = this.#registrations.get(agentId);
113
+ const next = previous.then(() => this.#deliver(agentId, event, registeredAtPublish));
108
114
  this.#chains.set(agentId, next);
109
115
  void next.finally(() => {
110
116
  if (this.#chains.get(agentId) === next)
111
117
  this.#chains.delete(agentId);
112
118
  });
113
119
  }
114
- async #deliver(agentId, event) {
115
- const registration = this.#registrations.get(agentId);
116
- if (!registration)
120
+ async #deliver(agentId, event, registeredAtPublish) {
121
+ // A session-claimed wake is for the process that held the identity when it was published; one that
122
+ // registers under the identity afterwards is the claimant.
123
+ const registration = event.reason === "session_claimed" ? registeredAtPublish : this.#registrations.get(agentId);
124
+ if (!registration?.open)
117
125
  return;
118
126
  // A shed wake is the scheduler recording that budget refused this one; the schedule offers the
119
127
  // inbox again later, so nothing is written now.
@@ -125,6 +133,14 @@ export class PushDeliveryService {
125
133
  await this.#write(agentId, registration, event, []);
126
134
  return;
127
135
  }
136
+ if (event.reason === "session_claimed") {
137
+ await this.#write(agentId, registration, event, []);
138
+ // The address belongs to the displaced process, which no longer acts as this identity, so
139
+ // nothing further for the identity goes there; the claiming process registers its own.
140
+ if (this.#registrations.get(agentId) === registration)
141
+ this.forget(agentId);
142
+ return;
143
+ }
128
144
  const claims = this.#dependencies.broker
129
145
  .fetch({ agentId, limit: FETCH_LIMIT_DEFAULT, claimedBy: "delivery" })
130
146
  .map(({ id, fromAddress, broadcastAddress, subject, type, body, claimToken }) => ({
@@ -171,6 +187,7 @@ export class PushDeliveryService {
171
187
  return;
172
188
  case "undelivered":
173
189
  this.#release(agentId, claims, reach.cause);
190
+ registration.open = false;
174
191
  // The session re-registers on its next request if it is still alive.
175
192
  if (this.#registrations.get(agentId) === registration)
176
193
  this.forget(agentId);
@@ -17,23 +17,43 @@ const messageContent = {
17
17
  },
18
18
  body: { description: "Message payload retained for the recipient to claim." },
19
19
  };
20
+ const forceProperty = {
21
+ type: "boolean",
22
+ description: "Take the alias over from the agent holding it, when you are certain that identity is yours: its id, alias, inbox and history become yours, it joins your workspace, or the one you move to, its session is told and ended so its next call runs as a fresh unnamed agent, and your previous alias is redirected to it.",
23
+ };
20
24
  /** Supplies the single discovery declaration consumed by every Pager MCP server. */
21
25
  export const MCP_TOOLS = [
22
26
  {
23
27
  name: "whoami",
24
- description: "Return the authenticated agent id, current alias, and prior aliases.",
28
+ description: "Return the authenticated agent id, current alias, workspace, the address that reaches it from any workspace, and prior aliases.",
25
29
  inputSchema: noArguments,
26
30
  },
27
31
  {
28
32
  name: "update_agent_alias",
29
- description: "Replace this agent's workspace-unique alias and durable session name. The alias is lowercased and inner whitespace becomes hyphens, so use the alias returned in the result rather than the one requested.",
33
+ description: "Replace this agent's workspace-unique alias and durable session name, and return the identity whoami now reports. The sanitised alias is returned in the result. Use the returned value rather than the requested one. An alias another agent holds, or holds as a retired name, fails with alias_conflict naming that agent unless force is set.",
30
34
  inputSchema: schema({
31
35
  alias: {
32
36
  type: "string",
33
37
  description: "New workspace-unique alias, with an optional leading @. Lowercased, and inner whitespace becomes hyphens.",
34
38
  },
39
+ force: forceProperty,
35
40
  }, ["alias"]),
36
41
  },
42
+ {
43
+ name: "move_to_workspace",
44
+ description: "Move this agent into another workspace, keeping its id, inbox and history, and return the identity whoami now reports. Every alias it held stays reserved in the workspace it leaves, so a send to its old address is redirected to its new one. An alias another agent in the target holds, or holds as a retired name, fails with alias_conflict naming that agent unless force is set.",
45
+ inputSchema: schema({
46
+ workspace: {
47
+ type: "string",
48
+ description: "Workspace name as list_agents reports it, or an absolute directory, which names the workspace a session started there would join and may create one.",
49
+ },
50
+ alias: {
51
+ type: "string",
52
+ description: "Alias to hold in the target workspace, replacing the current one in the same step; defaults to the current alias.",
53
+ },
54
+ force: forceProperty,
55
+ }, ["workspace"]),
56
+ },
37
57
  {
38
58
  name: "list_team_members",
39
59
  description: "List agents in this agent's own workspace.",
@@ -23,6 +23,13 @@ export function wakeMetadata(event, brokerEpoch) {
23
23
  coalesced_control_ids: JSON.stringify(event.coalescedControlIds),
24
24
  }
25
25
  : {}),
26
+ ...(event.reason === "session_claimed"
27
+ ? {
28
+ claimed_address: event.claimedAddress,
29
+ claimant_address: event.claimantAddress,
30
+ claimant_id: event.claimantId,
31
+ }
32
+ : {}),
26
33
  };
27
34
  }
28
35
  /**
@@ -35,6 +35,9 @@ export declare class OperatorAgentProjectionError extends Error {
35
35
  }
36
36
  /**
37
37
  * Project broker snapshot repositories and agents without changing broker identities.
38
+ *
39
+ * An agent groups under the repository its workspace projects to. Its worktree, and whether its
40
+ * directory still resolves, are projected from its own working directory.
38
41
  * @param snapshot - Authoritative broker state containing durable identity and attachment facts.
39
42
  * @param dependencies - Explicit repository and implementation collaborators.
40
43
  * @returns Bounded repositories and agents suitable for an operator snapshot.
@@ -59,16 +62,16 @@ export type OperatorAgentHealth = {
59
62
  /**
60
63
  * Report why an agent belongs to the operator archive rather than the active list.
61
64
  *
62
- * Archive membership is not lifecycle alone: an agent whose workspace directory has been removed
65
+ * Archive membership is not lifecycle alone: an agent whose working directory has been removed
63
66
  * can no longer be worked in, so it archives while the broker still holds it active and routable.
64
- * An operator-archived agent whose workspace is still there did not lose its workspace, so a surface
65
- * naming the cause must be told which one it is. The causes can hold together, and the first that holds
66
- * is reported, in this order: the operator's archive, then lifecycle, then a removed workspace. The
67
+ * An operator-archived agent whose directory is still there did not lose it, so a surface naming
68
+ * the cause must be told which one it is. The causes can hold together, and the first that holds
69
+ * is reported, in this order: the operator's archive, then lifecycle, then a removed directory. The
67
70
  * operator's own action comes first because it is why they see the agent archived.
68
71
  * @param agent - Projected agent whose archive cause is requested.
69
72
  * @returns The first cause that holds, or null when the agent is still active.
70
73
  */
71
- export declare function operatorArchiveReason(agent: OperatorAgent): "operator" | "lifecycle" | "workspace_removed" | null;
74
+ export declare function operatorArchiveReason(agent: OperatorAgent): "operator" | "lifecycle" | "directory_removed" | null;
72
75
  /**
73
76
  * Decide whether an agent belongs to the operator archive rather than the active list.
74
77
  * @param agent - Projected agent whose archive membership is requested.
@@ -1,12 +1,15 @@
1
1
  import { uniquePathNames } from "../broker/address.js";
2
2
  import { operatorDiagnostic } from "./diagnostics.js";
3
- import { projectRepositoryLocation } from "./repository.js";
3
+ import { projectRepositoryLocation, } from "./repository.js";
4
4
  import { OPERATOR_ARRAY_LENGTH_LIMIT, validateOperatorAgent, validateOperatorRepository } from "./validation.js";
5
5
  /** Reports an invalid or unbounded projection source. */
6
6
  export class OperatorAgentProjectionError extends Error {
7
7
  }
8
8
  /**
9
9
  * Project broker snapshot repositories and agents without changing broker identities.
10
+ *
11
+ * An agent groups under the repository its workspace projects to. Its worktree, and whether its
12
+ * directory still resolves, are projected from its own working directory.
10
13
  * @param snapshot - Authoritative broker state containing durable identity and attachment facts.
11
14
  * @param dependencies - Explicit repository and implementation collaborators.
12
15
  * @returns Bounded repositories and agents suitable for an operator snapshot.
@@ -15,6 +18,15 @@ export function projectOperatorAgents(snapshot, dependencies) {
15
18
  assertBounded(snapshot.workspaces.length, "workspace projection");
16
19
  assertBounded(snapshot.agents.length, "agent projection");
17
20
  const connections = snapshot.connections;
21
+ const locationsByPath = new Map();
22
+ const locate = (path) => {
23
+ const known = locationsByPath.get(path);
24
+ if (known)
25
+ return known;
26
+ const location = projectRepositoryLocation(path, dependencies);
27
+ locationsByPath.set(path, location);
28
+ return location;
29
+ };
18
30
  const locationsByWorkspace = new Map();
19
31
  const rootPathsById = new Map();
20
32
  const admitted = [];
@@ -22,7 +34,14 @@ export function projectOperatorAgents(snapshot, dependencies) {
22
34
  if (locationsByWorkspace.has(workspace.id)) {
23
35
  throw new OperatorAgentProjectionError(`duplicate broker workspace identity: ${workspace.id}`);
24
36
  }
25
- const location = projectRepositoryLocation(workspace.path, dependencies);
37
+ let location;
38
+ try {
39
+ location = locate(workspace.path);
40
+ }
41
+ catch (error) {
42
+ reportSkipped(dependencies, "repository", index, error);
43
+ continue;
44
+ }
26
45
  const existing = rootPathsById.get(location.repository.id);
27
46
  if (existing !== undefined && existing !== location.repository.rootPath) {
28
47
  throw new OperatorAgentProjectionError(`repository identity collision: ${location.repository.id}`);
@@ -55,6 +74,7 @@ export function projectOperatorAgents(snapshot, dependencies) {
55
74
  continue;
56
75
  }
57
76
  try {
77
+ const workingLocation = locate(agent.workingDirectory);
58
78
  agents.push(validateOperatorAgent({
59
79
  id: agent.id,
60
80
  alias: bareAlias(agent.alias),
@@ -62,10 +82,10 @@ export function projectOperatorAgents(snapshot, dependencies) {
62
82
  registeredAt: agent.createdAt,
63
83
  lifecycle: agent.state,
64
84
  archivedAt: agent.archivedAt,
65
- workspaceAvailable: location.available,
85
+ workspaceAvailable: workingLocation.available,
66
86
  workspace: workspaceNames.get(agent.workspaceId) ?? agent.workspaceId,
67
87
  brokerIdentity: { teamId: agent.teamId, workspaceId: agent.workspaceId },
68
- repository: { repositoryId: location.repository.id, worktree: location.worktree },
88
+ repository: { repositoryId: location.repository.id, worktree: workingLocation.worktree },
69
89
  adapter: adapterFromConnection(connectionsByAgent.get(agent.id)),
70
90
  implementation: projectImplementation(agent, dependencies.implementations),
71
91
  }));
@@ -114,11 +134,11 @@ function reportSkipped(dependencies, scope, index, error) {
114
134
  /**
115
135
  * Report why an agent belongs to the operator archive rather than the active list.
116
136
  *
117
- * Archive membership is not lifecycle alone: an agent whose workspace directory has been removed
137
+ * Archive membership is not lifecycle alone: an agent whose working directory has been removed
118
138
  * can no longer be worked in, so it archives while the broker still holds it active and routable.
119
- * An operator-archived agent whose workspace is still there did not lose its workspace, so a surface
120
- * naming the cause must be told which one it is. The causes can hold together, and the first that holds
121
- * is reported, in this order: the operator's archive, then lifecycle, then a removed workspace. The
139
+ * An operator-archived agent whose directory is still there did not lose it, so a surface naming
140
+ * the cause must be told which one it is. The causes can hold together, and the first that holds
141
+ * is reported, in this order: the operator's archive, then lifecycle, then a removed directory. The
122
142
  * operator's own action comes first because it is why they see the agent archived.
123
143
  * @param agent - Projected agent whose archive cause is requested.
124
144
  * @returns The first cause that holds, or null when the agent is still active.
@@ -129,7 +149,7 @@ export function operatorArchiveReason(agent) {
129
149
  if (agent.lifecycle !== "active")
130
150
  return "lifecycle";
131
151
  if (!agent.workspaceAvailable)
132
- return "workspace_removed";
152
+ return "directory_removed";
133
153
  return null;
134
154
  }
135
155
  /**
@@ -12,6 +12,8 @@ export interface OperatorCommandBrokerPort {
12
12
  acceptOperatorExpiry(commandId: string, messageId: string): BrokerOperatorCommandAcceptance;
13
13
  /** Mark an agent archived and persist command acceptance in one transaction, ending its attachments but leaving its inbox and addressability intact. */
14
14
  acceptOperatorArchive(commandId: string, targetAgentId: AgentId): BrokerOperatorCommandAcceptance;
15
+ /** Delete agents and their records, and persist command acceptance, in one transaction. */
16
+ acceptOperatorPurge(commandId: string, targetAgentIds: readonly AgentId[]): BrokerOperatorCommandAcceptance;
15
17
  }
16
18
  /** Supplies non-reentrant notification admission around durable ping acceptance. */
17
19
  export interface OperatorPingSchedulerPort {