@haywoodmarx/agent-pager 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -62,7 +62,7 @@ Pager does not change a harness's permission settings. A session that has to wai
62
62
 
63
63
  ## Service operations
64
64
 
65
- The broker starts on demand: `pager` with no command, and every harness connecting over MCP, adopts the running broker or starts one. `pager status` reports whether the broker is running, its pid, its MCP endpoint, and the database it serves. `pager exit` stops it, and `pager restart` stops it and starts a fresh one. `pager serve` runs the broker in the foreground.
65
+ The broker starts on demand: `pager` with no command, and every harness connecting over MCP, adopts the running broker or starts one. `pager status` reports whether the broker is running, its pid, its MCP endpoint, the database it serves, the installed pager version, and the version of the running broker. `pager exit` stops it, and `pager restart` stops it and starts a fresh one. `pager serve` runs the broker in the foreground.
66
66
 
67
67
  Callers adopt a running service only when its database-adjacent lock and `/health` agree on the service identity; concurrent starters race for that lock and every caller adopts the winner. The adoption rules are declared in [`src/launch/on-demand.ts`](src/launch/on-demand.ts) and [`src/launch/broker-lock.ts`](src/launch/broker-lock.ts).
68
68
 
@@ -712,6 +712,8 @@ export class Broker {
712
712
  const visited = new Set([String(current.id)]);
713
713
  while (Number(current.active) === 0) {
714
714
  const nextId = current.superseded_by;
715
+ if (nextId === null)
716
+ throw this.#sessionEndedError(current);
715
717
  if (typeof nextId !== "string" || nextId === "" || visited.has(nextId))
716
718
  throw new ProtocolError("expired", SESSION_EXPIRED_MESSAGE);
717
719
  const next = this.#db.prepare("select * from sessions where id = ?").get(nextId);
@@ -723,26 +725,42 @@ export class Broker {
723
725
  return current;
724
726
  }
725
727
  /**
726
- * Name why a known session no longer resolves, so its holder is not told it merely expired.
727
- *
728
- * An archive and a newer attachment both deactivate a session while its lease still has time
729
- * left; reporting either as an expiry sends the holder looking for a timing problem. A session
730
- * rotated to a new generation of the same attachment carries `superseded_by` and is not a
731
- * replacement by another holder.
728
+ * Name why a known session no longer resolves.
732
729
  * @param row - The session row the caller presented, inactive or past its lease.
733
- * @returns - The error to raise: the archive or the replacement when that ended it, otherwise an expiry.
730
+ * @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`.
734
732
  */
735
733
  #sessionEndedError(row) {
736
- const archived = this.#db.prepare("select archived_at from agents where id = ?").get(String(row.agent_id));
734
+ const newest = this.#newestGeneration(row);
735
+ if (Number(newest.active) === 1)
736
+ return new ProtocolError("expired", SESSION_EXPIRED_MESSAGE);
737
+ const archived = this.#db.prepare("select archived_at from agents where id = ?").get(String(newest.agent_id));
737
738
  if (typeof archived?.archived_at === "number")
738
- return new ProtocolError("expired", "MCP session ended because an operator archived this agent; re-attach required");
739
+ return new ProtocolError("ended", "MCP session ended because an operator archived this agent");
739
740
  const replaced = this.#db
740
741
  .prepare("select 1 as present from sessions where agent_id = ? and channel = ? and rowid > (select rowid from sessions where id = ?) limit 1")
741
- .get(String(row.agent_id), String(row.channel), String(row.id));
742
- if (Number(row.active) === 0 && row.superseded_by === null && replaced)
743
- return new ProtocolError("expired", "MCP session ended because a newer attachment replaced it; re-attach required");
742
+ .get(String(newest.agent_id), String(newest.channel), String(newest.id));
743
+ if (replaced)
744
+ return new ProtocolError("ended", "MCP session ended because a newer attachment replaced it");
744
745
  return new ProtocolError("expired", SESSION_EXPIRED_MESSAGE);
745
746
  }
747
+ /**
748
+ * Follow a session's rotation lineage to its newest generation, active or not.
749
+ * @param row - Any generation of the lineage.
750
+ * @returns - The last generation the lineage reaches, or the row itself when it was never rotated.
751
+ */
752
+ #newestGeneration(row) {
753
+ let current = row;
754
+ const visited = new Set([String(current.id)]);
755
+ while (typeof current.superseded_by === "string" && !visited.has(current.superseded_by)) {
756
+ const next = this.#db.prepare("select * from sessions where id = ?").get(current.superseded_by);
757
+ if (!next)
758
+ break;
759
+ visited.add(String(next.id));
760
+ current = next;
761
+ }
762
+ return current;
763
+ }
746
764
  /**
747
765
  * Return the configured lifetime of an authenticated MCP attachment.
748
766
  * @returns - Milliseconds from successful authentication until renewal is required.
@@ -188,7 +188,7 @@ export declare function lifecycleEventState(type: string): MessageState | null |
188
188
  /** Stable structured protocol error emitted at broker trust boundaries. */
