@haywoodmarx/agent-pager 0.2.0 → 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.
package/README.md CHANGED
@@ -52,9 +52,9 @@ pager status
52
52
  pager <implementation-id> [--session-name <name>] -- <implementation-arguments>
53
53
  ```
54
54
 
55
- The bare implementation form starts or adopts the loopback service automatically. A command already running in a hosted terminal keeps the implementation in that terminal with inherited stdio. Otherwise, it prefers an available discovered terminal provider and falls back to inherited stdio; implementation exit status is propagated to the invoking shell. A session name is also the agent's routable alias and is unique within its workspace across implementations. A session's workspace is the Git repository its working directory belongs to, so the main worktree, every linked worktree, and every subdirectory of them share one; a directory outside any repository is a workspace of its own. Agents in one workspace address each other by bare alias. Supplying it again rejoins that broker identity once its holder's harness has shut down gracefully and its connector has said so, its attachment heartbeat lease has lapsed, or the holder was archived; while the holder is attached, the launch exits non-zero naming the holder and the harness does not start. Omitting it creates an ephemeral identity that is reaped once its attachment heartbeat lease lapses, or, when a conversation key makes it resumable, once it has been detached for the resumable retention window, shortened to the unused window while it has never sent a message or received one addressed to it alone. An ephemeral identity that never attached is reaped once its attach reservations have lapsed. Archiving an identity from the operator TUI ends its attachments and hides it, while messages sent to it wait; attaching again un-archives it. A named identity detached for the named archive window is archived too, and comes back when its conversation attaches or its holder resumes its attachment. Otherwise an archived identity, ephemeral or named, is reaped once the archive retention window has passed since its first archive, and reaping dead-letters its unsettled mail. The first sweep after an upgrade reaps every identity already archived for longer than that window.
55
+ The bare implementation form starts or adopts the loopback service automatically. A command already running in a hosted terminal keeps the implementation in that terminal with inherited stdio. Otherwise, it prefers an available discovered terminal provider and falls back to inherited stdio; implementation exit status is propagated to the invoking shell. A session name is also the agent's routable alias and is unique within its workspace across implementations. A session's workspace is the Git repository its working directory belongs to, so the main worktree, every linked worktree, and every subdirectory of them share one; a directory outside any repository is a workspace of its own. Agents in one workspace address each other by bare alias. Supplying it again rejoins that broker identity once its holder's harness has shut down gracefully and its connector has said so, its attachment heartbeat lease has lapsed, or the holder was archived; while the holder is attached, the launch exits non-zero naming the holder and the harness does not start. Omitting it creates an ephemeral identity that is reaped once its attachment heartbeat lease lapses, or, when a conversation key makes it resumable, once it has been detached for the resumable retention window, shortened to the unused window while it has never sent a message or received one addressed to it alone. An ephemeral identity that never attached is reaped once its attach reservations have lapsed. Archiving an identity from the operator TUI ends its attachments and hides it, while messages sent to it wait; attaching again un-archives it. Purging an identity from the operator TUI, on its own or with others in its workspace's archive section, deletes it and ends any session still attached to it; a message it exchanged with an identity that remains stays in that identity's history, and any still waiting in its inbox is dead-lettered. A named identity detached for the named archive window is archived too, and comes back when its conversation attaches or its holder resumes its attachment. Otherwise an archived identity, ephemeral or named, is reaped once the archive retention window has passed since its first archive, and reaping dead-letters its unsettled mail. The first sweep after an upgrade reaps every identity already archived for longer than that window.
56
56
 
57
- The authenticated `whoami` MCP tool returns the caller's identity and the address that reaches it from any workspace. [`src/messaging/tools.ts`](src/messaging/tools.ts) describes what each tool returns. `update_agent_alias` atomically replaces the current alias and durable session name; assigning one to an unnamed agent makes that identity durable. `move_to_workspace` moves an agent into another workspace, keeping its identity and inbox. Prior names and addresses remain reserved. Sending to one is rejected with `recipient_alias_changed` and the current address rather than silently redirecting the message. An alias another agent holds is taken over only with `force`, which hands that agent's identity to the caller, tells its session and ends it, so that session's next call runs as a fresh unnamed agent.
57
+ The authenticated `whoami` MCP tool returns the caller's identity and the address that reaches it from any workspace. [`src/messaging/tools.ts`](src/messaging/tools.ts) describes what each tool returns. `update_agent_alias` atomically replaces the current alias and durable session name; assigning one to an unnamed agent makes that identity durable. `move_to_workspace` moves an agent into another workspace, keeping its identity and inbox. Prior names and addresses remain reserved until the identity is purged. Sending to one is rejected with `recipient_alias_changed` and the current address rather than silently redirecting the message. An alias another agent holds is taken over only with `force`, which hands that agent's identity to the caller, tells its session and ends it, so that session's next call runs as a fresh unnamed agent.
58
58
 
59
59
  The package also exposes its Pi integration as a Pi package. `pi install npm:@haywoodmarx/agent-pager` enables directly started Pi sessions to discover or start the broker and register themselves; `pager pi -- <pi-arguments>` loads the same extension explicitly for a wrapper-managed session.
60
60
 
@@ -616,7 +616,8 @@ export declare class Broker {
616
616
  operationalEvents(): BrokerOperationalEvent[];
617
617
  /**
618
618
  * Append an operational event recording the outcome of a delivery notification attempt.
619
- * @param messageId - Message the notification outcome is recorded against.
619
+ * @param messageId - Message the notification outcome is recorded against; an outcome for a
620
+ * message deleted while its notification was in flight is dropped.
620
621
  * @param outcome - Observed notification result recorded for diagnostics.
621
622
  */
622
623
  recordNotification(messageId: string, outcome: NotificationOutcome): void;
@@ -672,6 +673,25 @@ export declare class Broker {
672
673
  * @returns - Durable acceptance, reporting whether this call created it.
673
674
  */
674
675
  acceptOperatorArchive(commandId: string, agentId: AgentId): BrokerOperatorCommandAcceptance;
676
+ /**
677
+ * Accept one operator purge request, deleting the named identities and their records.
678
+ *
679
+ * A message is deleted with its events and claims only when neither end is an identity that
680
+ * outlives the purge. A message exchanged with one that does stays in that identity's history,
681
+ * naming the purged end by its raw id, and is dead-lettered if it was still waiting in a purged
682
+ * inbox. Operator command records keep the ids they targeted, so a retried command is still
683
+ * answered from its record, and the identity's sessions are kept ended, so a process still
684
+ * attached to it is told it was ended. Claims a purged identity made on a kept message stay as
685
+ * that message's delivery attempts, and operator stream events already published about the
686
+ * identity stay in the replay journal until it trims them. Every other row naming a purged identity
687
+ * is deleted.
688
+ * Workspace rows are kept even when no identity remains in them, because previous addresses
689
+ * into a workspace and the short names of other workspaces are resolved from them.
690
+ * @param commandId - Correlates retries of the same operator request.
691
+ * @param agentIds - Distinct agents the operator asked to purge, in any lifecycle state.
692
+ * @returns - Durable acceptance, reporting whether this call created it.
693
+ */
694
+ acceptOperatorPurge(commandId: string, agentIds: readonly AgentId[]): BrokerOperatorCommandAcceptance;
675
695
  /**
676
696
  * Returns the broker snapshot, optionally scoped to one workspace.
677
697
  *
@@ -6,7 +6,7 @@ import { PAGER_DEFAULTS } from "../configuration-defaults.js";
6
6
  import { publicEpoch } from "./snapshot.js";
7
7
  import { formatAgentAddress, normalizeAlias, parseAgentAddress, parseWorkspaceName, uniquePathNames, validateRegistrationSessionName, workspaceIdentity, workspaceTeamId, } from "./address.js";
8
8
  import { ATTACHMENT_CHANNELS, BrokerSchema, MESSAGE_STATES, } from "./schema.js";
9
- import { assertDurableOperatorId, readStoredAgentId, readStoredEnum, BrokerOperatorProjection, } from "./operator-projection.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" },
@@ -842,15 +842,20 @@ export class Broker {
842
842
  const settled = this.#db
843
843
  .prepare("select id from messages where state in ('acknowledged', 'expired', 'dead_lettered', 'failed') and created_at <= ?")
844
844
  .all(cutoff);
845
- for (const row of settled) {
846
- const messageId = String(row.id);
847
- this.#write("delete from message_events where message_id = ?", messageId);
848
- this.#write("delete from claims where message_id = ?", messageId);
849
- this.#write("delete from idempotency where message_id = ?", messageId);
850
- this.#write("delete from messages where id = ?", messageId);
851
- }
845
+ for (const row of settled)
846
+ this.#deleteMessage(String(row.id));
852
847
  });
853
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
+ }
854
859
  /**
855
860
  * Resume an MCP attachment while rotating a lapsed or prior-epoch session generation.
856
861
  * @param request - Session, fencing proof, and last-seen generation the caller resumes with.
@@ -941,8 +946,8 @@ export class Broker {
941
946
  * Name why a known session no longer resolves.
942
947
  * @param row - The session row the caller presented, inactive or past its lease.
943
948
  * @returns - `ended` when the newest generation of the row's rotation lineage was deactivated by an
944
- * operator archive or by a newer attachment of the same identity and channel, otherwise `expired`,
945
- * which includes a lineage whose end cause is a claim of its identity.
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.
946
951
  */
947
952
  #sessionEndedError(row) {
948
953
  const newest = this.#newestGeneration(row);
@@ -950,6 +955,8 @@ export class Broker {
950
955
  return new ProtocolError("expired", SESSION_EXPIRED_MESSAGE);
951
956
  if (newest.end_cause === "claimed")
952
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");
953
960
  const archived = this.#db.prepare("select archived_at from agents where id = ?").get(String(newest.agent_id));
954
961
  if (typeof archived?.archived_at === "number")
955
962
  return new ProtocolError("ended", "MCP session ended because an operator archived this agent");
@@ -1533,12 +1540,15 @@ export class Broker {
1533
1540
  }
1534
1541
  /**
1535
1542
  * Append an operational event recording the outcome of a delivery notification attempt.
1536
- * @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.
1537
1545
  * @param outcome - Observed notification result recorded for diagnostics.
1538
1546
  */
1539
1547
  recordNotification(messageId, outcome) {
1540
1548
  const display = notificationDisplay[outcome];
1541
1549
  this.#transaction(() => {
1550
+ if (this.#db.prepare("select 1 as present from messages where id = ?").get(messageId) === undefined)
1551
+ return;
1542
1552
  this.#appendEvent(messageId, "notify", `notify_${outcome}`, display.text, display.tone);
1543
1553
  });
1544
1554
  }
@@ -1660,6 +1670,86 @@ export class Broker {
1660
1670
  });
1661
1671
  return acceptance;
1662
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
+ }
1663
1753
  /**
1664
1754
  * Returns the broker snapshot, optionally scoped to one workspace.
1665
1755
  *
@@ -187,6 +187,15 @@ export type BrokerOperatorCommandRecord = {
187
187
  targetId: string;
188
188
  /** Records broker acceptance time. */
189
189
  acceptedAt: number;
190
+ } | {
191
+ /** Identifies an agent-purge request. */
192
+ type: "purge_agents";
193
+ /** Correlates retries of the same request. */
194
+ commandId: string;
195
+ /** Identifies the purged agents, in the order the request named them. */
196
+ targetIds: readonly AgentId[];
197
+ /** Records broker acceptance time. */
198
+ acceptedAt: number;
190
199
  };
191
200
  /** Distinguishes a newly committed command from an exact durable retry. */
192
201
  export type BrokerOperatorCommandAcceptance = {
@@ -219,6 +228,19 @@ export declare function readStoredAgentId(value: unknown, column: string): Agent
219
228
  * @returns - The value, narrowed to a validated durable identity string.
220
229
  */
221
230
  export declare function assertDurableOperatorId(value: unknown, label: string): string;
231
+ /**
232
+ * Encode the agents one purge names into the single `operator_commands.target_id` column.
233
+ * @param agentIds - Purged agents, in the order the request named them.
234
+ * @returns - Stored text that `decodePurgeTargets` reads back to the same list.
235
+ */
236
+ export declare function encodePurgeTargets(agentIds: readonly AgentId[]): string;
237
+ /**
238
+ * Report whether two purges name the same agents in the same order, as a retry of one must.
239
+ * @param left - Targets in the order one request listed them; order is significant.
240
+ * @param right - Targets in the order the other request listed them.
241
+ * @returns - Whether a command id recorded for one purge answers the other.
242
+ */
243
+ export declare function samePurgeTargets(left: readonly AgentId[], right: readonly AgentId[]): boolean;
222
244
  /**
223
245
  * Reads durable operator-facing state over a broker connection: traffic records, command
224
246
  * idempotency, and snapshot projections. Holds the in-memory operational event log, which only this
@@ -190,6 +190,38 @@ function assertDurableAgentId(value) {
190
190
  if (!isAgentId(value))
191
191
  throw new ProtocolError("validation_failed", "Operator command target is corrupt");
192
192
  }
193
+ /**
194
+ * Encode the agents one purge names into the single `operator_commands.target_id` column.
195
+ * @param agentIds - Purged agents, in the order the request named them.
196
+ * @returns - Stored text that `decodePurgeTargets` reads back to the same list.
197
+ */
198
+ export function encodePurgeTargets(agentIds) {
199
+ return JSON.stringify(agentIds);
200
+ }
201
+ /**
202
+ * Report whether two purges name the same agents in the same order, as a retry of one must.
203
+ * @param left - Targets in the order one request listed them; order is significant.
204
+ * @param right - Targets in the order the other request listed them.
205
+ * @returns - Whether a command id recorded for one purge answers the other.
206
+ */
207
+ export function samePurgeTargets(left, right) {
208
+ return left.length === right.length && left.every((agentId, index) => agentId === right[index]);
209
+ }
210
+ function decodePurgeTargets(value) {
211
+ let decoded;
212
+ try {
213
+ decoded = typeof value === "string" ? JSON.parse(value) : null;
214
+ }
215
+ catch {
216
+ decoded = null;
217
+ }
218
+ if (!Array.isArray(decoded) || decoded.length === 0)
219
+ throw new ProtocolError("validation_failed", "Operator command target is corrupt");
220
+ return decoded.map((entry) => {
221
+ assertDurableAgentId(entry);
222
+ return entry;
223
+ });
224
+ }
193
225
  /**
194
226
  * Reads durable operator-facing state over a broker connection: traffic records, command
195
227
  * idempotency, and snapshot projections. Holds the in-memory operational event log, which only this
@@ -393,6 +425,14 @@ export class BrokerOperatorProjection {
393
425
  assertDurableAgentId(row.target_id);
394
426
  return { type: "archive_agent", commandId: String(row.command_id), targetId: row.target_id, acceptedAt };
395
427
  }
428
+ if (row.type === "purge_agents") {
429
+ return {
430
+ type: "purge_agents",
431
+ commandId: String(row.command_id),
432
+ targetIds: decodePurgeTargets(row.target_id),
433
+ acceptedAt,
434
+ };
435
+ }
396
436
  if (row.type !== "expire_message") {
397
437
  throw new ProtocolError("validation_failed", "Operator command is corrupt");
398
438
  }
@@ -4,7 +4,7 @@
4
4
  * Declared here rather than beside the schema it names, because the service lock compares it and is
5
5
  * loaded by adapters running inside hosts that have no `bun:sqlite`.
6
6
  */
7
- export declare const SCHEMA_MARKER = "agent-session-key+message-event-index+queryable-timestamps+no-body-absence-reason+message-type+session-resume-lineage+session-supersession-timestamp+message-type-required+single-delivery-state-home+checked-stored-columns+unorderable-position-index+orderable-position-predicate+no-occupancy-contract+single-session-encoding+no-reservation-bindings+reservation-id-column+repository-workspace+session-end-cause";
7
+ export declare const SCHEMA_MARKER = "agent-session-key+message-event-index+queryable-timestamps+no-body-absence-reason+message-type+session-resume-lineage+session-supersession-timestamp+message-type-required+single-delivery-state-home+checked-stored-columns+unorderable-position-index+orderable-position-predicate+no-occupancy-contract+single-session-encoding+no-reservation-bindings+reservation-id-column+repository-workspace+session-end-cause+operator-purge";
8
8
  /**
9
9
  * Identifies the only protocol version accepted by this process.
10
10
  *
@@ -5,7 +5,7 @@ import { randomBytes, randomUUID } from "node:crypto";
5
5
  * Declared here rather than beside the schema it names, because the service lock compares it and is
6
6
  * loaded by adapters running inside hosts that have no `bun:sqlite`.
7
7
  */
8
- export const SCHEMA_MARKER = "agent-session-key+message-event-index+queryable-timestamps+no-body-absence-reason+message-type+session-resume-lineage+session-supersession-timestamp+message-type-required+single-delivery-state-home+checked-stored-columns+unorderable-position-index+orderable-position-predicate+no-occupancy-contract+single-session-encoding+no-reservation-bindings+reservation-id-column+repository-workspace+session-end-cause";
8
+ export const SCHEMA_MARKER = "agent-session-key+message-event-index+queryable-timestamps+no-body-absence-reason+message-type+session-resume-lineage+session-supersession-timestamp+message-type-required+single-delivery-state-home+checked-stored-columns+unorderable-position-index+orderable-position-predicate+no-occupancy-contract+single-session-encoding+no-reservation-bindings+reservation-id-column+repository-workspace+session-end-cause+operator-purge";
9
9
  /**
10
10
  * Identifies the only protocol version accepted by this process.
11
11
  *
@@ -35,9 +35,9 @@ export declare const MESSAGE_STATES: readonly MessageState[];
35
35
  /** Names one lifecycle state of an agent; `AGENT_STATES` lists them. */
36
36
  export type AgentLifecycleState = "active" | "terminal" | "reaped";
37
37
  /** Names why a session was ended on purpose rather than lapsing; `SESSION_END_CAUSES` lists them. */
38
- export type SessionEndCause = "claimed";
38
+ export type SessionEndCause = "claimed" | "purged";
39
39
  /** Every cause `sessions.end_cause` may hold. */
40
- export declare const SESSION_END_CAUSES: readonly "claimed"[];
40
+ export declare const SESSION_END_CAUSES: readonly SessionEndCause[];
41
41
  /** Every lifecycle state `agents.state` may hold. */
42
42
  export declare const AGENT_STATES: readonly AgentLifecycleState[];
43
43
  /** Every channel a reservation or session may occupy. */
@@ -74,7 +74,7 @@ export const MESSAGE_STATES = vocabulary({
74
74
  failed: true,
75
75
  });
76
76
  /** Every cause `sessions.end_cause` may hold. */
77
- export const SESSION_END_CAUSES = vocabulary({ claimed: true });
77
+ export const SESSION_END_CAUSES = vocabulary({ claimed: true, purged: true });
78
78
  /** Every lifecycle state `agents.state` may hold. */
79
79
  export const AGENT_STATES = vocabulary({
80
80
  active: true,
@@ -304,7 +304,7 @@ export class BrokerSchema {
304
304
  [
305
305
  "operator_commands",
306
306
  commandSql,
307
- "typein('ping_agent','expire_message','archive_agent')",
307
+ "typein('ping_agent','expire_message','archive_agent','purge_agents')",
308
308
  "does not constrain its command types",
309
309
  ],
310
310
  [
@@ -411,20 +411,29 @@ export class BrokerSchema {
411
411
  create table if not exists agents(id text primary key, team_id text not null, workspace_id text not null, working_directory text not null, branch text, parent_id text, state text not null ${vocabularyClause("state", AGENT_STATES)}, created_at integer not null, implementation_id text, implementation_version text, registration_name text, registration_ephemeral integer not null default 0, session_key text, archived_at integer, check(implementation_id is not null or implementation_version is null));
412
412
  create unique index if not exists agent_registration_name on agents(workspace_id, registration_name) where registration_name is not null and state != 'reaped';
413
413
  create unique index if not exists agent_session_key on agents(working_directory, implementation_id, session_key) where session_key is not null and state = 'active';
414
+ create index if not exists agent_parent on agents(parent_id) where parent_id is not null;
414
415
  create table if not exists alias_history(id integer primary key autoincrement, workspace_id text not null, alias text not null, agent_id text not null, unique(workspace_id, alias));
416
+ create index if not exists alias_history_agent on alias_history(agent_id);
415
417
  create table if not exists reservations(reservation_id text primary key, agent_id text not null, channel text not null ${vocabularyClause("channel", ATTACHMENT_CHANNELS)}, expires_at integer not null, consumed_at integer);
418
+ create index if not exists reservation_agent on reservations(agent_id);
416
419
  create table if not exists sessions(id text primary key, agent_id text not null, channel text not null ${vocabularyClause("channel", ATTACHMENT_CHANNELS)}, fencing_token text not null, expires_at integer not null, active integer not null, created_at integer not null, superseded_by text, epoch text not null, superseded_at integer, end_cause text ${vocabularyClause("end_cause", SESSION_END_CAUSES)});
420
+ create index if not exists session_agent on sessions(agent_id);
417
421
  create table if not exists traffic_positions(id integer primary key autoincrement);
418
422
  create table if not exists messages(id text primary key, team_id text not null, workspace_id text not null, sender_id text not null, recipient_id text not null, addressed_to text not null, address_outcome text not null, subject text not null, type text not null, body text, body_size integer, content_type text, state text not null ${vocabularyClause("state", MESSAGE_STATES)}, created_at integer not null, sequence integer not null, claim_token text, claim_expires_at integer);
419
423
  create unique index if not exists message_traffic_position on messages(sequence);
424
+ create index if not exists message_sender on messages(sender_id);
425
+ create index if not exists message_recipient on messages(recipient_id);
420
426
  create table if not exists message_events(id integer primary key autoincrement, message_id text not null, at integer not null, source text not null ${vocabularyClause("source", MESSAGE_EVENT_SOURCES)}, type text not null, text text not null, tone text not null ${vocabularyClause("tone", MESSAGE_EVENT_TONES)});
421
427
  create index if not exists message_events_message on message_events(message_id, id);
422
428
  create table if not exists claims(message_id text not null, token text not null, agent_id text not null, expires_at integer not null, created_at integer not null, primary key(message_id, token));
423
429
  create unique index if not exists claim_token on claims(token);
424
430
  create table if not exists wakes(agent_id text primary key, attempts integer not null, next_wake_at integer not null);
425
431
  create table if not exists idempotency(agent_id text not null, recipient_id text not null, key text not null, message_id text not null, created_at integer not null, primary key(agent_id, recipient_id, key));
426
- create table if not exists operator_commands(command_id text primary key, type text not null check(type in ('ping_agent','expire_message','archive_agent')), target_id text not null, accepted_at integer not null, control_id text unique, check((type = 'ping_agent' and control_id is not null) or (type != 'ping_agent' and control_id is null)));
432
+ create index if not exists idempotency_recipient on idempotency(recipient_id);
433
+ create index if not exists idempotency_message on idempotency(message_id);
434
+ create table if not exists operator_commands(command_id text primary key, type text not null check(type in ('ping_agent','expire_message','archive_agent','purge_agents')), target_id text not null, accepted_at integer not null, control_id text unique, check((type = 'ping_agent' and control_id is not null) or (type != 'ping_agent' and control_id is null)));
427
435
  create table if not exists operator_ping_audits(audit_id text primary key, command_id text not null unique references operator_commands(command_id), control_id text not null unique, target_agent_id text not null, actor text not null check(actor = 'operator'), accepted_at integer not null, traffic_position integer not null unique);
436
+ create index if not exists operator_ping_audit_target on operator_ping_audits(target_agent_id);
428
437
  create table if not exists operator_stream_state(singleton integer primary key check(singleton = 1), epoch text not null, sequence integer not null check(sequence >= 0));
429
438
  create table if not exists operator_stream_events(epoch text not null, sequence integer not null check(sequence > 0), payload text not null, unique(epoch, sequence));
430
439
  `);
