@haywoodmarx/agent-pager 0.1.3 → 0.2.0

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 (63) 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 +91 -23
  5. package/dist/src/broker/broker.js +505 -123
  6. package/dist/src/broker/operator-projection.js +3 -5
  7. package/dist/src/broker/protocol.d.ts +1 -1
  8. package/dist/src/broker/protocol.js +1 -1
  9. package/dist/src/broker/schema.d.ts +4 -0
  10. package/dist/src/broker/schema.js +9 -6
  11. package/dist/src/broker/snapshot.d.ts +4 -2
  12. package/dist/src/implementations/claude/gateway-cli.js +1 -2
  13. package/dist/src/implementations/claude/gateway.d.ts +11 -4
  14. package/dist/src/implementations/claude/gateway.js +44 -14
  15. package/dist/src/implementations/claude/instructions.js +1 -1
  16. package/dist/src/implementations/model.d.ts +6 -3
  17. package/dist/src/implementations/opencode/plugin.d.ts +7 -2
  18. package/dist/src/implementations/opencode/plugin.js +103 -7
  19. package/dist/src/implementations/pi/broker-client.d.ts +13 -4
  20. package/dist/src/implementations/pi/broker-client.js +71 -9
  21. package/dist/src/implementations/pi/extension.d.ts +7 -2
  22. package/dist/src/implementations/pi/extension.js +15 -3
  23. package/dist/src/index.d.ts +1 -1
  24. package/dist/src/index.js +1 -1
  25. package/dist/src/launch/broker-lock.d.ts +58 -4
  26. package/dist/src/launch/broker-lock.js +140 -7
  27. package/dist/src/launch/launch.d.ts +1 -1
  28. package/dist/src/launch/on-demand.d.ts +8 -8
  29. package/dist/src/launch/on-demand.js +54 -35
  30. package/dist/src/launch/runtime-composition.js +2 -0
  31. package/dist/src/launch/runtime.js +4 -14
  32. package/dist/src/launch/service.d.ts +1 -0
  33. package/dist/src/launch/service.js +23 -1
  34. package/dist/src/launch/workspace-branch.d.ts +3 -3
  35. package/dist/src/launch/workspace-branch.js +7 -15
  36. package/dist/src/launch/workspace-root.d.ts +13 -0
  37. package/dist/src/launch/workspace-root.js +27 -0
  38. package/dist/src/messaging/delivery-client.d.ts +17 -0
  39. package/dist/src/messaging/delivery-client.js +31 -1
  40. package/dist/src/messaging/identity-statement.d.ts +7 -0
  41. package/dist/src/messaging/identity-statement.js +8 -0
  42. package/dist/src/messaging/mcp.d.ts +8 -2
  43. package/dist/src/messaging/mcp.js +54 -1
  44. package/dist/src/messaging/notifications.d.ts +31 -3
  45. package/dist/src/messaging/notifications.js +25 -0
  46. package/dist/src/messaging/push-delivery.d.ts +2 -1
  47. package/dist/src/messaging/push-delivery.js +24 -7
  48. package/dist/src/messaging/tools.js +22 -2
  49. package/dist/src/messaging/wake.js +7 -0
  50. package/dist/src/operator/agent-projection.d.ts +8 -5
  51. package/dist/src/operator/agent-projection.js +29 -9
  52. package/dist/src/operator/protocol.d.ts +2 -2
  53. package/dist/src/operator/repository-system.d.ts +29 -0
  54. package/dist/src/operator/repository-system.js +39 -12
  55. package/dist/src/operator/repository.d.ts +28 -11
  56. package/dist/src/operator/repository.js +37 -4
  57. package/dist/src/shared/versions.d.ts +18 -0
  58. package/dist/src/shared/versions.js +26 -0
  59. package/dist/src/tui/agent-tree.js +2 -2
  60. package/dist/src/version.d.ts +7 -0
  61. package/dist/src/version.js +14 -6
  62. package/package.json +1 -1
  63. package/skills/pager/SKILL.md +35 -45
@@ -43,10 +43,24 @@ export type OperatorPingChanged = InboxChangedBase & {
43
43
  /** Preserves every additional command correlation represented by a coalesced wake. */
44
44
  coalescedControlIds: string[];
45
45
  };