189
189
  export declare class ProtocolError extends Error {
190
190
  /** Identifies the machine-readable cause of the protocol failure. */
191
- readonly code: "validation_failed" | "unsupported_version" | "oversized_content" | "unknown_recipient" | "recipient_alias_changed" | "alias_conflict" | "conflict" | "expired";
191
+ readonly code: "validation_failed" | "unsupported_version" | "oversized_content" | "unknown_recipient" | "recipient_alias_changed" | "alias_conflict" | "conflict" | "expired" | "ended";
192
192
  /**
193
193
  * Constructs an error that carries a protocol failure code.
194
194
  * @param code - Stable protocol error category exposed to callers.
@@ -196,7 +196,7 @@ export declare class ProtocolError extends Error {
196
196
  */
197
197
  constructor(
198
198
  /** Identifies the machine-readable cause of the protocol failure. */
199
- code: "validation_failed" | "unsupported_version" | "oversized_content" | "unknown_recipient" | "recipient_alias_changed" | "alias_conflict" | "conflict" | "expired", message: string);
199
+ code: "validation_failed" | "unsupported_version" | "oversized_content" | "unknown_recipient" | "recipient_alias_changed" | "alias_conflict" | "conflict" | "expired" | "ended", message: string);
200
200
  }
201
201
  /**
202
202
  * Generate a collision-resistant opaque token with a non-sensitive prefix.
@@ -24,7 +24,7 @@ try {
24
24
  if (resolution.attached) {
25
25
  let gateway;
26
26
  try {
27
- gateway = await startAttachedGateway(resolution);
27
+ gateway = await startAttachedGateway(resolution, identityPorts);
28
28
  }
29
29
  catch (error) {
30
30
  // Claude Code restarts a stdio server with the environment of the original launch, whose
@@ -34,11 +34,7 @@ try {
34
34
  if (sessionKey !== undefined) {
35
35
  try {
36
36
  const registered = await registerClaudeConversation(identityPorts, resolution.brokerUrl, sessionKey);
37
- gateway = await startAttachedGateway({
38
- ...resolution,
39
- reservation: registered.reservation,
40
- reservationOrigin: "registration",
41
- });
37
+ gateway = await startAttachedGateway({ ...resolution, reservation: registered.reservation, reservationOrigin: "registration" }, identityPorts);
42
38
  }
43
39
  catch (retryError) {
44
40
  await serveUnattached({
@@ -59,7 +55,7 @@ try {
59
55
  });
60
56
  if (brokerUrl !== undefined) {
61
57
  try {
62
- gateway = await startAttachedGateway({ ...resolution, brokerUrl });
58
+ gateway = await startAttachedGateway({ ...resolution, brokerUrl }, identityPorts);
63
59
  }
64
60
  catch {
65
61
  throw error;
@@ -104,12 +100,24 @@ async function serveUnattached(unattached) {
104
100
  /**
105
101
  * Attach to the broker with the given credentials and begin serving Claude over stdio.
106
102
  * @param identity - Endpoint, reservation, and conversation key to attach with.
103
+ * @param identityPorts - Workspace and registration boundary the gateway rejoins its identity through,
104
+ * by the conversation key alone, when the broker can no longer resume its session.
107
105
  * @returns - Running gateway; rejects, with the upstream released, when the attach is refused.
108
106
  */