@@ -22,6 +22,6 @@ export { PushDeliveryService, type DeliveryAddressRegistration, type PushDeliver
22
22
  export { MCP_TOOLS, type McpToolDefinition } from "./messaging/tools.js";
23
23
  export { PAGER_WAKE_TYPE, wakeLine, wakeMetadata } from "./messaging/wake.js";
24
24
  export { classifyOperatorEvent, validateOperatorAgent, validateOperatorCommand, validateOperatorCommandResult, validateOperatorCommandResultForCommand, validateOperatorControlId, validateOperatorCursor, validateOperatorDiagnostic, validateOperatorEventEnvelope, validateOperatorMessageBody, validateOperatorRepository, validateOperatorSnapshot, validateOperatorSnapshotByteLength, validateOperatorTraffic, validateOperatorTransportRequest, OperatorValidationError, OPERATOR_ARRAY_LENGTH_LIMIT, } from "./operator/validation.js";
25
- export { OPERATOR_DIAGNOSTIC_TEXT_LIMIT, OPERATOR_PROTOCOL_VERSION, OPERATOR_REJECTION_REASONS, OPERATOR_SNAPSHOT_CHUNK_BYTES, OPERATOR_SNAPSHOT_TOTAL_LIMIT_BYTES, OPERATOR_TRANSPORT_FRAME_LIMIT_BYTES, type OperatorAcceptedDelivery, type OperatorAdapterState, type OperatorAgent, type OperatorCommand, type OperatorCommandEnvelope, type OperatorCommandEnvelopeFor, type OperatorCommandResult, type OperatorCorrelatedCommandResult, type OperatorCursor, type OperatorCursorResult, type OperatorDeliveryLifecycleEntry, type OperatorDiagnostic, type OperatorDiagnosticSeverity, type OperatorEventEnvelope, type OperatorImplementation, type OperatorImplementationVersion, type OperatorJsonObject, type OperatorJsonValue, type OperatorMessageBody, type OperatorPingAudit, type OperatorRejectionReason, type OperatorRepository, type OperatorResyncReason, type OperatorSnapshot, type OperatorStateEvent, type OperatorTraffic, type OperatorTransportFrame, type OperatorTransportRequest, type OperatorWorktree, } from "./operator/protocol.js";
25
+ export { OPERATOR_DIAGNOSTIC_TEXT_LIMIT, OPERATOR_PROTOCOL_VERSION, OPERATOR_PURGE_TARGET_LIMIT, OPERATOR_REJECTION_REASONS, OPERATOR_SNAPSHOT_CHUNK_BYTES, OPERATOR_SNAPSHOT_TOTAL_LIMIT_BYTES, OPERATOR_TRANSPORT_FRAME_LIMIT_BYTES, OPERATOR_TRANSPORT_REQUEST_LIMIT_BYTES, type OperatorAcceptedDelivery, type OperatorAdapterState, type OperatorAgent, type OperatorCommand, type OperatorCommandEnvelope, type OperatorCommandEnvelopeFor, type OperatorCommandResult, type OperatorCorrelatedCommandResult, type OperatorCursor, type OperatorCursorResult, type OperatorDeliveryLifecycleEntry, type OperatorDiagnostic, type OperatorDiagnosticSeverity, type OperatorEventEnvelope, type OperatorImplementation, type OperatorImplementationVersion, type OperatorJsonObject, type OperatorJsonValue, type OperatorMessageBody, type OperatorPingAudit, type OperatorRejectionReason, type OperatorRepository, type OperatorResyncReason, type OperatorSnapshot, type OperatorStateEvent, type OperatorTraffic, type OperatorTransportFrame, type OperatorTransportRequest, type OperatorWorktree, } from "./operator/protocol.js";
26
26
  export { PAGER_VERSION } from "./version.js";
27
27
  export { shellQuote } from "./shared/shell.js";
package/dist/src/index.js CHANGED
@@ -21,6 +21,6 @@ export { PushDeliveryService, } from "./messaging/push-delivery.js";
21
21
  export { MCP_TOOLS } from "./messaging/tools.js";
22
22
  export { PAGER_WAKE_TYPE, wakeLine, wakeMetadata } from "./messaging/wake.js";
23
23
  export { classifyOperatorEvent, validateOperatorAgent, validateOperatorCommand, validateOperatorCommandResult, validateOperatorCommandResultForCommand, validateOperatorControlId, validateOperatorCursor, validateOperatorDiagnostic, validateOperatorEventEnvelope, validateOperatorMessageBody, validateOperatorRepository, validateOperatorSnapshot, validateOperatorSnapshotByteLength, validateOperatorTraffic, validateOperatorTransportRequest, OperatorValidationError, OPERATOR_ARRAY_LENGTH_LIMIT, } from "./operator/validation.js";
24
- export { OPERATOR_DIAGNOSTIC_TEXT_LIMIT, OPERATOR_PROTOCOL_VERSION, OPERATOR_REJECTION_REASONS, OPERATOR_SNAPSHOT_CHUNK_BYTES, OPERATOR_SNAPSHOT_TOTAL_LIMIT_BYTES, OPERATOR_TRANSPORT_FRAME_LIMIT_BYTES, } from "./operator/protocol.js";
24
+ export { OPERATOR_DIAGNOSTIC_TEXT_LIMIT, OPERATOR_PROTOCOL_VERSION, OPERATOR_PURGE_TARGET_LIMIT, OPERATOR_REJECTION_REASONS, OPERATOR_SNAPSHOT_CHUNK_BYTES, OPERATOR_SNAPSHOT_TOTAL_LIMIT_BYTES, OPERATOR_TRANSPORT_FRAME_LIMIT_BYTES, OPERATOR_TRANSPORT_REQUEST_LIMIT_BYTES, } from "./operator/protocol.js";
25
25
  export { PAGER_VERSION } from "./version.js";
26
26
  export { shellQuote } from "./shared/shell.js";
@@ -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 {
@@ -1,3 +1,4 @@
1
+ import { samePurgeTargets } from "../broker/operator-projection.js";
1
2
  import { ProtocolError } from "../broker/protocol.js";
2
3
  import { OPERATOR_PROTOCOL_VERSION, } from "./protocol.js";
3
4
  import { validateOperatorCommand, validateOperatorCommandResult, validateOperatorControlId } from "./validation.js";
@@ -37,6 +38,8 @@ export class OperatorCommandService {
37
38
  return this.#expire({ ...command, command: command.command });
38
39
  case "archive_agent":
39
40
  return this.#archive({ ...command, command: command.command });
41
+ case "purge_agents":
42
+ return this.#purge({ ...command, command: command.command });
40
43
  }
41
44
  }
42
45
  #ping(command) {
@@ -90,31 +93,16 @@ export class OperatorCommandService {
90
93
  }
91
94
  }
