agents-can-communicate 0.10.4 → 0.11.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 (98) hide show
  1. package/README.md +29 -21
  2. package/bin/acc-indicator.mjs +5 -0
  3. package/bin/entrypoints/acc-antigravity-relay.mjs +2 -2
  4. package/bin/entrypoints/acc-hook.mjs +4 -24
  5. package/bin/entrypoints/acc-mcp.mjs +8 -6
  6. package/bin/entrypoints/acc.mjs +22 -11
  7. package/bin/entrypoints/antigravity-relay-binding.mjs +1 -1
  8. package/docs/CAPABILITIES.md +3 -0
  9. package/docs/CLI.md +34 -7
  10. package/docs/MCP.md +42 -2
  11. package/docs/PROTOCOL.md +19 -0
  12. package/docs/STATUS_INDICATOR.md +133 -0
  13. package/docs/TROUBLESHOOTING.md +2 -2
  14. package/docs/WHY_ACC.md +14 -8
  15. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
  16. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  17. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/skills/acc/SKILL.md +29 -5
  18. package/node_modules/@agents-can-communicate/adapter-antigravity/src/adapter.mjs +3 -0
  19. package/node_modules/@agents-can-communicate/adapter-antigravity/src/indicator-install.mjs +80 -0
  20. package/node_modules/@agents-can-communicate/adapter-antigravity/src/indicator-render.mjs +8 -0
  21. package/node_modules/@agents-can-communicate/adapter-antigravity/src/install.mjs +7 -4
  22. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  23. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/hooks/indicator.mjs +83 -0
  24. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +29 -5
  25. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +1 -0
  26. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +16 -5
  27. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  28. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +29 -5
  29. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  30. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +29 -5
  31. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  32. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  33. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +29 -5
  34. package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +4 -0
  35. package/node_modules/@agents-can-communicate/adapter-grok/src/indicator-install.mjs +30 -0
  36. package/node_modules/@agents-can-communicate/adapter-grok/src/indicator.mjs +23 -0
  37. package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +9 -5
  38. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  39. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +29 -5
  40. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +4 -0
  41. package/node_modules/@agents-can-communicate/adapter-kimi/src/indicator-install.mjs +30 -0
  42. package/node_modules/@agents-can-communicate/adapter-kimi/src/indicator.mjs +17 -0
  43. package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +12 -6
  44. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  45. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +1 -0
  46. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +5 -1
  47. package/node_modules/@agents-can-communicate/adapter-sdk/src/indicator-command.mjs +14 -0
  48. package/node_modules/@agents-can-communicate/adapter-sdk/src/indicator-text.mjs +13 -0
  49. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt-writer.mjs +1 -1
  50. package/node_modules/@agents-can-communicate/adapter-sdk/src/offer-observation-order.mjs +12 -0
  51. package/node_modules/@agents-can-communicate/adapter-sdk/src/offer-observation-writer.mjs +37 -0
  52. package/node_modules/@agents-can-communicate/adapter-sdk/src/offer-observation.mjs +47 -0
  53. package/node_modules/@agents-can-communicate/adapter-sdk/src/process-table.mjs +40 -27
  54. package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-indicator.mjs +141 -0
  55. package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-scan.mjs +99 -0
  56. package/node_modules/@agents-can-communicate/cli/package.json +8 -1
  57. package/node_modules/@agents-can-communicate/cli/src/adapter-catalog.mjs +33 -0
  58. package/node_modules/@agents-can-communicate/cli/src/args.mjs +5 -3
  59. package/node_modules/@agents-can-communicate/cli/src/claim-command.mjs +64 -0
  60. package/node_modules/@agents-can-communicate/cli/src/help.mjs +17 -11
  61. package/node_modules/@agents-can-communicate/cli/src/index.mjs +1 -0
  62. package/node_modules/@agents-can-communicate/cli/src/indicator-command.mjs +65 -0
  63. package/node_modules/@agents-can-communicate/cli/src/indicator-previous-command.mjs +84 -0
  64. package/node_modules/@agents-can-communicate/cli/src/indicator-previous-windows.mjs +8 -0
  65. package/node_modules/@agents-can-communicate/cli/src/indicator.mjs +112 -0
  66. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +4 -0
  67. package/node_modules/@agents-can-communicate/cli/src/main.mjs +38 -110
  68. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +19 -0
  69. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +5 -3
  70. package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +1 -1
  71. package/node_modules/@agents-can-communicate/cli/src/recorded-text.mjs +32 -0
  72. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  73. package/node_modules/@agents-can-communicate/core/src/claim-selectors.mjs +62 -0
  74. package/node_modules/@agents-can-communicate/core/src/claims.mjs +88 -81
  75. package/node_modules/@agents-can-communicate/core/src/next-turn-delivery.mjs +2 -1
  76. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +5 -2
  77. package/node_modules/@agents-can-communicate/core/src/sync.mjs +2 -1
  78. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  79. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +19 -7
  80. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  81. package/node_modules/@agents-can-communicate/hook-runner/src/client-pid.mjs +23 -11
  82. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +66 -26
  83. package/node_modules/@agents-can-communicate/installer/package.json +3 -2
  84. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +3 -0
  85. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +2 -1
  86. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +11 -2
  87. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  88. package/node_modules/@agents-can-communicate/mcp-server/src/claim-tools.mjs +56 -0
  89. package/node_modules/@agents-can-communicate/mcp-server/src/input-validator.mjs +9 -0
  90. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +7 -8
  91. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +27 -9
  92. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  93. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +1 -0
  94. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +3 -2
  95. package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +1 -1
  96. package/node_modules/@agents-can-communicate/storage-filesystem/src/session-read.mjs +13 -0
  97. package/node_modules/@agents-can-communicate/storage-filesystem/src/state-reads.mjs +24 -7
  98. package/package.json +3 -1