109
- async function startAttachedGateway(identity) {
107
+ async function startAttachedGateway(identity, identityPorts) {
110
108
  const deliveryAddress = claudeDeliveryAddress(process.env);
109
+ const { sessionKey } = identity;
111
110
  const upstream = createClaudeGatewayUpstream(identity.brokerUrl, identity.reservation, {
112
- ...(identity.sessionKey ? { sessionKey: identity.sessionKey } : {}),
111
+ ...(sessionKey
112
+ ? {
113
+ sessionKey,
114
+ rejoin: async () => (await identityPorts.register(identity.brokerUrl, {
115
+ workspacePath: identityPorts.workspacePath,
116
+ sessionKey,
117
+ continuesSessionKey: undefined,
118
+ })).reservation,
119
+ }
120
+ : {}),
113
121
  ...(deliveryAddress.known ? { deliveryAddress: deliveryAddress.value } : {}),
114
122
  scheduler: {
115
123
  schedule: (delayMs, task) => {
@@ -26,6 +26,14 @@ export type ClaudeGatewayUpstreamOptions = {
26
26
  deliveryAddress?: string;
27
27
  /** Owns renewal timing for the lifetime of the upstream connection. */
28
28
  scheduler: ClaudeGatewayScheduler;
29
+ /**
30
+ * Register this conversation again by its key, used when the broker refuses to resume the session
31
+ * this process holds as expired; absent when nothing identifies the conversation to register.
32
+ * @returns - Single-use attachment reservation for the identity the key resolves to.
33
+ */
34
+ rejoin?: () => Promise<string>;
35
+ /** Sends one HTTP request to the broker; the global `fetch` when absent. */
36
+ fetch?: (input: string | URL, init?: RequestInit) => Promise<Response>;
29
37
  };
30
38
  /** Supplies the upstream MCP operations forwarded by the Claude stdio server. */
31
39
  export interface ClaudeGatewayUpstream {
@@ -55,9 +63,15 @@ export declare class ClaudeMcpGateway {
55
63
  }
56
64
  /**
57
65
  * Construct the SDK client that authenticates to the singleton broker over Streamable HTTP.
66
+ *
67
+ * When the broker refuses the session this process holds as `expired` and `rejoin` is configured, a
68
+ * forwarded call registers the conversation again, attaches with the reservation that returns, and is
69
+ * retried once; calls refused together share one rejoin. A call refused because this process attached
70
+ * again while it was in flight is retried on the new attachment. Any other refusal, `ended` included,
71
+ * reaches the caller unchanged. Closing waits for a rejoin in flight and detaches what it attached.
58
72
  * @param brokerUrl - Loopback MCP endpoint published by the singleton service.
59
73
  * @param reservation - Single-use attachment credential consumed during first initialization.
60
- * @param options - Conversation key presented at attach, and the renewal scheduler.
74
+ * @param options - Conversation key presented at attach, the renewal scheduler, and the rejoin.
61
75
  * @returns - Upstream tool operations for one Claude session.
62
76
  */
63
77
  export declare function createClaudeGatewayUpstream(brokerUrl: string, reservation: string, options: ClaudeGatewayUpstreamOptions): ClaudeGatewayUpstream;
@@ -2,10 +2,13 @@ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/cli
2
2
  import { McpServer } from "@modelcontextprotocol/server";
3
3
  import { LOOPBACK_HOST } from "../../configuration-defaults.js";
4
4
  import { ATTACH_HEADER } from "../../broker/protocol.js";
5
- import { compatibilityToolRequest } from "../../messaging/delivery-client.js";
5
+ import { compatibilityPayload, compatibilityToolRequest } from "../../messaging/delivery-client.js";
6
6
  import { DELIVERY_ADDRESS_HEADER } from "../../messaging/mcp.js";
7
7
  import { PAGER_VERSION } from "../../version.js";
8
8
  import { claudeUnattachedInstructions, claudeWakeInstructions } from "./instructions.js";
9
+ const CONSUMED = "conflict";
10
+ const EXPIRED = "expired";
11
+ const ENDED = "ended";
9
12
  /** Connects the Claude-facing stdio server to the singleton Streamable HTTP MCP service. */
10
13
  export class ClaudeMcpGateway {
11
14
  #upstream;
@@ -34,30 +37,40 @@ export class ClaudeMcpGateway {
34
37
  }
35
38
  /**
36
39
  * Construct the SDK client that authenticates to the singleton broker over Streamable HTTP.
40
+ *
41
+ * When the broker refuses the session this process holds as `expired` and `rejoin` is configured, a
42
+ * forwarded call registers the conversation again, attaches with the reservation that returns, and is
43
+ * retried once; calls refused together share one rejoin. A call refused because this process attached
44
+ * again while it was in flight is retried on the new attachment. Any other refusal, `ended` included,
45
+ * reaches the caller unchanged. Closing waits for a rejoin in flight and detaches what it attached.
37
46
  * @param brokerUrl - Loopback MCP endpoint published by the singleton service.
38
47
  * @param reservation - Single-use attachment credential consumed during first initialization.
39
- * @param options - Conversation key presented at attach, and the renewal scheduler.
48
+ * @param options - Conversation key presented at attach, the renewal scheduler, and the rejoin.
40
49
  * @returns - Upstream tool operations for one Claude session.
41
50
  */
42
51
  export function createClaudeGatewayUpstream(brokerUrl, reservation, options) {
43
52
  const url = validatedBrokerUrl(brokerUrl);
44
53
  if (!reservation)
45
54
  throw new Error("Claude gateway authentication is missing");
55
+ const send = options.fetch ?? fetch;
46
56
  const client = new Client({
47
57
  name: "pager-claude-gateway",
48
58
  version: PAGER_VERSION,
49
59
  });
60
+ let attachReservation = reservation;
50
61
  let session;
51
62
  let leaseTtlMs;
52
63
  let cancelRenewal;
53
64
  let closed = false;
65
+ let rejoining;
66
+ let attachments = 0;
54
67
  const scheduleRenewal = () => {
55
68
  cancelRenewal?.();
56
69
  if (closed || leaseTtlMs === undefined || leaseTtlMs <= 0)
57
70
  return;
58
71
  cancelRenewal = options.scheduler.schedule(Math.max(1, Math.floor(leaseTtlMs / 2)), async () => {
59
72
  try {
60
- await client.listTools();
73
+ await recovering(() => client.listTools());
61
74
  }
62
75
  finally {
63
76
  scheduleRenewal();
@@ -68,8 +81,7 @@ export function createClaudeGatewayUpstream(brokerUrl, reservation, options) {
68
81
  const headers = new Headers(init?.headers);
69
82
  if (session)
70
83
  headers.set("mcp-session-id", session.sessionId);
71
- if (reservation)
72
- headers.set(ATTACH_HEADER, reservation);
84
+ headers.set(ATTACH_HEADER, attachReservation);
73
85
  if (options.sessionKey)
74
86
  headers.set("x-pager-session-key", options.sessionKey);
75
87
  if (options.deliveryAddress)
@@ -80,7 +92,7 @@ export function createClaudeGatewayUpstream(brokerUrl, reservation, options) {
80
92
  headers.set("x-pager-epoch", session.epoch);
81
93
  let response;
82
94
  try {
83
- response = await fetch(input, { ...init, headers });
95
+ response = await send(input, { ...init, headers });
84
96
  }
85
97
  catch (cause) {
86
98
  throw new Error(`Cannot reach the Pager broker at ${url.href}. It is not running or not listening there; start it with \`pager\`, then retry.`, { cause });
@@ -97,6 +109,48 @@ export function createClaudeGatewayUpstream(brokerUrl, reservation, options) {
97
109
  return response;
98
110
  };
99
111
  const transport = new StreamableHTTPClientTransport(url, { fetch: brokerFetch });
112
+ const register = options.rejoin;
113
+ const rejoin = register
114
+ ? () => (rejoining = (async () => {
115
+ const refusedSession = session;
116
+ attachReservation = await register();
117
+ // An attach that presents a session would be taken as that session's, not as a new attachment.
118
+ session = undefined;
119
+ try {
120
+ await compatibilityPayload(await brokerFetch(url, compatibilityToolRequest("attach", {})), "Broker attach");
121
+ }
122
+ catch (error) {
123
+ session ??= refusedSession;
124
+ throw error;
125
+ }
126
+ attachments += 1;
127
+ })().finally(() => {
128
+ rejoining = undefined;
129
+ }))
130
+ : undefined;
131
+ const recovering = async (call) => {
132
+ await rejoining?.catch(() => undefined);
133
+ const presentedAttachment = attachments;
134
+ try {
135
+ return await call();
136
+ }
137
+ catch (error) {
138
+ const refusal = brokerErrorCode(error);
139
+ if (closed || !rejoin || (refusal !== EXPIRED && refusal !== ENDED))
140
+ throw error;
141
+ if (rejoining)
142
+ await rejoining.catch(() => undefined);
143
+ else if (attachments === presentedAttachment) {
144
+ if (refusal !== EXPIRED)
145
+ throw error;
146
+ await rejoin();
147
+ }
148
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- close() can run while the rejoin above is awaited
149
+ if (closed)
150
+ throw error;
151
+ return call();
152
+ }
153
+ };
100
154
  return {
101
155
  connect: async () => {
102
156
  await client.connect(transport);
@@ -105,6 +159,7 @@ export function createClaudeGatewayUpstream(brokerUrl, reservation, options) {
105
159
  close: async () => {
106
160
  closed = true;
107
161
  cancelRenewal?.();
162
+ await rejoining?.catch(() => undefined);
108
163
  // The model-facing tools/call path refuses connector-only tools, so this travels the
109
164
  // compatibility path. A failed notice leaves the lease to lapse, as after a crash.
110
165
  if (session) {
@@ -112,8 +167,8 @@ export function createClaudeGatewayUpstream(brokerUrl, reservation, options) {
112
167
  }
113
168
  await client.close();
114
169
  },
115
- listTools: () => client.listTools(),
116
- callTool: (params) => client.callTool(params),
170
+ listTools: () => recovering(() => client.listTools()),
171
+ callTool: (params) => recovering(() => client.callTool(params)),
117
172
  };
118
173
  }
119
174
  /**
@@ -170,13 +225,15 @@ export function rejoinKeyAfterRefusedAttach(identity, error) {
170
225
  * @returns - True only when the broker reported the reservation as consumed.
171
226
  */
172
227
  export function attachRefusedAsConsumed(error) {
228
+ return brokerErrorCode(error) === CONSUMED;
229
+ }
230
+ function brokerErrorCode(error) {
173
231
  if (typeof error !== "object" || error === null || !("data" in error))
174
- return false;
232
+ return undefined;
175
233
  const data = error.data;
176
234
  if (typeof data !== "object" || data === null || !("code" in data))
177
- return false;
178
- const code = "conflict";
179
- return data.code === code;
235
+ return undefined;
236
+ return data.code;
180
237
  }
181
238
  /**
182
239
  * Report whether an upstream connect failed because the broker named at launch could not be reached
@@ -83,6 +83,12 @@ export declare function brokerLockPath(databasePath: string): string;
83
83
  * @returns - Valid parsed lock data when the file is well-formed.
84
84
  */
85
85
  export declare function readBrokerLock(lockPath: string): BrokerLock | undefined;
86
+ /**
87
+ * Name the package version of the build that wrote a lock record.
88
+ * @param lock - Lock record, possibly written by a foreign build.
89
+ * @returns - The version after the build id's last `@`, or the whole build id when it names no version.
90
+ */
91
+ export declare function brokerLockVersion(lock: Pick<BrokerLock, "buildId">): string;
86
92
  /** Bounds how many times acquisition re-attempts after losing a lock-file race. */
87
93
  export declare const BROKER_LOCK_ACQUISITION_ATTEMPTS = 5;
88
94
  /** Reports the start time of a process, or that this platform cannot supply one. */
@@ -117,6 +117,15 @@ export function readBrokerLock(lockPath) {
117
117
  return undefined;
118
118
  }
119
119
  }
120
+ /**
121
+ * Name the package version of the build that wrote a lock record.
122
+ * @param lock - Lock record, possibly written by a foreign build.
123
+ * @returns - The version after the build id's last `@`, or the whole build id when it names no version.
124
+ */
125
+ export function brokerLockVersion(lock) {
126
+ const separator = lock.buildId.lastIndexOf("@");
127
+ return separator > 0 ? lock.buildId.slice(separator + 1) : lock.buildId;
128
+ }
120
129
  /**
121
130
  * Structural gate, not a currency check: `serviceProtocolVersion`, `runtime`, and `protocolVersion`
122
131
  * must equal the current build's because a mismatch there means the rest of the shape cannot be
@@ -10,8 +10,9 @@ import { packagedSkills } from "./skill.js";
10
10
  import { createOperatorTuiClient, fileBackedTuiLayout, DEFAULT_OPERATOR_TUI_THEME, startOperatorTui, } from "../tui/production.js";
11
11
  import { assertSupportedBunRuntime } from "./runtime.js";
12
12
  import { BrokerService } from "./service.js";
13
+ import { PAGER_VERSION } from "../version.js";
13
14
  import { systemBrokerServiceFilesystem, systemBrokerServiceScheduler } from "./service-system.js";
14
- import { BrokerLockGuard, brokerLockPath, brokerLockHasCurrentIdentity, brokerLockIsLive, processIsAlive, readBrokerLock, readBrokerOwnerPid, } from "./broker-lock.js";
15
+ import { BrokerLockGuard, brokerLockPath, brokerLockHasCurrentIdentity, brokerLockIsLive, brokerLockVersion, processIsAlive, readBrokerLock, readBrokerOwnerPid, } from "./broker-lock.js";
15
16
  /**
16
17
  * Run the operational CLI.
17
18
  *
@@ -55,6 +56,8 @@ export async function main(argv = process.argv.slice(2)) {
55
56
  state: running ? "running" : starting ? "starting" : ownerAlive ? "incompatible" : "stopped",
56
57
  ...(ownerAlive ? { pid: ownerPid } : {}),
57
58
  ...(running && lock.mcpUrl ? { mcpUrl: lock.mcpUrl } : {}),
59
+ version: PAGER_VERSION,
60
+ ...(ownerAlive && lock ? { brokerVersion: brokerLockVersion(lock) } : {}),
58
61
  dbPath,
59
62
  }));
60
63
  break;
@@ -278,7 +281,7 @@ function usage(implementations) {
278
281
  "pager open the operator TUI",
279
282
  "pager <harness> [--session-name <name>] [-- <harness arguments>]",
280
283
  ` start a harness attached to the broker (installed: ${harnesses || "none"})`,
281
- "pager status broker state, pid, MCP endpoint and database",
284
+ "pager status broker state, pid, MCP endpoint, database and versions",
282
285
  "pager exit stop the broker",
283
286
  "pager restart stop the broker and start a fresh one",
284
287
  "pager serve run the broker in the foreground",
@@ -90,6 +90,8 @@ async function handleRequest(context, request, response) {
90
90
  const call = readToolCall(body);
91
91
  // The pre-attach tools carry no session and no identity.
92
92
  const attached = call.tool === "attach" || call.tool === "register_agent" ? undefined : authenticate(broker, request);
93
+ if (attached)
94
+ announceRefreshedSession(response, attached);
93
95
  // A detaching connector is leaving, so its push address is not taken up again.
94
96
  if (attached && call.tool !== "detach")
95
97
  registerDeliveryAddress(context, request, attached.agentId);
@@ -107,7 +109,7 @@ async function handleRequest(context, request, response) {
107
109
  ...("fencingToken" in result ? { "x-pager-resume-token": String(result.fencingToken) } : {}),
108
110
  ...("agentId" in result ? { "x-pager-agent-id": String(result.agentId) } : {}),
109
111
  }
110
- : refreshedSessionHeaders(attached);
112
+ : {};
111
113
  writeJson(response, 200, { ok: true, result }, headers);
112
114
  }
113
115
  catch (error) {
@@ -159,19 +161,19 @@ function readToolCall(body) {
159
161
  return { tool, args: args ?? {} };
160
162
  }
161
163
  /**
162
- * Announce a session generation the caller does not yet hold.
163
- * @param attached - Authenticated session, carrying `refreshed` when this request resumed it in place,
164
- * or nothing for a pre-attach call that holds no session to rotate.
165
- * @returns - Rotated session headers, or an empty set when the caller's generation is still current.
164
+ * Announce a session generation the caller does not yet hold, on whichever response the request ends with.
165
+ *
166
+ * Called as soon as the request authenticates, so a request that resumed its session in place carries the
167
+ * replacement on every answer below, an error included.
168
+ * @param response - Response to the request that authenticated.
169
+ * @param attached - Authenticated session, carrying `refreshed` when this request resumed it in place.
166
170
  */
167
- function refreshedSessionHeaders(attached) {
168
- if (!attached?.refreshed)
169
- return {};
170
- return {
171
- "mcp-session-id": attached.sessionId,
172
- "x-pager-epoch": attached.epoch,
173
- "x-pager-resume-token": attached.refreshed.fencingToken,
174
- };
171
+ function announceRefreshedSession(response, attached) {
172
+ if (!attached.refreshed)
173
+ return;
174
+ response.setHeader("mcp-session-id", attached.sessionId);
175
+ response.setHeader("x-pager-epoch", attached.epoch);
176
+ response.setHeader("x-pager-resume-token", attached.refreshed.fencingToken);
175
177
  }
176
178
  function authenticate(broker, request) {
177
179
  const sessionId = String(request.headers["mcp-session-id"] ?? "");
@@ -342,32 +344,32 @@ function handleMcpRequest(context, request, response, rpc) {
342
344
  return;
343
345
  }
344
346
  if (rpc.method === "notifications/initialized") {
345
- // Authenticating this notification can itself transparently resume the session, so the 202 has to
346
- // carry the new generation. Dropping it leaves the client acknowledging initialization with
347
- // credentials the server superseded on this very request.
348
- response.writeHead(202, refreshedSessionHeaders(authenticate(broker, request)));
347
+ announceRefreshedSession(response, authenticate(broker, request));
348
+ response.writeHead(202);
349
349
  response.end();
350
350
  return;
351
351
  }
352
352
  const attached = authenticate(broker, request);
353
- // A transparent resume issues a new generation the client is still unaware of; every later response
354
- // must carry it. Computed before registerDeliveryAddress for the same reason as initialize's headers
355
- // above: a throw there must still answer with it.
356
- const refreshed = refreshedSessionHeaders(attached);
353
+ announceRefreshedSession(response, attached);
357
354
  try {
358
355
  registerDeliveryAddress(context, request, attached.agentId);
359
356
  }
360
357
  catch (error) {
361
358
  if (error instanceof ProtocolError) {
362
- writeMcpError(response, rpc.id, JSON_RPC_SERVER_ERROR, error.message, 200, refreshed, { code: error.code });
359
+ writeMcpError(response, rpc.id, JSON_RPC_SERVER_ERROR, error.message, 200, {}, { code: error.code });
363
360
  }
364
361
  else {
365
- writeMcpError(response, rpc.id, JSON_RPC_SERVER_ERROR, INTERNAL_ERROR_MESSAGE, 500, refreshed);
362
+ writeMcpError(response, rpc.id, JSON_RPC_SERVER_ERROR, INTERNAL_ERROR_MESSAGE, 500);
366
363
  }
367
364
  return;
368
365
  }
366
+ if (rpc.method.startsWith("notifications/")) {
367
+ response.writeHead(202);
368
+ response.end();
369
+ return;
370
+ }
369
371
  if (rpc.method === "tools/list") {
370
- writeMcpResult(response, rpc.id, { tools: mcpTools() }, refreshed);
372
+ writeMcpResult(response, rpc.id, { tools: mcpTools() });
371
373
  return;
372
374
  }
373
375
  if (rpc.method === "tools/call") {
@@ -377,13 +379,13 @@ function handleMcpRequest(context, request, response, rpc) {
377
379
  const args = isPlainObject(supplied) ? supplied : {};
378
380
  try {
379
381
  const result = callTool(context, attached, name, args, "model");
380
- writeMcpResult(response, rpc.id, { content: [{ type: "text", text: JSON.stringify(result) }] }, refreshed);
382
+ writeMcpResult(response, rpc.id, { content: [{ type: "text", text: JSON.stringify(result) }] });
381
383
  }
382
384
  catch (error) {
383
385
  const failure = error instanceof ProtocolError
384
386
  ? { code: error.code, message: error.message }
385
387
  : { code: INTERNAL_ERROR_CODE, message: INTERNAL_ERROR_MESSAGE };
386
- writeMcpResult(response, rpc.id, { content: [{ type: "text", text: JSON.stringify(failure) }], isError: true }, refreshed);
388
+ writeMcpResult(response, rpc.id, { content: [{ type: "text", text: JSON.stringify(failure) }], isError: true });
387
389
  }
388
390
  return;
389
391
  }
@@ -184,6 +184,7 @@ function rejectionReason(error) {
184
184
  switch (error.code) {
185
185
  case "validation_failed":
186
186
  case "expired":
187
+ case "ended":
187
188
  return "target_unavailable";
188
189
  case "conflict":
189
190
  return "conflict";
@@ -250,6 +250,8 @@ export type OperatorSnapshot = {
250
250
  protocolVersion: typeof OPERATOR_PROTOCOL_VERSION;
251
251
  /** Identifies the broker generation that produced the snapshot. */
252
252
  epoch: string;
253
+ /** Names the published package version of the broker that produced the snapshot. */
254
+ brokerVersion: string;
253
255
  /** Identifies the last event already reflected by this snapshot. */
254
256
  cursor: OperatorCursor;
255
257
  /** Records when the projection was generated. */
@@ -2,6 +2,7 @@ import { projectOperatorAgents } from "./agent-projection.js";
2
2
  import { OPERATOR_PROTOCOL_VERSION, } from "./protocol.js";
3
3
  import { operatorDiagnostic } from "./diagnostics.js";
4
4
  import { projectOperatorTraffic } from "./traffic-projection.js";
5
+ import { PAGER_VERSION } from "../version.js";
5
6
  /** Bounds retained stream-integrity diagnostics. */
6
7
  const UPDATE_DIAGNOSTIC_LIMIT = 64;
7
8
  /** Broker tables whose rows feed the repository and agent halves of an operator snapshot. */
@@ -181,6 +182,7 @@ export class OperatorUpdateService {
181
182
  return {
182
183
  protocolVersion: OPERATOR_PROTOCOL_VERSION,
183
184
  epoch: cursor.epoch,
185
+ brokerVersion: PAGER_VERSION,
184
186
  cursor,
185
187
  generatedAt: this.#now(),
186
188
  repositories: projected.repositories,
@@ -173,7 +173,16 @@ export function validateOperatorSnapshot(value) {
173
173
  const record = readRecord(value, "snapshot");
174
174
  exactFields({
175
175
  record,
176
- required: ["protocolVersion", "epoch", "cursor", "generatedAt", "repositories", "agents", "traffic"],
176
+ required: [
177
+ "protocolVersion",
178
+ "epoch",
179
+ "brokerVersion",
180
+ "cursor",
181
+ "generatedAt",
182
+ "repositories",
183
+ "agents",
184
+ "traffic",
185
+ ],
177
186
  optional: [],
178
187
  path: "snapshot",
179
188
  });
@@ -207,6 +216,7 @@ export function validateOperatorSnapshot(value) {
207
216
  const snapshot = {
208
217
  protocolVersion,
209
218
  epoch,
219
+ brokerVersion: readNonemptyString(record.brokerVersion, "snapshot.brokerVersion"),
210
220
  cursor,
211
221
  generatedAt: readTimestamp(record.generatedAt, "snapshot.generatedAt"),
212
222
  repositories,
@@ -8,6 +8,7 @@ import { Line, Overlay, PaneBar, Rule, VERTICAL_SCROLL_STYLE, useTuiModel } from
8
8
  import { AgentTrafficLog, MessageDetail, MessageOverlays, selectNewestTuiTraffic, stepTuiTrafficSelection, } from "./message-views.js";
9
9
  import { AGENT_TREE_COLUMNS, sidePaneWidths, MESSAGE_LOG_COLUMNS, PORTRAIT_BREAKPOINT_COLUMNS, agentColor, activeAgentCount, agentLogScope, brokerIndicator, COLLAPSED_LOG_ROWS, describeCommandOutcome, diagnosticColor, fitBarLeading, formatClock, formatDiagnostic, selectedMessage, selectMessageBodyRows, selectTuiTraffic, } from "./presentation.js";
10
10
  import { dimTuiTheme } from "./theme.js";
11
+ import { PAGER_VERSION } from "../version.js";
11
12
  /* eslint-enable jsdoc/require-jsdoc -- The collaborator declarations end here. */
12
13
  /** Rows the wide layout spends on fixed chrome rather than pane content. */
13
14
  const WIDE_PANE_CHROME_ROWS = 7;
@@ -274,11 +275,11 @@ function FollowFooter({ state, theme }) {
274
275
  }
275
276
  function StatusBar({ state, theme }) {
276
277
  const broker = brokerIndicator(state.connection, theme);
277
- return (_jsxs("box", { height: 1, flexShrink: 0, flexDirection: "row", paddingLeft: 1, paddingRight: 1, justifyContent: "space-between", children: [_jsx("text", { fg: theme.structure.emphasis, children: "pager" }), _jsx("box", { flexDirection: "row", children: _jsxs("text", { fg: theme.structure.rulesAndLabels, children: [_jsx("span", { fg: broker.color, children: broker.glyph }), " broker ", `${String(state.unreadMessageIds.size)} unread ${String(activeAgentCount(state))} agents`] }) })] }));
278
+ return (_jsxs("box", { height: 1, flexShrink: 0, flexDirection: "row", paddingLeft: 1, paddingRight: 1, justifyContent: "space-between", children: [_jsx("text", { fg: theme.structure.emphasis, children: "pager" }), _jsx("box", { flexDirection: "row", children: _jsxs("text", { fg: theme.structure.rulesAndLabels, children: [_jsx("span", { fg: broker.color, children: broker.glyph }), " broker ", `${String(state.unreadMessageIds.size)} unread ${String(activeAgentCount(state))} agents v${PAGER_VERSION}`] }) })] }));
278
279
  }
279
280
  function PortraitStatusBar({ state, theme }) {
280
281
  const broker = brokerIndicator(state.connection, theme);
281
- return (_jsx(PaneBar, { left: _jsx("text", { fg: theme.structure.emphasis, children: "pager" }), right: _jsxs("text", { fg: theme.structure.rulesAndLabels, children: [_jsx("span", { fg: broker.color, children: broker.glyph }), ` ${String(state.unreadMessageIds.size)} unread · ${String(activeAgentCount(state))} agents`] }) }));
282
+ return (_jsx(PaneBar, { left: _jsx("text", { fg: theme.structure.emphasis, children: "pager" }), right: _jsxs("text", { fg: theme.structure.rulesAndLabels, children: [_jsx("span", { fg: broker.color, children: broker.glyph }), ` ${String(state.unreadMessageIds.size)} unread · ${String(activeAgentCount(state))} agents · v${PAGER_VERSION}`] }) }));
282
283
  }
283
284
  const HELP_KEYS = [
284
285
  ["↑↓", "move within the focused pane"],
@@ -349,7 +349,7 @@ export class TuiOperatorModel {
349
349
  if (!this.#active(generation))
350
350
  return;
351
351
  if (this.#state.connection !== "healthy")
352
- this.reportDiagnostic("info", "connection", `connected: snapshot at sequence ${String(snapshot.cursor.sequence)}`);
352
+ this.reportDiagnostic("info", "connection", `connected: broker ${snapshot.brokerVersion}, snapshot at sequence ${String(snapshot.cursor.sequence)}`);
353
353
  // A fetched snapshot is installed as a fresh start rather than a replacement, so unread identities
354
354
  // begin empty from it.
355
355
  this.observeSnapshot(structuredClone(snapshot), false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haywoodmarx/agent-pager",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Durable local messaging between agent implementations.",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",