92
95
  #archive(command) {
93
- try {
94
- const acceptance = this.#broker.acceptOperatorArchive(command.commandId, command.command.targetAgentId);
95
- const accepted = acceptance.record;
96
- if (accepted.type !== "archive_agent" || accepted.commandId !== command.commandId) {
97
- throw new Error("Broker returned a mismatched archive acceptance");
98
- }
99
- return validateOperatorCommandResult({
100
- protocolVersion: OPERATOR_PROTOCOL_VERSION,
101
- type: "archive_accepted",
102
- status: "accepted",
103
- commandId: command.commandId,
104
- acceptedAt: accepted.acceptedAt,
105
- });
106
- }
107
- catch (error) {
108
- return rejected(command.commandId, rejectionReason(error));
109
- }
96
+ return agentCommandResult(command, () => this.#broker.acceptOperatorArchive(command.commandId, command.command.targetAgentId));
97
+ }
98
+ #purge(command) {
99
+ return agentCommandResult(command, () => this.#broker.acceptOperatorPurge(command.commandId, command.command.targetAgentIds));
110
100
  }
111
101
  #priorResult(command, prior) {
112
- const expectedTarget = command.command.type === "expire_message" ? command.command.messageId : command.command.targetAgentId;
113
102
  if (prior.commandId !== command.commandId)