46
- /** Represents exactly one mutually exclusive message or operator-control wake. */
47
- export type InboxChanged = InboxMessageChanged | OperatorPingChanged;
46
+ /**
47
+ * Tells the process connected to an identity that another process took the identity over, and that
48
+ * its session has ended.
49
+ */
50
+ export type SessionClaimedChanged = InboxChangedBase & {
51
+ /** Records that the identity's alias was claimed by another process. */
52
+ reason: "session_claimed";
53
+ /** Address that was taken, as it reached the identity. */
54
+ claimedAddress: string;
55
+ /** Address the claiming process held before the claim. */
56
+ claimantAddress: string;
57
+ /** Identity the claiming process held before the claim. */
58
+ claimantId: AgentId;
59
+ };
60
+ /** Represents exactly one mutually exclusive message, operator-control, or session-claimed wake. */
61
+ export type InboxChanged = InboxMessageChanged | OperatorPingChanged | SessionClaimedChanged;
48
62
  /** Records a message-related reason that may optionally carry one durable message identity. */
49
- export type InboxMessageReason = Exclude<InboxChanged["reason"], "operator_ping">;
63
+ export type InboxMessageReason = Exclude<InboxChanged["reason"], "operator_ping" | "session_claimed">;
50
64
  /** Classifies scheduler admission without claiming adapter delivery. */