@@ -1,6 +1,7 @@
1
- import { AccError, EXIT, SCHEMA_VERSION, assertMatchableResource, createId,
2
- normaliseResource, validateRecord } from "@agents-can-communicate/protocol";
1
+ import { AccError, EXIT, SCHEMA_VERSION, createId, validateRecord }
2
+ from "@agents-can-communicate/protocol";
3
3
 
4
+ import { claimResources, releaseSelectors, selectReleaseClaims } from "./claim-selectors.mjs";
4
5
  import { ensureMaterialised } from "./materialisation.mjs";
5
6
  import { classifySessionPresence } from "./sessions.mjs";
6
7
 
@@ -33,10 +34,11 @@ const isLive = (claim, now) => Date.parse(claim.expiresAt) > Date.parse(now);
33
34
  export function createClaimService(ports, sessions) {
34
35
  const { store, clock, ids, pidIsAlive } = ports;
35
36
 
36
- async function requireOwner(input, action, tx) {
37
+ async function requireOwner(input, action, tx, allowEphemeral = false) {
37
38
  const current = tx === undefined
38
39
  ? (await sessions.locateSession(input.sessionId, input.workspaceId))?.record
39
- : tx.get("session", input.sessionId);
40
+ : tx.get("session", input.sessionId)
41
+ ?? (allowEphemeral ? await store.ephemeral.get("session", input.sessionId) : null);
40
42
  if (current == null || current.state !== "open") {
41
43
  throw new AccError(EXIT.CONFLICT, `cannot ${action} from a session that is not open`,
42
44
  { sessionId: input.sessionId });
@@ -65,63 +67,66 @@ export function createClaimService(ports, sessions) {
65
67
  });
66
68
  }
67
69
 
68
- async function acquireClaim(input) {
70
+ async function acquire(input, resources, batch) {
69
71
  const session = await requireOwner(input, "claim");
70
- // One name for one file, decided here rather than at each surface, so a
71
- // claim taken over MCP and one taken from the CLI mean the same thing - and
72
- // a shape that could never match is refused rather than stored.
73
- assertMatchableResource(input.resource, message => {
74
- throw new AccError(EXIT.USAGE, message, { resource: input.resource });
75
- });
76
- const resource = normaliseResource(input.resource);
72
+ resources = claimResources(resources);
77
73
  const workspaceId = session.workspaceId;
78
74
  await ensureMaterialised(ports, { workspaceId, descriptor: input.descriptor,
79
75
  reason: "durable_object" });
80
- const now = clock.now();
81
- const expiresAt = new Date(Date.parse(now) + (input.leaseSeconds ?? 1800) * 1000)
82
- .toISOString();
83
- // Only to name the owner of a conflicting claim in the error.
84
- const snapshot = await store.snapshot(workspaceId, { kinds: ["session"] });
85
-
86
- let record = null;
87
- await store.transaction(async tx => {
76
+ return store.transaction(async tx => {
88
77
  await requireOwner(input, "claim", tx);
89
- const live = tx.list("claim", claim => isLive(claim, now));
90
- // This session's own claim on this resource, live or lapsed. Looking only
91
- // among the live ones left the expired record behind and made a fresh one
92
- // beside it, so taking a resource back after a lease ran out reported
93
- // success while the workspace still held a dead claim saying otherwise -
94
- // and the owner went on being told its claim had run out.
95
- const mine = tx.list("claim", claim => claim.ownerSessionId === session.sessionId
96
- && claim.resource === resource).at(0);
97
- const blocking = live.find(claim => overlaps(claim.resource, resource)
98
- && claim.ownerSessionId !== session.sessionId
99
- && (claim.mode === "exclusive" || input.mode === "exclusive"));
100
- if (blocking !== undefined) throw conflictWith(blocking, session, now, snapshot.sessions);
101
-
102
- const claimId = mine?.claimId ?? createId("claim");
103
- record = validateRecord("claim", {
104
- schemaVersion: SCHEMA_VERSION,
105
- claimId,
106
- workspaceId,
107
- ownerSessionId: session.sessionId,
108
- resource,
109
- mode: input.mode ?? "exclusive",
110
- enforcement: input.enforcement ?? "advisory",
111
- reason: input.reason,
112
- // A lapsed claim taken again is a new period of holding it, not a
113
- // continuation of one that ended.
114
- acquiredAt: mine !== undefined && isLive(mine, now) ? mine.acquiredAt : now,
115
- expiresAt,
116
- generation: ids.next("generation"),
78
+ const now = clock.now();
79
+ const expiresAt = new Date(Date.parse(now) + (input.leaseSeconds ?? 1800) * 1000)
80
+ .toISOString();
81
+ const existing = tx.list("claim");
82
+ const live = existing.filter(claim => isLive(claim, now));
83
+ const mode = input.mode ?? "exclusive";
84
+ const conflicts = resources.flatMap(resource => live
85
+ .filter(claim => overlaps(claim.resource, resource)
86
+ && claim.ownerSessionId !== session.sessionId
87
+ && (claim.mode === "exclusive" || mode === "exclusive"))
88
+ .map(claim => ({ requestedResource: resource,
89
+ ...conflictWith(claim, session, now, tx.list("session")).details })));
90
+ if (conflicts.length > 0) {
91
+ if (!batch) {
92
+ const { requestedResource, ...details } = conflicts[0];
93
+ throw new AccError(EXIT.CONFLICT, "the resource is already claimed", details);
94
+ }
95
+ const message = "batch claim conflicts:\n" + conflicts.map(conflict =>
96
+ `${conflict.requestedResource} overlaps ${conflict.resource}, held by ${conflict.ownerSessionId}`)
97
+ .join("\n");
98
+ throw new AccError(EXIT.CONFLICT, message, { conflicts });
99
+ }
100
+ // Validate every record before staging a write, including renewals.
101
+ const records = resources.map(resource => {
102
+ const mine = existing.find(claim => claim.ownerSessionId === session.sessionId
103
+ && claim.resource === resource);
104
+ const claimId = mine?.claimId ?? createId("claim");
105
+ return { mine, record: validateRecord("claim", {
106
+ schemaVersion: SCHEMA_VERSION,
107
+ claimId,
108
+ workspaceId,
109
+ ownerSessionId: session.sessionId,
110
+ resource,
111
+ mode,
112
+ enforcement: input.enforcement ?? "advisory",
113
+ reason: input.reason,
114
+ // Reuse a lapsed record, but start a new period of holding it.
115
+ acquiredAt: mine !== undefined && isLive(mine, now) ? mine.acquiredAt : now,
116
+ expiresAt,
117
+ generation: ids.next("generation"),
118
+ }) };
117
119
  });
118
- tx.put("claim", claimId, record, tx.generationOf("claim", claimId));
119
- tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
120
- actorSessionId: session.sessionId, type: mine === undefined ? "claim.acquired"
121
- : "claim.renewed", occurredAt: now,
122
- payload: { claimId, resource, mode: record.mode } });
120
+ for (const { mine, record } of records) {
121
+ const { claimId, resource } = record;
122
+ tx.put("claim", claimId, record, tx.generationOf("claim", claimId));
123
+ tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
124
+ actorSessionId: session.sessionId, type: mine === undefined ? "claim.acquired"
125
+ : "claim.renewed", occurredAt: now,
126
+ payload: { claimId, resource, mode: record.mode } });
127
+ }
128
+ return records.map(item => item.record);
123
129
  }, { kinds: ["session", "claim"] });
124
- return record;
125
130
  }
126
131
 
127
132
  async function renewClaim(input) {
@@ -146,40 +151,42 @@ export function createClaimService(ports, sessions) {
146
151
  return record;
147
152
  }
148
153
 
149
- async function removeClaim(input, { authority, reason }) {
154
+ async function removeClaims(input, selectors, { authority = null, reason = null } = {}) {
150
155
  const session = await requireOwner(input, "release");
151
- const now = clock.now();
152
- await store.transaction(async tx => {
153
- await requireOwner(input, "release", tx);
154
- const existing = tx.get("claim", input.claimId);
155
- if (existing === null) {
156
- throw new AccError(EXIT.CONFLICT, "the claim does not exist", { claimId: input.claimId });
156
+ return store.transaction(async tx => {
157
+ await requireOwner(input, "release", tx, true);
158
+ const now = clock.now();
159
+ const selected = selectReleaseClaims(tx, selectors, session.sessionId);
160
+ for (const existing of selected) {
161
+ const owned = existing.ownerSessionId === session.sessionId;
162
+ if (!owned && authority !== "human" && authority !== "policy") {
163
+ throw new AccError(EXIT.CONFLICT,
164
+ "force release requires human or policy authority",
165
+ { claimId: existing.claimId, authority });
166
+ }
157
167
  }
158
- const owned = existing.ownerSessionId === session.sessionId;
159
- // Force release is an authority decision, not a peer decision. A
160
- // coordinator lease does not extend outside its own workstream.
161
- if (!owned && authority !== "human" && authority !== "policy") {
162
- throw new AccError(EXIT.CONFLICT,
163
- "force release requires human or policy authority",
164
- { claimId: input.claimId, authority: authority ?? null });
168
+ for (const existing of selected) {
169
+ const owned = existing.ownerSessionId === session.sessionId;
170
+ // Remove the live record; per-resource events retain its history.
171
+ tx.remove("claim", existing.claimId, tx.generationOf("claim", existing.claimId));
172
+ tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
173
+ workspaceId: existing.workspaceId, actorSessionId: session.sessionId,
174
+ type: owned ? "claim.released" : "claim.force_released", occurredAt: now,
175
+ payload: { claimId: existing.claimId, authority,
176
+ reason, replacedGeneration: existing.generation } });
165
177
  }
166
- // The claim record is removed rather than tombstoned: a released claim is
167
- // not a claim, and every consumer of a snapshot would otherwise have to
168
- // re-derive liveness. The event log keeps the history.
169
- tx.remove("claim", input.claimId, tx.generationOf("claim", input.claimId));
170
- tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
171
- workspaceId: existing.workspaceId, actorSessionId: session.sessionId,
172
- type: owned ? "claim.released" : "claim.force_released", occurredAt: now,
173
- payload: { claimId: input.claimId, authority: authority ?? null,
174
- reason: reason ?? null, replacedGeneration: existing.generation } });
178
+ return selected;
175
179
  }, { kinds: ["session", "claim"] });
176
180
  }
177
181
 
178
182
  return {
179
- acquireClaim,
183
+ acquireClaim: async input => (await acquire(input, [input.resource], false))[0],
184
+ acquireClaims: input => acquire(input, input.resources, true),
180
185
  renewClaim,
181
- releaseClaim: input => removeClaim(input, { authority: null, reason: null }),
182
- forceReleaseClaim: input => removeClaim(input,
183
- { authority: input.authority, reason: input.reason }),
186
+ releaseClaims: async input => removeClaims(input, releaseSelectors(input)),
187
+ releaseClaim: async input => { await removeClaims(input, { claimIds: [input.claimId] }); },
188
+ forceReleaseClaim: async input => { await removeClaims(input, { claimIds: [input.claimId] },
189
+ { authority: input.authority, reason: input.reason });
190
+ },
184
191
  };
185
192
  }
@@ -6,7 +6,8 @@ export async function nextTurnDelivery(store, input = {}) {
6
6
  return { queuedMessages: [], liveOfferedMessageIds: [], reminderMessageIds: [],
7
7
  roomMessageIds: [], repeatMessages: [], repeatOffers: [] };
8
8
  }
9
- const snapshot = await store.snapshot(input.workspaceId ?? store.workspaceId,
9
+ // A caller that has just read the store hands that read over (#298).
10
+ const snapshot = input.snapshot ?? await store.snapshot(input.workspaceId ?? store.workspaceId,
10
11
  { kinds: ["message", "receipt"] });
11
12
  const receiptByMessage = new Map(snapshot.receipts
12
13
  .filter(item => item.recipientParticipantId === input.participantId)
@@ -101,12 +101,15 @@ export function createSessionService(ports) {
101
101
  : await store.ephemeral.update("session", sessionId, async current =>
102
102
  current !== null && !await isMaterialised(store, current.workspaceId) ? beat(current) : null);
103
103
  if (ephemeral !== null) return ephemeral;
104
- return store.transaction(tx => {
104
+ // One session is read, not every session the workspace ever had: a turn's
105
+ // heartbeat took about a second in a workspace with 2,234 of them.
106
+ return store.transaction(async tx => {
107
+ await tx.load("session", sessionId);
105
108
  const beaten = beat(tx.get("session", sessionId));
106
109
  if (beaten === null) throw new AccError(EXIT.CONFLICT, "session is not open", { sessionId });
107
110
  tx.put("session", sessionId, beaten, tx.generationOf("session", sessionId));
108
111
  return beaten;
109
- }, { kinds: ["session"] });
112
+ }, { kinds: ["session"], exactKinds: ["session"] });
110
113
  }
111
114
 
112
115
  /**
@@ -71,7 +71,8 @@ export function createSyncService(ports, sessions) {
71
71
  const located = input.sessionId === undefined
72
72
  ? null : await sessions.locateSession(input.sessionId, workspaceId);
73
73
  const session = located?.record ?? null;
74
- const durable = await store.snapshot(workspaceId);
74
+ // A caller that reads the store once for a whole turn hands that read over (#298).
75
+ const durable = input.snapshot ?? await store.snapshot(workspaceId);
75
76
  const snapshot = durable.workspace !== null
76
77
  ? durable
77
78
  : { ...durable,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/delivery-router",
3
- "version": "0.10.4",
3
+ "version": "0.11.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,12 +1,10 @@
1
- import { NATIVE_REASON_CODES, evaluateVersionContract, isLaunchOption }
1
+ import { NATIVE_REASON_CODES, evaluateVersionContract, isLaunchOption, OFFER_ERRORS, storeOfferObservation }
2
2
  from "@agents-can-communicate/adapter-sdk";
3
3
 
4
4
  import { lastNativeReason } from "./native-reason.mjs";
5
5
  import { refreshExpiredBinding } from "./refresh-binding.mjs";
6
6
 
7
- const SAFE_ERRORS = new Set(["ambiguous_recipient_sessions", "delivery_disabled",
8
- "recipient_busy", "recipient_unavailable", "transport_error", "transport_rejected", "transport_permission_denied",
9
- "unsupported_client_version"]);
7
+ const SAFE_ERRORS = new Set(OFFER_ERRORS);
10
8
  const NAMED_LIVE_TRANSPORTS = new Set(["claude-inbox", "codex-app-server"]);
11
9
 
12
10
  const adaptersById = adapters => adapters instanceof Map
@@ -47,9 +45,20 @@ function safeTransport(value, opaqueEndpointRef) {
47
45
  return opaqueEndpointRef === "live-adapter" ? "native-live" : "live-adapter";
48
46
  }
49
47
 
50
- export function createDeliveryRouter({ service, adapters, clock, readLivePolicy,
48
+ export function createDeliveryRouter({ service, adapters, loadAdapter, clock, readLivePolicy,
51
49
  readNativeReason = input => lastNativeReason({ runtimeDir: service.store?.root, ...input }) }) {
52
50
  const registry = adaptersById(adapters);
51
+ // A command that routes one message loads only its recipients' adapters (#270).
52
+ const adapterFor = typeof loadAdapter === "function" ? loadAdapter
53
+ : async adapterId => registry.get(adapterId);
54
+ const observeOffer = (binding, reasonCode = null) => {
55
+ // Capture time and order now, but never await optional diagnostic I/O on
56
+ // the delivery path. The SDK bounds its own lifetime for short-lived CLIs.
57
+ void storeOfferObservation({
58
+ runtimeDir: service.store?.root, workspaceId: service.store?.workspaceId,
59
+ sessionId: binding.sessionId, generation: binding.generation, at: clock.now(),
60
+ state: reasonCode === null ? "active" : "degraded", reasonCode }).catch(() => {});
61
+ };
53
62
 
54
63
  // A session that bound no live transport may have said why: the reason its
55
64
  // own last native attempt recorded, from the closed vocabulary only, and the
@@ -82,6 +91,7 @@ export function createDeliveryRouter({ service, adapters, clock, readLivePolicy,
82
91
  targetGeneration: binding.generation,
83
92
  transport: safeTransport(transport, binding.opaqueEndpointRef), adapterId: binding.adapterId,
84
93
  clientVersion: binding.clientVersion, safeErrorCode }).catch(() => null);
94
+ observeOffer(binding, safeErrorCode);
85
95
  }
86
96
 
87
97
  async function offerTo(message, participantId, now) {
@@ -112,7 +122,7 @@ export function createDeliveryRouter({ service, adapters, clock, readLivePolicy,
112
122
  // that bound no live transport. It reads its inbox, so it is not gone.
113
123
  if (bindings.length === 0) return withoutLiveTransport(participantId, target);
114
124
  const evaluated = await Promise.all(bindings.map(async binding => {
115
- const adapter = registry.get(binding.adapterId);
125
+ const adapter = await adapterFor(binding.adapterId);
116
126
  return { binding, adapter, policy: await policyFor(adapter, binding) };
117
127
  }));
118
128
  const permitted = evaluated.filter(({ policy }) => permits(policy, message.kind));
@@ -135,7 +145,7 @@ export function createDeliveryRouter({ service, adapters, clock, readLivePolicy,
135
145
  if (!refreshed.refreshed) {
136
146
  if (refreshed.errorCode === "transport_permission_denied") {
137
147
  await recordFailure(binding, message, participantId, "live-adapter", refreshed.errorCode);
138
- }
148
+ } else observeOffer(binding, refreshed.errorCode);
139
149
  return durable(participantId, refreshed.errorCode);
140
150
  }
141
151
  const current = (await service.listDeliveryBindings({
@@ -216,6 +226,7 @@ export function createDeliveryRouter({ service, adapters, clock, readLivePolicy,
216
226
  // shown and leave it out of the very turn the wake started. The receipt
217
227
  // stays queued; the hook records the offer once its stdout carried the body.
218
228
  if (adapter.nativeDelivery?.offerKind === "wake") {
229
+ observeOffer(binding, response.pendingApproval === true ? "inbound_approval_required" : null);
219
230
  // The client took the wake but holds it for its user's approval.
220
231
  return { recipientParticipantId: participantId, outcome: "woken", transport,
221
232
  ...(response.pendingApproval === true ? { pendingApproval: true } : {}) };
@@ -229,6 +240,7 @@ export function createDeliveryRouter({ service, adapters, clock, readLivePolicy,
229
240
  await recordFailure(binding, message, participantId, transport, "transport_error");
230
241
  return durable(participantId, "transport_error");
231
242
  }
243
+ observeOffer(binding);
232
244
  return { recipientParticipantId: participantId, outcome: "offered", transport };
233
245
  }
234
246
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.10.4",
3
+ "version": "0.11.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -102,19 +102,31 @@ export function resolveClient({ table, from, command, clientPackage, identify,
102
102
  const entry = table.get(current);
103
103
  if (entry === undefined || seen.has(current)) return null;
104
104
  seen.add(current);
105
- const product = recognised(identify, entry);
106
- if (product !== null) {
107
- return { pid: current, entry, certificationName: product.certificationName,
108
- version: product.version };
109
- }
110
- // `ps` reports some entries bare (`claude`) and some with a path
111
- // (`/bin/zsh`), so the comparison has to be on the basename.
112
- if (imageName(entry.comm) === command
113
- || scriptsOf(entry, clientPackage).some(name => name === command || name === CLIENT_PACKAGE)) {
114
- return { pid: current, entry, certificationName: undefined, version: undefined };
115
- }
105
+ const match = clientMatch(entry, { command, clientPackage, identify });
106
+ if (match !== null) return { pid: current, entry, ...match };
116
107
  if (entry.ppid === current || entry.ppid <= 1) return null;
117
108
  current = entry.ppid;
118
109
  }
119
110
  return null;
120
111
  }
112
+
113
+ /** What one process-table entry is as the declared client, or null. */
114
+ function clientMatch(entry, { command, clientPackage, identify }) {
115
+ const product = recognised(identify, entry);
116
+ if (product !== null) {
117
+ return { certificationName: product.certificationName, version: product.version };
118
+ }
119
+ // `ps` reports some entries bare (`claude`) and some with a path
120
+ // (`/bin/zsh`), so the comparison has to be on the basename.
121
+ if (imageName(entry.comm) === command
122
+ || scriptsOf(entry, clientPackage).some(name => name === command || name === CLIENT_PACKAGE)) {
123
+ return { certificationName: undefined, version: undefined };
124
+ }
125
+ return null;
126
+ }
127
+
128
+ /** Whether one entry is the client, as `resolveClient` would decide: a table
129
+ * read can stop there instead of reading the client's own parents. */
130
+ export function isClientProcess(entry, { command, clientPackage, identify }) {
131
+ return clientMatch(entry, { command, clientPackage, identify }) !== null;
132
+ }
@@ -15,7 +15,7 @@ import { clearPin, createGitProbe, resolveHookWorkspace, platformDataHome, runti
15
15
  writePin as writeRuntimePin }
16
16
  from "@agents-can-communicate/cli/hook-support";
17
17
 
18
- import { resolveClient } from "./client-pid.mjs";
18
+ import { isClientProcess, resolveClient } from "./client-pid.mjs";
19
19
  import { ownExecutable, probeClientVersion as defaultProbeClientVersion, probeExecutableVersion }
20
20
  from "./client-version.mjs";
21
21
  import { bindNative, callRelease, nativeActivationHintFor, nativeDiagnosticDeadline }
@@ -44,6 +44,17 @@ const WINDOWS = process.platform === "win32";
44
44
  const PROCESS_TABLE_MS = WINDOWS ? 3_000 : 1_000;
45
45
  const TABLE_RESERVE_MS = WINDOWS ? 500 : 0;
46
46
 
47
+ // How this adapter's client is recognised in a process table.
48
+ const clientOf = adapter => ({ command: adapter.client?.command ?? null,
49
+ clientPackage: adapter.client?.package, identify: adapter.identifyClientProcess });
50
+
51
+ // One bounded table read for a hook, which ends at the client it is looking for.
52
+ function readClientTable(readProcessTable, adapter, deadline) {
53
+ return Promise.resolve(readProcessTable({
54
+ timeoutMs: Math.max(1, Math.min(PROCESS_TABLE_MS, deadline - Date.now() - TABLE_RESERVE_MS)),
55
+ until: entry => isClientProcess(entry, clientOf(adapter)) }));
56
+ }
57
+
47
58
  function assertHookBudget(deadline, message = "hook deadline expired") {
48
59
  if (Date.now() >= deadline) throw new Error(message);
49
60
  }
@@ -252,8 +263,12 @@ async function openContext({ event, adapterId, dataHome, runtime, env, deadline
252
263
  // the adapter's context envelope, at the end of one it is a continuation.
253
264
  async function projectTurn({ binding, context, adapter, adapterId, render = null,
254
265
  activationHint = null, activationNotice = null, repeats = true }) {
266
+ // One read of the store serves the whole projection. Sync and next-turn
267
+ // delivery each read every message and receipt the workspace held, twice a
268
+ // prompt, and nothing is written between the two (#298).
269
+ const snapshot = await context.service.store.snapshot(context.descriptor.id);
255
270
  const sync = await context.service.sync({ sessionId: binding.accSessionId,
256
- cursor: null, scope: "delta" });
271
+ cursor: null, scope: "delta", snapshot });
257
272
 
258
273
  // Sync already carries the roster. Calling collectStatus here used to read
259
274
  // the entire materialised store a second time on every prompt merely to
@@ -277,7 +292,7 @@ async function projectTurn({ binding, context, adapter, adapterId, render = null
277
292
  workspaceId: context.descriptor.id,
278
293
  participantId: mine?.participantId,
279
294
  exceptSessionId: binding.accSessionId,
280
- repeats: repeats && canOfferNextTurn });
295
+ repeats: repeats && canOfferNextTurn, snapshot });
281
296
  // New bodies first, so a repeat is what the byte budget drops.
282
297
  const messages = [...delivery.queuedMessages, ...delivery.repeatMessages];
283
298
  const repeating = new Set(delivery.repeatMessages.map(message => message.messageId));
@@ -498,8 +513,8 @@ const HANDLERS = {
498
513
  // each can start a program. A session start began the read already, when
499
514
  // its event was known. Handled here so a probe that fails first leaves no
500
515
  // rejection unobserved.
501
- const tableRead = known || command === null ? null : started ?? Promise.resolve(readProcessTable({
502
- timeoutMs: Math.max(1, Math.min(PROCESS_TABLE_MS, deadline - Date.now() - TABLE_RESERVE_MS)) }));
516
+ const tableRead = known || command === null ? null
517
+ : started ?? readClientTable(readProcessTable, adapter, deadline);
503
518
  tableRead?.catch(() => {});
504
519
  // The binary on PATH answers in parallel, as the fallback: the version the
505
520
  // running client names for itself, when it names one, wins below.
@@ -507,8 +522,7 @@ const HANDLERS = {
507
522
  { timeoutMs: Math.max(1, Math.min(1_000, deadline - Date.now())) });
508
523
  mark("reading the process table");
509
524
  const client = known ? { pid: knownClientPid } : command === null ? null
510
- : resolveClient({ table: await tableRead, from: process.pid, command,
511
- clientPackage: adapter.client?.package, identify: adapter.identifyClientProcess });
525
+ : resolveClient({ table: await tableRead, from: process.pid, ...clientOf(adapter) });
512
526
  assertHookBudget(deadline);
513
527
  mark("probing the client version");
514
528
  const product = declaredProduct(adapter, client);
@@ -535,9 +549,9 @@ const HANDLERS = {
535
549
  };
536
550
  if (event.kind === "beforeTurn" && binding !== null) {
537
551
  // A genuine prompt can resume after an ephemeral removal or a durable
538
- // close, or carry on a session whose client restarted. Recheck after
539
- // probes: a CLI replacement can run outside the native lifecycle lock.
540
- // Never adopt that replacement's identity.
552
+ // close, or carry on a session whose client restarted or was found only
553
+ // now. Recheck after probes: a CLI replacement can run outside the
554
+ // native lifecycle lock. Never adopt that replacement's identity.
541
555
  // A session can also close between the turn's check and this one while
542
556
  // its client was down; that is the closed-session resumption below,
543
557
  // not a replacement.
@@ -665,14 +679,28 @@ const HANDLERS = {
665
679
  const turn = await projectActivation({ ...input, binding: fresh }, offered);
666
680
  return { ...turn, nativeBinding: started.nativeBinding };
667
681
  }
668
- if (clientGone(binding)) {
682
+ // A start that could not find its client opened the session without it:
683
+ // on 2026-10-12, at load average 56-84, the table read outlasted its limit.
684
+ // Every turn looks again until the client is found; nothing else would,
685
+ // and the session would stay without live delivery for its whole life.
686
+ // Only the open generation this binding names is rebound; a stale binding
687
+ // never reaches the start path, whatever the table says.
688
+ const known = Number.isInteger(binding.clientPid) && binding.clientPid > 0;
689
+ const bindable = current.record.state === "open" && current.record.generation === binding.generation;
690
+ const found = known || !bindable || clientOf(adapter).command === null ? null
691
+ : await readClientTable(input.readProcessTable, adapter, deadline);
692
+ const lateClient = found !== null
693
+ && resolveClient({ table: found, from: process.pid, ...clientOf(adapter) }) !== null;
694
+ if (lateClient || clientGone(binding)) {
669
695
  // The client this session was bound to has exited, and a turn arrived
670
696
  // anyway: the client restarted under the same conversation. Antigravity
671
697
  // 2.0 reopens a conversation without a second SessionStart, so without
672
698
  // this the session stayed bound to a dead process and lost live delivery
673
699
  // for good. The start path finds the client running now, keeps this ACC
674
- // session and generation, and binds again.
675
- const started = await HANDLERS.sessionStart({ ...input, clientRestarted: true });
700
+ // session and generation, and binds again. A client found only now
701
+ // takes the same path, with the table this turn already read.
702
+ const started = await HANDLERS.sessionStart({ ...input, clientRestarted: true,
703
+ ...(lateClient ? { tableRead: Promise.resolve(found) } : {}) });
676
704
  const fresh = await loadSessionBinding({ runtimeDir: paths.root,
677
705
  harnessSessionId: event.sessionId });
678
706
  const offered = await nativeActivationHintFor({ adapter, event,
@@ -809,7 +837,7 @@ export async function runHook({ adapterId, payload, adapters, dataHome, env,
809
837
  runtime = defaultRuntime(), budgetMs = DEFAULT_BUDGET_MS,
810
838
  readProcessTable = defaultReadProcessTable,
811
839
  probeClientVersion = defaultProbeClientVersion,
812
- platform = `${process.platform}-${process.arch}` }) {
840
+ platform = `${process.platform}-${process.arch}`, includeSessions = false }) {
813
841
  const deadline = Date.now() + budgetMs;
814
842
  const fallback = { stdout: "", exitCode: 0, decision: "allow", sessions: [], deadlineAt: deadline,
815
843
  commitOffers: async () => {} };
@@ -827,8 +855,7 @@ export async function runHook({ adapterId, payload, adapters, dataHome, env,
827
855
  // below: it starts now, while the store opens. On Windows it is the part
828
856
  // of a start that runs another program.
829
857
  const tableRead = event.kind !== "sessionStart" || (adapter.client?.command ?? null) === null
830
- ? null : Promise.resolve(readProcessTable({ timeoutMs: Math.max(1,
831
- Math.min(PROCESS_TABLE_MS, deadline - Date.now() - TABLE_RESERVE_MS)) }));
858
+ ? null : readClientTable(readProcessTable, adapter, deadline);
832
859
  tableRead?.catch(() => {});
833
860
  mark("finding the workspace");
834
861
  const context = await openContext({ event, adapterId, dataHome, runtime, env, deadline });
@@ -881,11 +908,16 @@ export async function runHook({ adapterId, payload, adapters, dataHome, env,
881
908
  };
882
909
  delete result.offerInputs;
883
910
 
884
- mark("reading the status");
885
- const status = await context.service.collectStatus({
886
- workspaceId: context.descriptor.id });
887
- assertHookBudget(deadline);
888
- result.sessions = status.participants.filter(p => p.presence !== "offline");
911
+ // Who is present, for a caller that asks; the hook entry point never does.
912
+ // Reading it took the whole store a second time on every hook, 0.4 s to 4 s
913
+ // of the five-second budget in real workspaces (#298).
914
+ if (includeSessions) {
915
+ mark("reading the status");
916
+ const status = await context.service.collectStatus({
917
+ workspaceId: context.descriptor.id });
918
+ assertHookBudget(deadline);
919
+ result.sessions = status.participants.filter(p => p.presence !== "offline");
920
+ }
889
921
  result.service = context.service;
890
922
  return result;
891
923
  };
@@ -988,8 +1020,8 @@ export async function registerNativeSession({ adapterId, adapters, event: target
988
1020
  * register those in this workspace. Bounded by one budget and fail-open; the
989
1021
  * answer lists what was registered and is otherwise informational.
990
1022
  */
991
- export async function registerDiscoveredSessions({ adapters, workspaceId, dataHome, env = {},
992
- budgetMs = 2_500, runtime, probeClientVersion, platform }) {
1023
+ export async function registerDiscoveredSessions({ adapters, loadAdapters, workspaceId, dataHome,
1024
+ env = {}, budgetMs = 2_500, runtime, probeClientVersion, platform }) {
993
1025
  const deadline = Date.now() + budgetMs;
994
1026
  const remaining = () => Math.max(0, deadline - Date.now());
995
1027
  // An adapter that ignores its timeout must not hold status: its answer is
@@ -1001,19 +1033,27 @@ export async function registerDiscoveredSessions({ adapters, workspaceId, dataHo
1001
1033
  .finally(() => clearTimeout(timer));
1002
1034
  };
1003
1035
  const registered = [];
1004
- let installed;
1036
+ let installed, registry = adapters;
1005
1037
  try {
1006
1038
  installed = new Set((await loadOwnership({ dataHome })).installs.map(entry => entry.adapterId));
1007
1039
  } catch {
1008
1040
  return registered;
1009
1041
  }
1010
- for (const adapter of Object.values(adapters ?? {})) {
1042
+ // A caller that loads adapters on demand loads only the installed ones (#270),
1043
+ // within the same budget: a loader that fails or has not answered when it runs
1044
+ // out leaves nothing to ask, the answer for a client with no adapter.
1045
+ if (typeof loadAdapters === "function") {
1046
+ const loaded = await withinBudget(() => loadAdapters([...installed]));
1047
+ if (Array.isArray(loaded)) return registered;
1048
+ registry = loaded;
1049
+ }
1050
+ for (const adapter of Object.values(registry ?? {})) {
1011
1051
  if (typeof adapter?.discoverNativeSessions !== "function" || !installed.has(adapter.id)) continue;
1012
1052
  const found = await withinBudget(() => adapter.discoverNativeSessions({ env,
1013
1053
  timeoutMs: remaining() }));
1014
1054
  for (const candidate of Array.isArray(found) ? found : []) {
1015
1055
  if (remaining() === 0) return registered;
1016
- const result = await registerNativeSession({ adapterId: adapter.id, adapters,
1056
+ const result = await registerNativeSession({ adapterId: adapter.id, adapters: registry,
1017
1057
  event: { sessionId: candidate?.sessionId, cwd: candidate?.cwd }, clientPid: candidate?.clientPid,
1018
1058
  workspaceId, dataHome, env, budgetMs: remaining(),
1019
1059
  ...(runtime ? { runtime } : {}), ...(probeClientVersion ? { probeClientVersion } : {}),
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/installer",
3
- "version": "0.10.4",
3
+ "version": "0.11.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/index.mjs",
8
- "./hook-support": "./src/hook-support.mjs"
8
+ "./hook-support": "./src/hook-support.mjs",
9
+ "./live-policy": "./src/live-policy.mjs"
9
10
  },
10
11
  "files": [
11
12
  "src/"
@@ -39,6 +39,7 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
39
39
  const createdDirectories = await missingArtifactParents({ home: context.home,
40
40
  artifacts: operation.artifacts });
41
41
  const installContext = { ...context,
42
+ indicator: operation.indicator,
42
43
  clientVersion: operation.clientVersion, platform: operation.platform,
43
44
  requestedLivePolicy: operation.livePolicy ?? "off",
44
45
  livePolicy: operation.configuredLivePolicy ?? operation.effectiveLivePolicy ?? "off" };
@@ -71,10 +72,12 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
71
72
  version: operation.clientVersion ?? null, accVersion,
72
73
  artifacts: operation.artifacts, createdDirectories,
73
74
  deliveryPolicy: operation.livePolicy, deliveryDecision: operation.deliveryDecision,
75
+ indicator: operation.indicator,
74
76
  nativeActivation: native });
75
77
  results.operations.push({ ...operation, applied: true,
76
78
  needsAction: outcome.needsAction ?? [],
77
79
  changes: outcome.changes ?? [], diagnostics: [
80
+ ...(operation.indicatorDiagnostic === undefined ? [] : [operation.indicatorDiagnostic]),
78
81
  ...(operation.deliveryDiagnostic === undefined
79
82
  ? [] : [operation.deliveryDiagnostic]),
80
83
  ...(outcome.diagnostics ?? []),