114
103
  throw new Error("Broker returned a mismatched command lookup");
115
- if (prior.type !== command.command.type || prior.targetId !== expectedTarget) {
104
+ if (!sameTarget(command.command, prior))
116
105
  return rejected(command.commandId, "command_id_conflict");
117
- }
118
106
  if (prior.type === "ping_agent")
119
107
  validateOperatorControlId(prior.controlId);
120
108
  if (prior.type === "ping_agent")
@@ -128,13 +116,61 @@ export class OperatorCommandService {
128
116
  });
129
117
  return validateOperatorCommandResult({
130
118
  protocolVersion: OPERATOR_PROTOCOL_VERSION,
131
- type: prior.type === "expire_message" ? "expiry_accepted" : "archive_accepted",
119
+ type: ACCEPTED_RESULT_TYPES[prior.type],
132
120
  status: "accepted",
133
121
  commandId: prior.commandId,
134
122
  acceptedAt: prior.acceptedAt,
135
123
  });
136
124
  }
137
125
  }
126
+ /** Names the result each command kind without a control correlation is accepted with. */
127
+ const ACCEPTED_RESULT_TYPES = {
128
+ expire_message: "expiry_accepted",
129
+ archive_agent: "archive_accepted",
130
+ purge_agents: "purge_accepted",
131
+ };
132
+ /**
133
+ * Run one durable agent command and answer it with its acceptance or a rejection.
134
+ * @param command - Command being admitted, whose kind and target the broker's record must match.
135
+ * @param accept - Broker transaction that applies the command and records its acceptance.
136
+ * @returns - The acceptance, or a rejection naming why the broker refused or failed.
137
+ */
138
+ function agentCommandResult(command, accept) {
139
+ try {
140
+ const { record, status } = accept();
141
+ assertAcceptanceStatus(status);
142
+ if (record.commandId !== command.commandId || !sameTarget(command.command, record)) {
143
+ throw new Error(`Broker returned a mismatched ${command.command.type} acceptance`);
144
+ }
145
+ return validateOperatorCommandResult({
146
+ protocolVersion: OPERATOR_PROTOCOL_VERSION,
147
+ type: ACCEPTED_RESULT_TYPES[command.command.type],
148
+ status: "accepted",
149
+ commandId: command.commandId,
150
+ acceptedAt: record.acceptedAt,
151
+ });
152
+ }
153
+ catch (error) {
154
+ return rejected(command.commandId, rejectionReason(error));
155
+ }
156
+ }
157
+ /**
158
+ * Report whether a stored acceptance answers the same request a retried command makes.
159
+ * @param command - Command carried by the retry.
160
+ * @param prior - Acceptance stored under the retry's command id.
161
+ * @returns - Whether both name the same command kind and the same target.
162
+ */
163
+ function sameTarget(command, prior) {
164
+ switch (command.type) {
165
+ case "ping_agent":
166
+ case "archive_agent":
167
+ return prior.type === command.type && prior.targetId === command.targetAgentId;
168
+ case "expire_message":
169
+ return prior.type === command.type && prior.targetId === command.messageId;
170
+ case "purge_agents":
171
+ return prior.type === command.type && samePurgeTargets(prior.targetIds, command.targetAgentIds);
172
+ }
173
+ }
138
174
  function pingAccepted(commandId, accepted) {
139
175
  if (accepted.type !== "ping_agent" || accepted.commandId !== commandId)
140
176
  throw new Error("Broker returned the wrong ping command acceptance");
@@ -29,6 +29,15 @@ export declare const OPERATOR_SNAPSHOT_TOTAL_LIMIT_BYTES: number;
29
29
  export declare const OPERATOR_SNAPSHOT_CHUNK_BYTES: number;
30
30
  /** Bounds every newline-delimited server frame after JSON encoding. */
31
31
  export declare const OPERATOR_TRANSPORT_FRAME_LIMIT_BYTES: number;
32
+ /** Bounds every newline-delimited client request after JSON encoding; a longer one ends the connection. */
33
+ export declare const OPERATOR_TRANSPORT_REQUEST_LIMIT_BYTES: number;
34
+ /**
35
+ * Bounds how many agents one purge command names.
36
+ *
37
+ * A purge naming this many agents at the longest agent id still fits in one request under
38
+ * `OPERATOR_TRANSPORT_REQUEST_LIMIT_BYTES`.
39
+ */
40
+ export declare const OPERATOR_PURGE_TARGET_LIMIT = 256;
32
41
  /** Represents JSON data that is safe to cross the operator boundary. */
33
42
  export type OperatorJsonValue = null | boolean | number | string | OperatorJsonValue[] | OperatorJsonObject;
34
43
  /** Represents an object whose values are safe to cross the operator boundary. */
@@ -310,6 +319,11 @@ export type OperatorCommand = {
310
319
  type: "archive_agent";
311
320
  /** Identifies the target using broker identity, not repository projection. */
312
321
  targetAgentId: AgentId;
322
+ } | {
323
+ /** Requests that agent identities and their records be deleted in one transaction. */
324
+ type: "purge_agents";
325
+ /** Distinct broker identities to delete, at least one; a purge of any that is already gone is refused whole. */
326
+ targetAgentIds: readonly AgentId[];
313
327
  };
314
328
  /** Carries one versioned operator command request. */
315
329
  export type OperatorCommandEnvelope = {
@@ -367,6 +381,17 @@ export type OperatorCommandResult = {
367
381
  commandId: string;
368
382
  /** Records broker acceptance time. */
369
383
  acceptedAt: number;
384
+ } | {
385
+ /** Identifies the operator wire contract. */
386
+ protocolVersion: typeof OPERATOR_PROTOCOL_VERSION;
387
+ /** Identifies agent-purge command acceptance. */
388
+ type: "purge_accepted";
389
+ /** Reports that the broker accepted the command request. */
390
+ status: "accepted";
391
+ /** Correlates the result with the submitted command. */
392
+ commandId: string;
393
+ /** Records broker acceptance time. */
394
+ acceptedAt: number;
370
395
  } | {
371
396
  /** Identifies the operator wire contract. */
372
397
  protocolVersion: typeof OPERATOR_PROTOCOL_VERSION;
@@ -408,6 +433,13 @@ export type OperatorCorrelatedCommandResult = {
408
433
  result: Extract<OperatorCommandResult, {
409
434
  type: "archive_accepted" | "command_rejected";
410
435
  }>;
436
+ } | {
437
+ /** Carries the originating purge request. */
438
+ command: OperatorCommandEnvelopeFor<"purge_agents">;
439
+ /** Carries purge acceptance or a command-agnostic rejection. */
440
+ result: Extract<OperatorCommandResult, {
441
+ type: "purge_accepted" | "command_rejected";
442
+ }>;
411
443
  };
412
444
  /** Classifies one event relative to a consumer cursor without conflating recovery actions. */
413
445
  export type OperatorCursorResult = {
@@ -28,6 +28,15 @@ export const OPERATOR_SNAPSHOT_TOTAL_LIMIT_BYTES = 512 * 1_024 * 1_024;
28
28
  export const OPERATOR_SNAPSHOT_CHUNK_BYTES = 48 * 1_024;
29
29
  /** Bounds every newline-delimited server frame after JSON encoding. */
30
30
  export const OPERATOR_TRANSPORT_FRAME_LIMIT_BYTES = 128 * 1_024;
31
+ /** Bounds every newline-delimited client request after JSON encoding; a longer one ends the connection. */
32
+ export const OPERATOR_TRANSPORT_REQUEST_LIMIT_BYTES = 64 * 1_024;
33
+ /**
34
+ * Bounds how many agents one purge command names.
35
+ *
36
+ * A purge naming this many agents at the longest agent id still fits in one request under
37
+ * `OPERATOR_TRANSPORT_REQUEST_LIMIT_BYTES`.
38
+ */
39
+ export const OPERATOR_PURGE_TARGET_LIMIT = 256;
31
40
  /**
32
41
  * Names every stable category a command rejection can carry.
33
42
  *