@haywoodmarx/agent-pager 0.1.3 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +4 -4
  2. package/dist/src/broker/address.d.ts +7 -0
  3. package/dist/src/broker/address.js +9 -0
  4. package/dist/src/broker/broker.d.ts +112 -24
  5. package/dist/src/broker/broker.js +604 -132
  6. package/dist/src/broker/operator-projection.d.ts +22 -0
  7. package/dist/src/broker/operator-projection.js +43 -5
  8. package/dist/src/broker/protocol.d.ts +1 -1
  9. package/dist/src/broker/protocol.js +1 -1
  10. package/dist/src/broker/schema.d.ts +4 -0
  11. package/dist/src/broker/schema.js +20 -8
  12. package/dist/src/broker/snapshot.d.ts +4 -2
  13. package/dist/src/implementations/claude/gateway-cli.js +1 -2
  14. package/dist/src/implementations/claude/gateway.d.ts +11 -4
  15. package/dist/src/implementations/claude/gateway.js +44 -14
  16. package/dist/src/implementations/claude/instructions.js +1 -1
  17. package/dist/src/implementations/model.d.ts +6 -3
  18. package/dist/src/implementations/opencode/plugin.d.ts +7 -2
  19. package/dist/src/implementations/opencode/plugin.js +103 -7
  20. package/dist/src/implementations/pi/broker-client.d.ts +13 -4
  21. package/dist/src/implementations/pi/broker-client.js +71 -9
  22. package/dist/src/implementations/pi/extension.d.ts +7 -2
  23. package/dist/src/implementations/pi/extension.js +15 -3
  24. package/dist/src/index.d.ts +2 -2
  25. package/dist/src/index.js +2 -2
  26. package/dist/src/launch/broker-lock.d.ts +58 -4
  27. package/dist/src/launch/broker-lock.js +140 -7
  28. package/dist/src/launch/launch.d.ts +1 -1
  29. package/dist/src/launch/on-demand.d.ts +8 -8
  30. package/dist/src/launch/on-demand.js +54 -35
  31. package/dist/src/launch/runtime-composition.js +2 -0
  32. package/dist/src/launch/runtime.js +4 -14
  33. package/dist/src/launch/service.d.ts +1 -0
  34. package/dist/src/launch/service.js +23 -1
  35. package/dist/src/launch/workspace-branch.d.ts +3 -3
  36. package/dist/src/launch/workspace-branch.js +7 -15
  37. package/dist/src/launch/workspace-root.d.ts +13 -0
  38. package/dist/src/launch/workspace-root.js +27 -0
  39. package/dist/src/messaging/delivery-client.d.ts +17 -0
  40. package/dist/src/messaging/delivery-client.js +31 -1
  41. package/dist/src/messaging/identity-statement.d.ts +7 -0
  42. package/dist/src/messaging/identity-statement.js +8 -0
  43. package/dist/src/messaging/mcp.d.ts +8 -2
  44. package/dist/src/messaging/mcp.js +54 -1
  45. package/dist/src/messaging/notifications.d.ts +31 -3
  46. package/dist/src/messaging/notifications.js +25 -0
  47. package/dist/src/messaging/push-delivery.d.ts +2 -1
  48. package/dist/src/messaging/push-delivery.js +24 -7
  49. package/dist/src/messaging/tools.js +22 -2
  50. package/dist/src/messaging/wake.js +7 -0
  51. package/dist/src/operator/agent-projection.d.ts +8 -5
  52. package/dist/src/operator/agent-projection.js +29 -9
  53. package/dist/src/operator/commands.d.ts +2 -0
  54. package/dist/src/operator/commands.js +57 -21
  55. package/dist/src/operator/protocol.d.ts +34 -2
  56. package/dist/src/operator/protocol.js +9 -0
  57. package/dist/src/operator/repository-system.d.ts +29 -0
  58. package/dist/src/operator/repository-system.js +39 -12
  59. package/dist/src/operator/repository.d.ts +28 -11
  60. package/dist/src/operator/repository.js +37 -4
  61. package/dist/src/operator/transport.js +10 -5
  62. package/dist/src/operator/updates.js +7 -1
  63. package/dist/src/operator/validation.js +35 -1
  64. package/dist/src/shared/versions.d.ts +18 -0
  65. package/dist/src/shared/versions.js +26 -0
  66. package/dist/src/tui/agent-tree.d.ts +8 -5
  67. package/dist/src/tui/agent-tree.js +195 -22
  68. package/dist/src/tui/app.js +6 -2
  69. package/dist/src/tui/message-views.js +4 -3
  70. package/dist/src/tui/model.d.ts +25 -1
  71. package/dist/src/tui/model.js +33 -9
  72. package/dist/src/tui/presentation.d.ts +11 -2
  73. package/dist/src/tui/presentation.js +26 -4
  74. package/dist/src/version.d.ts +7 -0
  75. package/dist/src/version.js +14 -6
  76. package/package.json +1 -1
  77. package/skills/pager/SKILL.md +35 -45