51
65
  export type OperatorPingAdmission = {
52
66
  /** Reports an exact durable retry for which no second wake was admitted. */
@@ -150,6 +164,20 @@ export declare class NotificationHub {
150
164
  * @returns - Copy of the published event; changing it does not alter what subscribers received.
151
165
  */
152
166
  publishOperatorPing(agentId: AgentId, controlId: string, coalescedControlIds?: readonly string[]): OperatorPingChanged;
167
+ /**
168
+ * Publish the one wake a process receives when another process has taken its identity over.
169
+ *
170
+ * Published to the identity's address as the displaced process registered it; the scheduler's
171
+ * budget does not apply, since nothing is offered again later.
172
+ * @param agentId - Identity that was taken over, which the displaced process still addresses.
173
+ * @param claim - Address taken, and the address and identity the claiming process held before.
174
+ * @returns - Copy of the published event; changing it does not alter what subscribers received.
175
+ */
176
+ publishSessionClaimed(agentId: AgentId, claim: {
177
+ claimedAddress: string;
178
+ claimantAddress: string;
179
+ claimantId: AgentId;
180
+ }): SessionClaimedChanged;
153
181
  /**
154
182
  * Subscribe an adapter to body-free notification events.
155
183
  * @param agentId - Agent whose operational events the listener receives.
@@ -71,6 +71,31 @@ export class NotificationHub {
71
71
  this.#fanOut(agentId, event);
72
72
  return cloneInboxChanged(event);
73
73
  }
74
+ /**
75
+ * Publish the one wake a process receives when another process has taken its identity over.
76
+ *
77
+ * Published to the identity's address as the displaced process registered it; the scheduler's
78
+ * budget does not apply, since nothing is offered again later.
79
+ * @param agentId - Identity that was taken over, which the displaced process still addresses.
80
+ * @param claim - Address taken, and the address and identity the claiming process held before.
81
+ * @returns - Copy of the published event; changing it does not alter what subscribers received.
82
+ */
83
+ publishSessionClaimed(agentId, claim) {
84
+ const sequence = (this.#sequences.get(agentId) ?? 0) + 1;
85
+ this.#sequences.set(agentId, sequence);
86
+ const event = {
87
+ type: "inbox_changed",
88
+ agentId,
89
+ sequence,
90
+ epoch: this.#epoch,
91
+ reason: "session_claimed",
92
+ claimedAddress: claim.claimedAddress,
93
+ claimantAddress: claim.claimantAddress,
94
+ claimantId: claim.claimantId,
95
+ };
96
+ this.#fanOut(agentId, event);
97
+ return cloneInboxChanged(event);
98
+ }
74
99
  /**
75
100
  * Subscribe an adapter to body-free notification events.
76
101
  * @param agentId - Agent whose operational events the listener receives.
@@ -50,7 +50,8 @@ export declare class PushDeliveryService {
50
50
  /**
51
51
  * Hold the address a session registered, and deliver anything already waiting for it.
52
52
  *
53
- * An unchanged address is a no-op.
53
+ * An unchanged address is a no-op. A session-claimed wake is the last thing written to an address,
54
+ * which is dropped once it is written.
54
55
  * @param agentId - Recipient whose session supplied the address.
55
56
  * @param raw - Untrusted address text exactly as the session supplied it.
56
57
  * @returns - What the registration changed.
@@ -36,7 +36,8 @@ export class PushDeliveryService {
36
36
  /**
37
37
  * Hold the address a session registered, and deliver anything already waiting for it.
38
38
  *
39
- * An unchanged address is a no-op.
39
+ * An unchanged address is a no-op. A session-claimed wake is the last thing written to an address,
40
+ * which is dropped once it is written.
40
41
  * @param agentId - Recipient whose session supplied the address.
41
42
  * @param raw - Untrusted address text exactly as the session supplied it.
42
43
  * @returns - What the registration changed.
@@ -67,7 +68,7 @@ export class PushDeliveryService {
67
68
  const unsubscribe = this.#dependencies.hub.subscribe(agentId, (event) => {
68
69
  this.#enqueue(agentId, event);
69
70
  });
70
- this.#registrations.set(agentId, { raw, address: parsed.value, capability, unsubscribe });
71
+ this.#registrations.set(agentId, { open: true, raw, address: parsed.value, capability, unsubscribe });
71
72
  this.#dependencies.scheduler.declareAdapter(agentId, { coalescing: true, maxProtocolVersion: 1 });
72
73
  if (this.#dependencies.broker.hasQueuedMessages(agentId))
73
74
  this.#dependencies.scheduler.notify(agentId, "resync");
@@ -78,7 +79,11 @@ export class PushDeliveryService {
78
79
  * @param agentId - Recipient whose address is no longer usable.
79
80
  */
80
81
  forget(agentId) {
81
- this.#registrations.get(agentId)?.unsubscribe();
82
+ const registration = this.#registrations.get(agentId);
83
+ if (registration) {
84
+ registration.open = false;
85
+ registration.unsubscribe();
86
+ }
82
87
  this.#registrations.delete(agentId);
83
88
  }
84
89
  /**
@@ -104,16 +109,19 @@ export class PushDeliveryService {
104
109
  }
105
110
  #enqueue(agentId, event) {
106
111
  const previous = this.#chains.get(agentId) ?? Promise.resolve();
107
- const next = previous.then(() => this.#deliver(agentId, event));
112
+ const registeredAtPublish = this.#registrations.get(agentId);
113
+ const next = previous.then(() => this.#deliver(agentId, event, registeredAtPublish));
108
114
  this.#chains.set(agentId, next);
109
115
  void next.finally(() => {
110
116
  if (this.#chains.get(agentId) === next)
111
117
  this.#chains.delete(agentId);
112
118
  });
113
119
  }
114
- async #deliver(agentId, event) {
115
- const registration = this.#registrations.get(agentId);
116
- if (!registration)
120
+ async #deliver(agentId, event, registeredAtPublish) {
121
+ // A session-claimed wake is for the process that held the identity when it was published; one that
122
+ // registers under the identity afterwards is the claimant.
123
+ const registration = event.reason === "session_claimed" ? registeredAtPublish : this.#registrations.get(agentId);
124
+ if (!registration?.open)
117
125
  return;
118
126
  // A shed wake is the scheduler recording that budget refused this one; the schedule offers the
119
127
  // inbox again later, so nothing is written now.
@@ -125,6 +133,14 @@ export class PushDeliveryService {
125
133
  await this.#write(agentId, registration, event, []);
126
134
  return;
127
135
  }
136
+ if (event.reason === "session_claimed") {
137
+ await this.#write(agentId, registration, event, []);
138
+ // The address belongs to the displaced process, which no longer acts as this identity, so
139
+ // nothing further for the identity goes there; the claiming process registers its own.
140
+ if (this.#registrations.get(agentId) === registration)
141
+ this.forget(agentId);
142
+ return;
143
+ }
128
144
  const claims = this.#dependencies.broker
129
145
  .fetch({ agentId, limit: FETCH_LIMIT_DEFAULT, claimedBy: "delivery" })
130
146
  .map(({ id, fromAddress, broadcastAddress, subject, type, body, claimToken }) => ({
@@ -171,6 +187,7 @@ export class PushDeliveryService {
171
187
  return;
172
188
  case "undelivered":
173
189
  this.#release(agentId, claims, reach.cause);
190
+ registration.open = false;
174
191
  // The session re-registers on its next request if it is still alive.
175
192
  if (this.#registrations.get(agentId) === registration)
176
193
  this.forget(agentId);
@@ -17,23 +17,43 @@ const messageContent = {
17
17
  },
18
18
  body: { description: "Message payload retained for the recipient to claim." },
19
19
  };
20
+ const forceProperty = {
21
+ type: "boolean",
22
+ description: "Take the alias over from the agent holding it, when you are certain that identity is yours: its id, alias, inbox and history become yours, it joins your workspace, or the one you move to, its session is told and ended so its next call runs as a fresh unnamed agent, and your previous alias is redirected to it.",
23
+ };
20
24
  /** Supplies the single discovery declaration consumed by every Pager MCP server. */
21
25
  export const MCP_TOOLS = [
22
26
  {
23
27
  name: "whoami",
24
- description: "Return the authenticated agent id, current alias, and prior aliases.",
28
+ description: "Return the authenticated agent id, current alias, workspace, the address that reaches it from any workspace, and prior aliases.",
25
29
  inputSchema: noArguments,
26
30
  },
27
31
  {
28
32
  name: "update_agent_alias",
29
- description: "Replace this agent's workspace-unique alias and durable session name. The alias is lowercased and inner whitespace becomes hyphens, so use the alias returned in the result rather than the one requested.",
33
+ description: "Replace this agent's workspace-unique alias and durable session name, and return the identity whoami now reports. The sanitised alias is returned in the result. Use the returned value rather than the requested one. An alias another agent holds, or holds as a retired name, fails with alias_conflict naming that agent unless force is set.",
30
34
  inputSchema: schema({
31
35
  alias: {
32
36
  type: "string",
33
37
  description: "New workspace-unique alias, with an optional leading @. Lowercased, and inner whitespace becomes hyphens.",
34
38
  },
39
+ force: forceProperty,
35
40
  }, ["alias"]),
36
41
  },
42
+ {
43
+ name: "move_to_workspace",
44
+ description: "Move this agent into another workspace, keeping its id, inbox and history, and return the identity whoami now reports. Every alias it held stays reserved in the workspace it leaves, so a send to its old address is redirected to its new one. An alias another agent in the target holds, or holds as a retired name, fails with alias_conflict naming that agent unless force is set.",
45
+ inputSchema: schema({
46
+ workspace: {
47
+ type: "string",
48
+ description: "Workspace name as list_agents reports it, or an absolute directory, which names the workspace a session started there would join and may create one.",
49
+ },
50
+ alias: {
51
+ type: "string",
52
+ description: "Alias to hold in the target workspace, replacing the current one in the same step; defaults to the current alias.",
53
+ },
54
+ force: forceProperty,
55
+ }, ["workspace"]),
56
+ },
37
57
  {
38
58
  name: "list_team_members",
39
59
  description: "List agents in this agent's own workspace.",
@@ -23,6 +23,13 @@ export function wakeMetadata(event, brokerEpoch) {
23
23
  coalesced_control_ids: JSON.stringify(event.coalescedControlIds),
24
24
  }
25
25
  : {}),
26
+ ...(event.reason === "session_claimed"
27
+ ? {
28
+ claimed_address: event.claimedAddress,
29
+ claimant_address: event.claimantAddress,
30
+ claimant_id: event.claimantId,
31
+ }
32
+ : {}),
26
33
  };
27
34
  }
28
35
  /**
@@ -35,6 +35,9 @@ export declare class OperatorAgentProjectionError extends Error {
35
35
  }
36
36
  /**
37
37
  * Project broker snapshot repositories and agents without changing broker identities.
38
+ *
39
+ * An agent groups under the repository its workspace projects to. Its worktree, and whether its
40
+ * directory still resolves, are projected from its own working directory.
38
41
  * @param snapshot - Authoritative broker state containing durable identity and attachment facts.
39
42
  * @param dependencies - Explicit repository and implementation collaborators.
40
43
  * @returns Bounded repositories and agents suitable for an operator snapshot.
@@ -59,16 +62,16 @@ export type OperatorAgentHealth = {
59
62
  /**
60
63
  * Report why an agent belongs to the operator archive rather than the active list.
61
64
  *
62
- * Archive membership is not lifecycle alone: an agent whose workspace directory has been removed
65
+ * Archive membership is not lifecycle alone: an agent whose working directory has been removed
63
66
  * can no longer be worked in, so it archives while the broker still holds it active and routable.
64
- * An operator-archived agent whose workspace is still there did not lose its workspace, so a surface
65
- * naming the cause must be told which one it is. The causes can hold together, and the first that holds
66
- * is reported, in this order: the operator's archive, then lifecycle, then a removed workspace. The
67
+ * An operator-archived agent whose directory is still there did not lose it, so a surface naming
68
+ * the cause must be told which one it is. The causes can hold together, and the first that holds
69
+ * is reported, in this order: the operator's archive, then lifecycle, then a removed directory. The
67
70
  * operator's own action comes first because it is why they see the agent archived.
68
71
  * @param agent - Projected agent whose archive cause is requested.
69
72
  * @returns The first cause that holds, or null when the agent is still active.
70
73
  */
71
- export declare function operatorArchiveReason(agent: OperatorAgent): "operator" | "lifecycle" | "workspace_removed" | null;
74
+ export declare function operatorArchiveReason(agent: OperatorAgent): "operator" | "lifecycle" | "directory_removed" | null;
72
75
  /**
73
76
  * Decide whether an agent belongs to the operator archive rather than the active list.
74
77
  * @param agent - Projected agent whose archive membership is requested.
@@ -1,12 +1,15 @@
1
1
  import { uniquePathNames } from "../broker/address.js";
2
2
  import { operatorDiagnostic } from "./diagnostics.js";
3
- import { projectRepositoryLocation } from "./repository.js";
3
+ import { projectRepositoryLocation, } from "./repository.js";
4
4
  import { OPERATOR_ARRAY_LENGTH_LIMIT, validateOperatorAgent, validateOperatorRepository } from "./validation.js";
5
5
  /** Reports an invalid or unbounded projection source. */
6
6
  export class OperatorAgentProjectionError extends Error {
7
7
  }
8
8
  /**
9
9
  * Project broker snapshot repositories and agents without changing broker identities.
10
+ *
11
+ * An agent groups under the repository its workspace projects to. Its worktree, and whether its
12
+ * directory still resolves, are projected from its own working directory.
10
13
  * @param snapshot - Authoritative broker state containing durable identity and attachment facts.
11
14
  * @param dependencies - Explicit repository and implementation collaborators.
12
15
  * @returns Bounded repositories and agents suitable for an operator snapshot.
@@ -15,6 +18,15 @@ export function projectOperatorAgents(snapshot, dependencies) {
15
18
  assertBounded(snapshot.workspaces.length, "workspace projection");
16
19
  assertBounded(snapshot.agents.length, "agent projection");
17
20
  const connections = snapshot.connections;
21
+ const locationsByPath = new Map();
22
+ const locate = (path) => {
23
+ const known = locationsByPath.get(path);
24
+ if (known)
25
+ return known;
26
+ const location = projectRepositoryLocation(path, dependencies);
27
+ locationsByPath.set(path, location);
28
+ return location;
29
+ };
18
30
  const locationsByWorkspace = new Map();
19
31
  const rootPathsById = new Map();
20
32
  const admitted = [];
@@ -22,7 +34,14 @@ export function projectOperatorAgents(snapshot, dependencies) {
22
34
  if (locationsByWorkspace.has(workspace.id)) {
23
35
  throw new OperatorAgentProjectionError(`duplicate broker workspace identity: ${workspace.id}`);
24
36
  }
25
- const location = projectRepositoryLocation(workspace.path, dependencies);
37
+ let location;
38
+ try {
39
+ location = locate(workspace.path);
40
+ }
41
+ catch (error) {
42
+ reportSkipped(dependencies, "repository", index, error);
43
+ continue;
44
+ }
26
45
  const existing = rootPathsById.get(location.repository.id);
27
46
  if (existing !== undefined && existing !== location.repository.rootPath) {
28
47
  throw new OperatorAgentProjectionError(`repository identity collision: ${location.repository.id}`);
@@ -55,6 +74,7 @@ export function projectOperatorAgents(snapshot, dependencies) {
55
74
  continue;
56
75
  }
57
76
  try {
77
+ const workingLocation = locate(agent.workingDirectory);
58
78
  agents.push(validateOperatorAgent({
59
79
  id: agent.id,
60
80
  alias: bareAlias(agent.alias),
@@ -62,10 +82,10 @@ export function projectOperatorAgents(snapshot, dependencies) {
62
82
  registeredAt: agent.createdAt,
63
83
  lifecycle: agent.state,
64
84
  archivedAt: agent.archivedAt,
65
- workspaceAvailable: location.available,
85
+ workspaceAvailable: workingLocation.available,
66
86
  workspace: workspaceNames.get(agent.workspaceId) ?? agent.workspaceId,
67
87
  brokerIdentity: { teamId: agent.teamId, workspaceId: agent.workspaceId },
68
- repository: { repositoryId: location.repository.id, worktree: location.worktree },
88
+ repository: { repositoryId: location.repository.id, worktree: workingLocation.worktree },
69
89
  adapter: adapterFromConnection(connectionsByAgent.get(agent.id)),
70
90
  implementation: projectImplementation(agent, dependencies.implementations),
71
91
  }));
@@ -114,11 +134,11 @@ function reportSkipped(dependencies, scope, index, error) {
114
134
  /**
115
135
  * Report why an agent belongs to the operator archive rather than the active list.
116
136
  *
117
- * Archive membership is not lifecycle alone: an agent whose workspace directory has been removed
137
+ * Archive membership is not lifecycle alone: an agent whose working directory has been removed
118
138
  * can no longer be worked in, so it archives while the broker still holds it active and routable.
119
- * An operator-archived agent whose workspace is still there did not lose its workspace, so a surface
120
- * naming the cause must be told which one it is. The causes can hold together, and the first that holds
121
- * is reported, in this order: the operator's archive, then lifecycle, then a removed workspace. The
139
+ * An operator-archived agent whose directory is still there did not lose it, so a surface naming
140
+ * the cause must be told which one it is. The causes can hold together, and the first that holds
141
+ * is reported, in this order: the operator's archive, then lifecycle, then a removed directory. The
122
142
  * operator's own action comes first because it is why they see the agent archived.
123
143
  * @param agent - Projected agent whose archive cause is requested.
124
144
  * @returns The first cause that holds, or null when the agent is still active.
@@ -129,7 +149,7 @@ export function operatorArchiveReason(agent) {
129
149
  if (agent.lifecycle !== "active")
130
150
  return "lifecycle";
131
151
  if (!agent.workspaceAvailable)
132
- return "workspace_removed";
152
+ return "directory_removed";
133
153
  return null;
134
154
  }
135
155
  /**
@@ -143,9 +143,9 @@ export type OperatorAgent = {
143
143
  */
144
144
  archivedAt: number | null;
145
145
  /**
146
- * Reports whether the recorded workspace path still resolves on the broker's machine.
146
+ * Reports whether the agent's recorded working directory still resolves on the broker's machine.
147
147
  *
148
- * Presentation-only, like the rest of repository grouping: a removed workspace archives its
148
+ * Presentation-only, like the rest of repository grouping: a removed directory archives its
149
149
  * agents in the operator view without changing broker lifecycle, routing, or addressability.
150
150
  */
151
151
  workspaceAvailable: boolean;
@@ -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 {};