@@ -1,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. */
@@ -143,9 +152,9 @@ export type OperatorAgent = {
143
152
  */
144
153
  archivedAt: number | null;
145
154
  /**
146
- * Reports whether the recorded workspace path still resolves on the broker's machine.
155
+ * Reports whether the agent's recorded working directory still resolves on the broker's machine.
147
156
  *
148
- * Presentation-only, like the rest of repository grouping: a removed workspace archives its
157
+ * Presentation-only, like the rest of repository grouping: a removed directory archives its
149
158
  * agents in the operator view without changing broker lifecycle, routing, or addressability.
150
159
  */
151
160
  workspaceAvailable: boolean;
@@ -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
  *
@@ -5,6 +5,8 @@ export declare const REPOSITORY_GIT_OUTPUT_LIMIT = 65536;
5
5
  export type SystemRepositoryProjectionOptions = {
6
6
  /** Selects the Git executable without mutating process PATH. */
7
7
  gitExecutable?: string;
8
+ /** Bounds each Git command's run time in milliseconds; unbounded when omitted. */
9
+ gitTimeoutMs?: number;
8
10
  };
9
11
  /**
10
12
  * Compose production filesystem and Git discovery adapters.
@@ -12,3 +14,30 @@ export type SystemRepositoryProjectionOptions = {
12
14
  * @returns Dependencies whose process execution is isolated from pure projection logic.
13
15
  */
14
16
  export declare function createSystemRepositoryProjectionDependencies(options?: SystemRepositoryProjectionOptions): RepositoryProjectionDependencies;
17
+ /** Reports how one Git command ended, separating the two answers Git gives for a path it cannot serve. */
18
+ export type GitCommandResult = {
19
+ kind: "ok";
20
+ stdout: string;
21
+ } | {
22
+ kind: "not_git";
23
+ } | {
24
+ kind: "no_worktree";
25
+ };
26
+ /**
27
+ * Report the Git directory every worktree of the repository containing a directory shares.
28
+ * @param path - Absolute directory inside a worktree, or inside a bare repository.
29
+ * @param options - Executable and time bound for the Git command.
30
+ * @returns The repository's common Git directory, canonical and absolute, or null when the directory
31
+ * is not inside a Git repository.
32
+ * @throws {RepositoryDiscoveryError} When Git is unavailable, times out, or answers with something other than one absolute path.
33
+ */
34
+ export declare function discoverRepositoryCommonDirectory(path: string, options?: SystemRepositoryProjectionOptions): string | null;
35
+ /**
36
+ * Run one Git command about the repository its arguments name, with a bounded, captured output.
37
+ * @param executable - Git executable to spawn.
38
+ * @param args - Arguments, including the `-C` directory the command is about.
39
+ * @param timeoutMs - Milliseconds the command may run before it is treated as unavailable; unbounded when undefined.
40
+ * @returns The command's output, or which of Git's two refusals it answered with.
41
+ * @throws {RepositoryDiscoveryError} When Git cannot be run, exceeds the timeout or the output limit, or fails for any other reason.
42
+ */
43
+ export declare function runGit(executable: string, args: string[], timeoutMs: number | undefined): GitCommandResult;
@@ -11,6 +11,7 @@ export const REPOSITORY_GIT_OUTPUT_LIMIT = 65_536;
11
11
  */
12
12
  export function createSystemRepositoryProjectionDependencies(options = {}) {
13
13
  const gitExecutable = options.gitExecutable ?? "git";
14
+ const timeoutMs = options.gitTimeoutMs;
14
15
  return {
15
16
  fileSystem: {
16
17
  canonicalPath: (path) => realpathSync(path),
@@ -18,25 +19,52 @@ export function createSystemRepositoryProjectionDependencies(options = {}) {
18
19
  },
19
20
  git: {
20
21
  inspect: (workspacePath) => {
21
- const topLevel = runGit(gitExecutable, ["-C", workspacePath, "rev-parse", "--show-toplevel"], true);
22
+ const topLevel = runGit(gitExecutable, ["-C", workspacePath, "rev-parse", "--show-toplevel"], timeoutMs);
22
23
  if (topLevel.kind === "not_git")
23
24
  return topLevel;
24
- const worktreeRoot = parseSingleAbsolutePath(topLevel.stdout, "Git top-level output is invalid");
25
- const topology = runGit(gitExecutable, ["-C", workspacePath, "worktree", "list", "--porcelain", "-z"], false);
26
- if (topology.kind === "not_git")
25
+ const topology = runGit(gitExecutable, ["-C", workspacePath, "worktree", "list", "--porcelain", "-z"], timeoutMs);
26
+ if (topology.kind !== "ok")
27
27
  throw new RepositoryDiscoveryError("invalid", "Git topology disappeared");
28
- const repositoryRoot = parsePrimaryWorktree(topology.stdout, worktreeRoot);
28
+ const repositoryRoot = parsePrimaryWorktree(topology.stdout);
29
+ if (topLevel.kind === "no_worktree")
30
+ return { kind: "git", repositoryRoot, worktreeRoot: repositoryRoot };
31
+ const worktreeRoot = parseSingleAbsolutePath(topLevel.stdout, "Git top-level output is invalid");
29
32
  return { kind: "git", repositoryRoot, worktreeRoot };
30
33
  },
31
34
  },
32
35
  };
33
36
  }
34
- function runGit(executable, args, allowNotGit) {
37
+ /**
38
+ * Report the Git directory every worktree of the repository containing a directory shares.
39
+ * @param path - Absolute directory inside a worktree, or inside a bare repository.
40
+ * @param options - Executable and time bound for the Git command.
41
+ * @returns The repository's common Git directory, canonical and absolute, or null when the directory
42
+ * is not inside a Git repository.
43
+ * @throws {RepositoryDiscoveryError} When Git is unavailable, times out, or answers with something other than one absolute path.
44
+ */
45
+ export function discoverRepositoryCommonDirectory(path, options = {}) {
46
+ const answer = runGit(options.gitExecutable ?? "git", ["-C", path, "rev-parse", "--path-format=absolute", "--git-common-dir"], options.gitTimeoutMs);
47
+ if (answer.kind !== "ok")
48
+ return null;
49
+ return parseSingleAbsolutePath(answer.stdout, "Git common directory output is invalid");
50
+ }
51
+ /**
52
+ * Run one Git command about the repository its arguments name, with a bounded, captured output.
53
+ * @param executable - Git executable to spawn.
54
+ * @param args - Arguments, including the `-C` directory the command is about.
55
+ * @param timeoutMs - Milliseconds the command may run before it is treated as unavailable; unbounded when undefined.
56
+ * @returns The command's output, or which of Git's two refusals it answered with.
57
+ * @throws {RepositoryDiscoveryError} When Git cannot be run, exceeds the timeout or the output limit, or fails for any other reason.
58
+ */
59
+ export function runGit(executable, args, timeoutMs) {
60
+ // Git redirects itself to whatever these name, which would answer for a repository other than the one asked about.
61
+ const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => !["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"].includes(name)));
35
62
  const result = spawnSync(executable, args, {
36
63
  encoding: "utf8",
37
- env: { ...process.env, LC_ALL: "C", LANG: "C" },
64
+ env: { ...environment, LC_ALL: "C", LANG: "C" },
38
65
  maxBuffer: REPOSITORY_GIT_OUTPUT_LIMIT,
39
66
  stdio: ["ignore", "pipe", "pipe"],
67
+ ...(timeoutMs === undefined ? {} : { timeout: timeoutMs }),
40
68
  });
41
69
  if (result.error) {
42
70
  const code = "code" in result.error ? result.error.code : undefined;
@@ -53,8 +81,10 @@ function runGit(executable, args, allowNotGit) {
53
81
  if (result.signal !== null)
54
82
  throw new RepositoryDiscoveryError("unavailable", "Git discovery process was interrupted");
55
83
  if (result.status !== 0) {
56
- if (allowNotGit && result.status === 128 && /not a git repository/u.test(stderr))
84
+ if (result.status === 128 && /not a git repository/u.test(stderr))
57
85
  return { kind: "not_git" };
86
+ if (result.status === 128 && /must be run in a work tree/u.test(stderr))
87
+ return { kind: "no_worktree" };
58
88
  throw new RepositoryDiscoveryError("unavailable", "Git discovery command failed");
59
89
  }
60
90
  return { kind: "ok", stdout };
@@ -66,7 +96,7 @@ function parseSingleAbsolutePath(output, message) {
66
96
  }
67
97
  return normalized;
68
98
  }
69
- function parsePrimaryWorktree(output, currentWorktree) {
99
+ function parsePrimaryWorktree(output) {
70
100
  if (!output.endsWith("\0\0"))
71
101
  throw new RepositoryDiscoveryError("invalid", "Git topology output is truncated");
72
102
  const tokens = output.split("\0");
@@ -100,9 +130,6 @@ function parsePrimaryWorktree(output, currentWorktree) {
100
130
  throw new RepositoryDiscoveryError("invalid", "Only the primary Git topology record may be bare");
101
131
  }
102
132
  }
103
- if (!worktrees.has(currentWorktree)) {
104
- throw new RepositoryDiscoveryError("invalid", "Git topology omitted the current worktree");
105
- }
106
133
  const primary = topology[0]?.path;
107
134
  if (!primary)
108
135
  throw new RepositoryDiscoveryError("invalid", "Git topology omitted its primary worktree");
@@ -3,7 +3,7 @@ import type { OperatorRepository, OperatorWorktree } from "./protocol.js";
3
3
  export type RepositoryGitLocation = {
4
4
  /** Supplies the root of the primary repository worktree. */
5
5
  repositoryRoot: string;
6
- /** Supplies the root of the worktree containing the inspected workspace. */
6
+ /** Supplies the root of the worktree containing the inspected directory. */
7
7
  worktreeRoot: string;
8
8
  };
9
9
  /** Reports either confirmed Git topology or a confirmed non-Git workspace. */
@@ -16,13 +16,13 @@ export type RepositoryGitInspection = ({
16
16
  export interface RepositoryFileSystemPort {
17
17
  /**
18
18
  * Resolve aliases and symbolic links in one existing path.
19
- * @param path - Absolute workspace or Git root to canonicalize.
19
+ * @param path - Absolute path or Git root to canonicalize.
20
20
  * @returns The canonical absolute filesystem path.
21
21
  */
22
22
  canonicalPath(path: string): string;
23
23
  /**
24
24
  * Report whether one recorded path still resolves on this machine.
25
- * @param path - Absolute broker workspace path recorded when the agent registered.
25
+ * @param path - Absolute path recorded at registration.
26
26
  * @returns True when the path resolves through any symbolic links, false when it has been removed, is a dangling link, or is unreachable.
27
27
  */
28
28
  exists(path: string): boolean;
@@ -30,12 +30,28 @@ export interface RepositoryFileSystemPort {
30
30
  /** Discovers Git topology without granting it broker routing authority. */
31
31
  export interface RepositoryGitPort {
32
32
  /**
33
- * Inspect one canonical workspace for Git repository topology.
34
- * @param workspacePath - Canonical absolute workspace path.
33
+ * Inspect one canonical directory for Git repository topology.
34
+ * @param path - Canonical absolute directory to inspect.
35
35
  * @returns Confirmed Git topology or a confirmed non-Git result.
36
36
  */
37
- inspect(workspacePath: string): RepositoryGitInspection;
37
+ inspect(path: string): RepositoryGitInspection;
38
38
  }
39
+ /** Configures how long one Git inspection answers for its path. */
40
+ export type RememberedInspectionOptions = {
41
+ /** Supplies the clock the expiry is measured against. */
42
+ now: () => number;
43
+ /** Milliseconds an inspection answers for before the path is inspected again. */
44
+ ttlMs: number;
45
+ };
46
+ /**
47
+ * Wrap a Git port so each path is inspected at most once per expiry window.
48
+ *
49
+ * A thrown inspection is remembered like an answered one, and rethrown for the rest of its window.
50
+ * @param git - Port that performs the inspection when nothing current is remembered.
51
+ * @param options - Clock and expiry window.
52
+ * @returns A port answering repeated requests for one path from the last inspection within the window.
53
+ */
54
+ export declare function rememberRepositoryInspections(git: RepositoryGitPort, options: RememberedInspectionOptions): RepositoryGitPort;
39
55
  /** Supplies filesystem and Git collaborators to repository projection. */
40
56
  export type RepositoryProjectionDependencies = {
41
57
  /** Canonicalizes discovered paths. */
@@ -49,7 +65,7 @@ export type ProjectedRepositoryLocation = {
49
65
  repository: Omit<OperatorRepository, "name">;
50
66
  /** Carries worktree detail for the inspected workspace. */
51
67
  worktree: OperatorWorktree;
52
- /** Reports whether the workspace path still resolves, which decides operator archive membership. */
68
+ /** Reports whether the inspected path still resolves, which decides operator archive membership. */
53
69
  available: boolean;
54
70
  };
55
71
  /** Reports invalid or unavailable repository projection inputs. */
@@ -66,14 +82,15 @@ export declare class RepositoryDiscoveryError extends RepositoryProjectionError
66
82
  constructor(code: "unavailable" | "invalid" | "output_limit", message: string);
67
83
  }
68
84
  /**
69
- * Project one broker workspace into repository grouping and worktree detail.
85
+ * Project one directory, a broker workspace or an agent's working directory, into repository
86
+ * grouping and worktree detail.
70
87
  *
71
- * A workspace whose path no longer resolves is projected from its recorded path alone. Neither
88
+ * A directory whose path no longer resolves is projected from its recorded path alone. Neither
72
89
  * canonicalization nor Git discovery can run against a removed directory, and both throw rather
73
90
  * than reporting absence, so the removal is detected before either is attempted.
74
- * @param workspacePath - Absolute broker workspace path to inspect.
91
+ * @param workspacePath - Absolute broker workspace path, or agent working directory, to inspect.
75
92
  * @param dependencies - Explicit filesystem and Git discovery collaborators.
76
- * @returns Presentation-only repository and worktree values, and whether the workspace still resolves.
93
+ * @returns Presentation-only repository and worktree values, and whether the directory still resolves.
77
94
  */
78
95
  export declare function projectRepositoryLocation(workspacePath: string, dependencies: RepositoryProjectionDependencies): ProjectedRepositoryLocation;
79
96
  export {};
@@ -1,4 +1,36 @@
1
1
  import { createHash } from "node:crypto";
2
+ /**
3
+ * Wrap a Git port so each path is inspected at most once per expiry window.
4
+ *
5
+ * A thrown inspection is remembered like an answered one, and rethrown for the rest of its window.
6
+ * @param git - Port that performs the inspection when nothing current is remembered.
7
+ * @param options - Clock and expiry window.
8
+ * @returns A port answering repeated requests for one path from the last inspection within the window.
9
+ */
10
+ export function rememberRepositoryInspections(git, options) {
11
+ const remembered = new Map();
12
+ const inspectAndRemember = (path, now) => {
13
+ let outcome;
14
+ try {
15
+ outcome = { kind: "answered", inspection: git.inspect(path) };
16
+ }
17
+ catch (error) {
18
+ outcome = { kind: "threw", error };
19
+ }
20
+ remembered.set(path, { outcome, expiresAt: now + options.ttlMs });
21
+ return outcome;
22
+ };
23
+ return {
24
+ inspect: (path) => {
25
+ const now = options.now();
26
+ const current = remembered.get(path);
27
+ const outcome = current && current.expiresAt > now ? current.outcome : inspectAndRemember(path, now);
28
+ if (outcome.kind === "threw")
29
+ throw outcome.error;
30
+ return outcome.inspection;
31
+ },
32
+ };
33
+ }
2
34
  /** Reports invalid or unavailable repository projection inputs. */
3
35
  class RepositoryProjectionError extends Error {
4
36
  }
@@ -17,14 +49,15 @@ export class RepositoryDiscoveryError extends RepositoryProjectionError {
17
49
  }
18
50
  }
19
51
  /**
20
- * Project one broker workspace into repository grouping and worktree detail.
52
+ * Project one directory, a broker workspace or an agent's working directory, into repository
53
+ * grouping and worktree detail.
21
54
  *
22
- * A workspace whose path no longer resolves is projected from its recorded path alone. Neither
55
+ * A directory whose path no longer resolves is projected from its recorded path alone. Neither
23
56
  * canonicalization nor Git discovery can run against a removed directory, and both throw rather
24
57
  * than reporting absence, so the removal is detected before either is attempted.
25
- * @param workspacePath - Absolute broker workspace path to inspect.
58
+ * @param workspacePath - Absolute broker workspace path, or agent working directory, to inspect.
26
59
  * @param dependencies - Explicit filesystem and Git discovery collaborators.
27
- * @returns Presentation-only repository and worktree values, and whether the workspace still resolves.
60
+ * @returns Presentation-only repository and worktree values, and whether the directory still resolves.
28
61
  */
29
62
  export function projectRepositoryLocation(workspacePath, dependencies) {
30
63
  if (!dependencies.fileSystem.exists(workspacePath)) {
@@ -3,9 +3,8 @@ import { StringDecoder } from "node:string_decoder";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { LOOPBACK_HOST } from "../configuration-defaults.js";
5
5
  import { closeServer, listenServer, urlHost } from "../shared/http.js";
6
- import { OPERATOR_PROTOCOL_VERSION, OPERATOR_SNAPSHOT_CHUNK_BYTES, OPERATOR_TRANSPORT_FRAME_LIMIT_BYTES, } from "./protocol.js";
6
+ import { OPERATOR_PROTOCOL_VERSION, OPERATOR_SNAPSHOT_CHUNK_BYTES, OPERATOR_TRANSPORT_FRAME_LIMIT_BYTES, OPERATOR_TRANSPORT_REQUEST_LIMIT_BYTES, } from "./protocol.js";
7
7
  import { OperatorValidationError, validateOperatorCommandResultForCommand, validateOperatorSnapshotByteLength, validateOperatorTransportRequest, } from "./validation.js";
8
- const INPUT_LIMIT_BYTES = 64 * 1_024;
9
8
  const LIVE_QUEUE_LIMIT = 32;
10
9
  const DIAGNOSTIC_LIMIT = 256;
11
10
  const DEFAULT_MAX_CONNECTIONS = 64;
@@ -214,10 +213,16 @@ class OperatorConnection {
214
213
  this.#socket.destroy();
215
214
  }
216
215
  #receive(chunk) {
217
- if (this.#closed)
216
+ // A connection that is closing reads nothing more, so input from a peer that has stopped reading
217
+ // cannot pile up while its last frames wait to flush.
218
+ if (this.#closed || this.#closeAfterFlush) {
219
+ this.#buffer = "";
218
220
  return;
221
+ }
219
222
  this.#buffer += chunk;
220
- if (Buffer.byteLength(this.#buffer, "utf8") > INPUT_LIMIT_BYTES) {
223
+ // One read can carry several whole requests, so only the unterminated one is measured here.
224
+ const unterminated = this.#buffer.slice(this.#buffer.lastIndexOf("\n") + 1);
225
+ if (Buffer.byteLength(unterminated, "utf8") > OPERATOR_TRANSPORT_REQUEST_LIMIT_BYTES) {
221
226
  this.#malformed();
222
227
  return;
223
228
  }
@@ -227,7 +232,7 @@ class OperatorConnection {
227
232
  return;
228
233
  const line = this.#buffer.slice(0, newline);
229
234
  this.#buffer = this.#buffer.slice(newline + 1);
230
- if (!line || Buffer.byteLength(line, "utf8") > INPUT_LIMIT_BYTES) {
235
+ if (!line || Buffer.byteLength(line, "utf8") > OPERATOR_TRANSPORT_REQUEST_LIMIT_BYTES) {
231
236
  this.#malformed();
232
237
  return;
233
238
  }
@@ -8,7 +8,13 @@ const UPDATE_DIAGNOSTIC_LIMIT = 64;
8
8
  /** Broker tables whose rows feed the repository and agent halves of an operator snapshot. */
9
9
  const AGENT_SOURCE_TABLES = ["agents", "sessions", "workspaces", "alias_history"];
10
10
  /** Broker tables whose rows feed the traffic half of an operator snapshot. */
11
- const TRAFFIC_SOURCE_TABLES = ["messages", "message_events", "claims", "traffic_positions"];
11
+ const TRAFFIC_SOURCE_TABLES = [
12
+ "messages",
13
+ "message_events",
14
+ "claims",
15
+ "traffic_positions",
16
+ "operator_ping_audits",
17
+ ];
12
18
  /** Produces complete snapshots and durable strictly ordered projection changes. */
13
19
  export class OperatorUpdateService {
14
20
  #source;
@@ -1,4 +1,4 @@
1
- import { OPERATOR_DIAGNOSTIC_TEXT_LIMIT, OPERATOR_PROTOCOL_VERSION, OPERATOR_REJECTION_REASONS, OPERATOR_SNAPSHOT_TOTAL_LIMIT_BYTES, } from "./protocol.js";
1
+ import { OPERATOR_DIAGNOSTIC_TEXT_LIMIT, OPERATOR_PROTOCOL_VERSION, OPERATOR_PURGE_TARGET_LIMIT, OPERATOR_REJECTION_REASONS, OPERATOR_SNAPSHOT_TOTAL_LIMIT_BYTES, } from "./protocol.js";
2
2
  import { BROKER_OPERATOR_TRAFFIC_LIMIT, hasControlCharacter, isAgentId, isImplementationId, isOpaqueId, lifecycleEventState, MESSAGE_BODY_LIMIT_BYTES, MESSAGE_SUBJECT_LIMIT_CHARACTERS, MESSAGE_TYPE_LIMIT_CHARACTERS, } from "../broker/protocol.js";
3
3
  import { MESSAGE_STATES } from "../broker/schema.js";
4
4
  import { exactFields as guardExactFields, readArray as guardReadArray, readOwnEnumerableEntries as guardReadOwnEnumerableEntries, readRecord as guardReadRecord, } from "../shared/guards.js";
@@ -407,6 +407,22 @@ export function validateOperatorCommandResult(value) {
407
407
  commandId: readOpaqueId(record.commandId, "commandResult.commandId"),
408
408
  acceptedAt: readTimestamp(record.acceptedAt, "commandResult.acceptedAt"),
409
409
  };
410
+ case "purge_accepted":
411
+ exactFields({
412
+ record,
413
+ required: ["protocolVersion", "type", "status", "commandId", "acceptedAt"],
414
+ optional: [],
415
+ path: "commandResult",
416
+ });
417
+ if (record.status !== "accepted")
418
+ throw new OperatorValidationError("commandResult.status", "purge result must be accepted");
419
+ return {
420
+ protocolVersion: readProtocolVersion(record.protocolVersion, "commandResult.protocolVersion"),
421
+ type: "purge_accepted",
422
+ status: "accepted",
423
+ commandId: readOpaqueId(record.commandId, "commandResult.commandId"),
424
+ acceptedAt: readTimestamp(record.acceptedAt, "commandResult.acceptedAt"),
425
+ };
410
426
  case "command_rejected":
411
427
  exactFields({
412
428
  record,
@@ -452,6 +468,10 @@ export function validateOperatorCommandResultForCommand(commandValue, resultValu
452
468
  if (result.type === "archive_accepted" || result.type === "command_rejected")
453
469
  return { command: { ...command, command: command.command }, result };
454
470
  break;
471
+ case "purge_agents":
472
+ if (result.type === "purge_accepted" || result.type === "command_rejected")
473
+ return { command: { ...command, command: command.command }, result };
474
+ break;
455
475
  }
456
476
  throw new OperatorValidationError("commandResult.type", `${resultDescriptions[result.type]} cannot answer ${commandDescriptions[command.command.type]}`);
457
477
  }
@@ -708,11 +728,13 @@ const commandDescriptions = {
708
728
  ping_agent: "a ping command",
709
729
  expire_message: "an expiry command",
710
730
  archive_agent: "an archive command",
731
+ purge_agents: "a purge command",
711
732
  };
712
733
  const resultDescriptions = {
713
734
  ping_accepted: "ping acceptance",
714
735
  expiry_accepted: "expiry acceptance",
715
736
  archive_accepted: "archive acceptance",
737
+ purge_accepted: "purge acceptance",
716
738
  command_rejected: "rejection",
717
739
  };
718
740
  function validateCommandMember(value) {
@@ -727,6 +749,9 @@ function validateCommandMember(value) {
727
749
  case "archive_agent":
728
750
  exactFields({ record, required: ["type", "targetAgentId"], optional: [], path: "commandEnvelope.command" });
729
751
  return { type: "archive_agent", targetAgentId: readAgentId(record.targetAgentId, "command.targetAgentId") };
752
+ case "purge_agents":
753
+ exactFields({ record, required: ["type", "targetAgentIds"], optional: [], path: "commandEnvelope.command" });
754
+ return { type: "purge_agents", targetAgentIds: readPurgeTargets(record.targetAgentIds, "command.targetAgentIds") };
730
755
  default:
731
756
  throw new OperatorValidationError("commandEnvelope.command.type", "unknown command");
732
757
  }
@@ -858,6 +883,15 @@ function readAgentId(value, path) {
858
883
  throw new OperatorValidationError(path, "invalid agent identity");
859
884
  return id;
860
885
  }
886
+ function readPurgeTargets(value, path) {
887
+ const entries = readArray(value, path);
888
+ if (entries.length === 0 || entries.length > OPERATOR_PURGE_TARGET_LIMIT)
889
+ throw new OperatorValidationError(path, "must name between one and the bounded number of agents");
890
+ const ids = entries.map((entry, index) => readAgentId(entry, `${path}[${String(index)}]`));
891
+ if (new Set(ids).size !== ids.length)
892
+ throw new OperatorValidationError(path, "must not repeat an agent");
893
+ return ids;
894
+ }
861
895
  function readOpaqueId(value, path) {
862
896
  const id = readNonemptyString(value, path);
863
897
  if (!isOpaqueId(id))
@@ -0,0 +1,18 @@
1
+ /** A `major.minor.patch` version as three numbers. */
2
+ export type NumericVersion = readonly [number, number, number];
3
+ /**
4
+ * Read the `major.minor.patch` numbers of a version string.
5
+ * @param version - Version text, such as a runtime's reported version or a package release.
6
+ * @param extent - `prefix` accepts trailing text such as a prerelease or build suffix and ignores it;
7
+ * `exact` rejects any version carrying one.
8
+ * @returns - The three numbers, or undefined when the text does not start with, or is not exactly, a
9
+ * numeric version.
10
+ */
11
+ export declare function parseNumericVersion(version: string, extent: "prefix" | "exact"): NumericVersion | undefined;
12
+ /**
13
+ * Order two numeric versions.
14
+ * @param left - First version.
15
+ * @param right - Second version.
16
+ * @returns - Negative when `left` is lower, positive when it is higher, zero when they are equal.
17
+ */
18
+ export declare function compareNumericVersions(left: NumericVersion, right: NumericVersion): number;