agents-can-communicate 0.11.0 → 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 (42) hide show
  1. package/bin/entrypoints/acc-antigravity-relay.mjs +2 -2
  2. package/bin/entrypoints/acc-hook.mjs +4 -24
  3. package/bin/entrypoints/acc-mcp.mjs +8 -6
  4. package/bin/entrypoints/acc.mjs +22 -11
  5. package/bin/entrypoints/antigravity-relay-binding.mjs +1 -1
  6. package/docs/STATUS_INDICATOR.md +8 -3
  7. package/docs/TROUBLESHOOTING.md +2 -2
  8. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
  9. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  10. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  11. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/hooks/indicator.mjs +29 -13
  12. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  13. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  14. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  15. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  16. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  17. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  18. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt-writer.mjs +1 -1
  19. package/node_modules/@agents-can-communicate/adapter-sdk/src/process-table.mjs +40 -27
  20. package/node_modules/@agents-can-communicate/cli/package.json +7 -1
  21. package/node_modules/@agents-can-communicate/cli/src/adapter-catalog.mjs +33 -0
  22. package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -3
  23. package/node_modules/@agents-can-communicate/cli/src/indicator-command.mjs +2 -2
  24. package/node_modules/@agents-can-communicate/cli/src/indicator.mjs +4 -4
  25. package/node_modules/@agents-can-communicate/cli/src/main.mjs +38 -60
  26. package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +1 -1
  27. package/node_modules/@agents-can-communicate/cli/src/recorded-text.mjs +32 -0
  28. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  29. package/node_modules/@agents-can-communicate/core/src/next-turn-delivery.mjs +2 -1
  30. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +5 -2
  31. package/node_modules/@agents-can-communicate/core/src/sync.mjs +2 -1
  32. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  33. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +5 -2
  34. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  35. package/node_modules/@agents-can-communicate/hook-runner/src/client-pid.mjs +23 -11
  36. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +66 -26
  37. package/node_modules/@agents-can-communicate/installer/package.json +3 -2
  38. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  39. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  40. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +3 -2
  41. package/node_modules/@agents-can-communicate/storage-filesystem/src/state-reads.mjs +24 -7
  42. package/package.json +1 -1
@@ -5,10 +5,6 @@
5
5
  import { AccError, CONFIG_FILENAME, EXIT, GENERIC_MESSAGE_KINDS, VALID_OBLIGATIONS,
6
6
  failure, ok }
7
7
  from "@agents-can-communicate/protocol";
8
- import { createCoordinationService } from "@agents-can-communicate/core";
9
- import { shellQuote } from "@agents-can-communicate/adapter-sdk";
10
- import { describeNativeReason } from "@agents-can-communicate/installer";
11
- import { openFilesystemStore } from "@agents-can-communicate/storage-filesystem";
12
8
 
13
9
  import { parseArgs, positiveNumber } from "./args.mjs";
14
10
 
@@ -16,18 +12,10 @@ import { parseArgs, positiveNumber } from "./args.mjs";
16
12
  // with the argument already half-applied.
17
13
  const usage = message => new AccError(EXIT.USAGE, message);
18
14
  import { commandHelpText, describeCommand, describeCommands, helpText } from "./help.mjs";
19
- import { runUpdateCommand } from "./update-command.mjs";
20
- import { runConfigCommand } from "./config-command.mjs";
21
- import { ALL_ADAPTERS, runInstallCommand } from "./install-command.mjs";
22
- import { runDoctor } from "./doctor-command.mjs";
23
- import { runManagementDoctor } from "./managed-runtime/diagnostics.mjs";
24
- import { runStoreMigration } from "./store-migration-command.mjs";
15
+ import { loadAdapters } from "./adapter-catalog.mjs";
25
16
  import { createGitProbe } from "./git-probe.mjs";
26
- import { runClaimCommand, runReleaseCommand } from "./claim-command.mjs";
27
17
  import { platformDataHome, runtimePaths } from "./runtime-paths.mjs";
28
18
  import { resolveOwner } from "./session-owner.mjs";
29
- import { discoverWorkspace } from "./workspace-discovery.mjs";
30
- import { resolveSavedHookWorkspace } from "./hook-workspace.mjs";
31
19
 
32
20
  const DEFAULT_CADENCE_MS = 30_000;
33
21
 
@@ -39,6 +27,10 @@ const DEFAULT_CADENCE_MS = 30_000;
39
27
  * looking is safe.
40
28
  */
41
29
  async function locateContext(options, runtime) {
30
+ // Workspace discovery reads through the store's no-follow opens, so it is
31
+ // loaded by the commands that use a workspace, never by `version` (#270).
32
+ const [{ discoverWorkspace }, { resolveSavedHookWorkspace }] = await Promise.all([
33
+ import("./workspace-discovery.mjs"), import("./hook-workspace.mjs")]);
42
34
  const dataHome = runtime.dataHome ?? platformDataHome({ platform: runtime.platform,
43
35
  env: runtime.env });
44
36
  const discovery = {
@@ -59,6 +51,10 @@ async function locateContext(options, runtime) {
59
51
  }
60
52
 
61
53
  async function withService({ descriptor, paths, dataHome }, runtime) {
54
+ // Loaded here rather than at the top: `version`, `help` and the commands
55
+ // that work on a machine never open a store (#270).
56
+ const [{ openFilesystemStore }, { createCoordinationService }] = await Promise.all([
57
+ import("@agents-can-communicate/storage-filesystem"), import("@agents-can-communicate/core")]);
62
58
  const store = await openFilesystemStore({ root: paths.root, clock: runtime.clock,
63
59
  ids: runtime.ids, workspaceId: descriptor.id });
64
60
  const service = createCoordinationService({ store, clock: runtime.clock, ids: runtime.ids });
@@ -120,35 +116,10 @@ function obligationFor(kind, explicit, addressed) {
120
116
  return obligation;
121
117
  }
122
118
 
123
- export function recordedText(message, delivery) {
124
- const diagnostics = delivery.map(item => item.outcome === "offered"
125
- ? `live offered to ${item.recipientParticipantId} via ${item.transport}`
126
- : item.outcome === "woken" && item.pendingApproval === true
127
- ? `sent a wake to ${item.recipientParticipantId} via ${item.transport}, which its session holds `
128
- + "for approval because it bypasses permission prompts; the message arrives with its next turn"
129
- : item.outcome === "woken"
130
- ? `woke ${item.recipientParticipantId} via ${item.transport}; the message arrives with its next turn`
131
- : item.outcome === "queued"
132
- ? item.errorCode === "transport_permission_denied"
133
- ? "live offer blocked by sender permissions; message remains queued; retry the same command "
134
- + "through approved execution"
135
- + (typeof message.clientMessageId === "string"
136
- ? ` with --client-message-id ${shellQuote(message.clientMessageId)}` : " with its original retry key")
137
- : item.errorCode === "no_live_transport"
138
- ? `${item.recipientParticipantId} has no live transport`
139
- + `${item.nativeReasonCode ? ` (${describeNativeReason(item.nativeReasonCode,
140
- { launchOption: item.nativeLaunchOption })})` : ""}`
141
- + "; the message waits in its inbox"
142
- : item.errorCode === "recipient_offline" && item.endReason === "clear"
143
- ? `${item.recipientParticipantId}'s conversation was cleared (/clear); the message waits `
144
- + "until that conversation resumes"
145
- : item.errorCode === "recipient_offline"
146
- ? `${item.recipientParticipantId} has no open session; the message waits until it starts `
147
- + "or resumes one"
148
- : `live offer unavailable (${item.errorCode ?? "durable_fallback"})`
149
- : `delivery already ${item.outcome}`);
150
- return [`recorded ${message.messageId}`, ...diagnostics].join("; ");
151
- }
119
+ // What a sent message met, in words. Its diagnostics load with the first
120
+ // message a command sends, not with `acc version` (#270).
121
+ const describeRecorded = async (message, delivery) =>
122
+ (await import("./recorded-text.mjs")).recordedText(message, delivery);
152
123
 
153
124
  export async function recordAndOffer({ record, router, selectMessage = value => value }) {
154
125
  const recorded = await record();
@@ -179,7 +150,7 @@ export async function recordAndOffer({ record, router, selectMessage = value =>
179
150
  * A live binding wakes on the router's own rule: a current lease, or a lapsed
180
151
  * one the adapter can re-verify. Without that the next send is durable.
181
152
  */
182
- export function presenceBreakdown(participants = [], adapters = ALL_ADAPTERS()) {
153
+ export function presenceBreakdown(participants = [], adapters = []) {
183
154
  const nextTurn = new Set(adapters.filter(adapter => adapter.capabilities?.delivery?.nextTurn === true)
184
155
  .map(adapter => adapter.id));
185
156
  const refreshes = new Set(adapters.filter(adapter => typeof adapter.refreshNativeSession === "function")
@@ -212,13 +183,13 @@ export function describePresence({ live = 0, stale = 0 } = {},
212
183
  return `${live} live (${parts.join("; ")})`;
213
184
  }
214
185
 
215
- export function describeStatus(status) {
186
+ export function describeStatus(status, adapters = []) {
216
187
  // `live` counts everyone present, which includes sessions gone stale - a
217
188
  // client that exited without its session being closed keeps a record that
218
189
  // stops being answered but does not disappear. Printing the number alone
219
190
  // said "1 live" about a workspace where the last agent had left minutes
220
191
  // before, which is the one thing a person reads this line to find out.
221
- const line = `${describePresence(status.counts, presenceBreakdown(status.participants))}; `
192
+ const line = `${describePresence(status.counts, presenceBreakdown(status.participants, adapters))}; `
222
193
  + `${status.counts.claims} claim(s); `
223
194
  + `protection ${status.protection}`;
224
195
  // A transport accepting a message is not a model reading it. Said only when
@@ -294,8 +265,8 @@ const HANDLERS = Object.freeze({
294
265
  return { data: intent, text: `intent: ${intent.summary}` };
295
266
  },
296
267
 
297
- claim: runClaimCommand,
298
- release: runReleaseCommand,
268
+ claim: async input => (await import("./claim-command.mjs")).runClaimCommand(input),
269
+ release: async input => (await import("./claim-command.mjs")).runReleaseCommand(input),
299
270
 
300
271
  message: async ({ options, context }) => {
301
272
  const kind = options.type ?? "note";
@@ -310,7 +281,7 @@ const HANDLERS = Object.freeze({
310
281
  subject: options.subject, body: options.body, descriptor: context.descriptor }) });
311
282
  const message = routed.recorded;
312
283
  return { data: { message, delivery: routed.delivery },
313
- text: recordedText(message, routed.delivery) };
284
+ text: await describeRecorded(message, routed.delivery) };
314
285
  },
315
286
 
316
287
  inbox: async ({ options, context }) => {
@@ -337,7 +308,7 @@ const HANDLERS = Object.freeze({
337
308
  subject: options.subject, clientMessageId: clientMessageId(options, context) }) });
338
309
  const result = routed.recorded;
339
310
  return { data: { message: result.reply, receipt: result.receipt, delivery: routed.delivery },
340
- text: `${recordedText(result.reply, routed.delivery)}; acknowledged ${result.receipt.messageId}` };
311
+ text: `${await describeRecorded(result.reply, routed.delivery)}; acknowledged ${result.receipt.messageId}` };
341
312
  },
342
313
 
343
314
  request: async ({ options, context }) => {
@@ -355,7 +326,7 @@ const HANDLERS = Object.freeze({
355
326
  }) });
356
327
  const message = routed.recorded;
357
328
  return { data: { message, delivery: routed.delivery },
358
- text: recordedText(message, routed.delivery) };
329
+ text: await describeRecorded(message, routed.delivery) };
359
330
  },
360
331
 
361
332
  ack: async ({ options, context }) => {
@@ -374,17 +345,20 @@ const HANDLERS = Object.freeze({
374
345
  remaining: options.remaining ?? [], blockers: options.blocker ?? [] }) });
375
346
  const handoff = routed.recorded;
376
347
  return { data: { message: handoff.message, delivery: routed.delivery },
377
- text: recordedText(handoff.message, routed.delivery) };
348
+ text: await describeRecorded(handoff.message, routed.delivery) };
378
349
  },
379
350
 
380
351
  status: async ({ options, context, runtime }) => {
381
352
  await registerNativeSessions(runtime, context);
382
353
  const status = await context.service.collectStatus({
383
354
  participantId: options.participant, all: options.all === true });
384
- return { data: status, text: describeStatus(status) };
355
+ // Only the clients present here say how their idle sessions are reached.
356
+ const adapters = await loadAdapters((status.participants ?? []).map(item => item.harness));
357
+ return { data: status, text: describeStatus(status, Object.values(adapters)) };
385
358
  },
386
359
 
387
360
  config: async ({ options, runtime }) => {
361
+ const { runConfigCommand } = await import("./config-command.mjs");
388
362
  const result = await runConfigCommand({ subcommand: options.subcommand,
389
363
  cwd: options.cwd ?? runtime.cwd,
390
364
  // A pipe is not a person. Without a TTY there is nobody to answer, so the
@@ -411,11 +385,11 @@ const HANDLERS = Object.freeze({
411
385
  text: result.written ? `wrote ${result.file}` : `not written: ${result.file}` };
412
386
  },
413
387
 
414
- install: async ({ options, runtime }) =>
415
- runInstallCommand({ options, runtime, action: "install" }),
388
+ install: async ({ options, runtime }) => (await import("./install-command.mjs"))
389
+ .runInstallCommand({ options, runtime, action: "install" }),
416
390
 
417
- uninstall: async ({ options, runtime }) =>
418
- runInstallCommand({ options, runtime, action: "uninstall" }),
391
+ uninstall: async ({ options, runtime }) => (await import("./install-command.mjs"))
392
+ .runInstallCommand({ options, runtime, action: "uninstall" }),
419
393
 
420
394
  prune: async ({ options, context }) => {
421
395
  const result = await context.service.prune({
@@ -435,9 +409,12 @@ const HANDLERS = Object.freeze({
435
409
  return { data: result, text };
436
410
  },
437
411
 
438
- doctor: async input => input.options.migrateStore ? runStoreMigration(input) : runDoctor(input),
412
+ doctor: async input => input.options.migrateStore
413
+ ? (await import("./store-migration-command.mjs")).runStoreMigration(input)
414
+ : (await import("./doctor-command.mjs")).runDoctor(input),
439
415
 
440
- update: async ({ options, runtime }) => runUpdateCommand({ options, runtime }),
416
+ update: async ({ options, runtime }) => (await import("./update-command.mjs"))
417
+ .runUpdateCommand({ options, runtime }),
441
418
 
442
419
  help: async ({ options }) => {
443
420
  if (options.helpCommand === undefined) {
@@ -499,8 +476,9 @@ export async function main(argv, runtime) {
499
476
  const options = context === null ? parsed.options
500
477
  : await resolveOwner({ command: parsed.command, options: parsed.options,
501
478
  context, env: runtime.env });
502
- const { data, text, error: outcome } = await (managementDoctor ? runManagementDoctor : HANDLERS[parsed.command])(
503
- { options, context, runtime });
479
+ const handler = managementDoctor
480
+ ? (await import("./managed-runtime/diagnostics.mjs")).runManagementDoctor : HANDLERS[parsed.command];
481
+ const { data, text, error: outcome } = await handler({ options, context, runtime });
504
482
  // A handler may have done real work and still failed: `acc install` writes
505
483
  // for the clients it could and reports the one it could not. The data is
506
484
  // printed either way, and the command still fails.
@@ -99,7 +99,7 @@ export async function updateNativeSessions(adapters, { service, status, root, no
99
99
  const failures = {
100
100
  transport_permission_denied: "this command cannot access the local delivery channel; "
101
101
  + "check the sender's command permissions",
102
- client_process_unknown: "the client process could not be identified; start a new client session",
102
+ client_process_unknown: "the client process could not be identified; the next prompt looks for it again",
103
103
  handshake_failed: "the session handshake failed; check the client's integration/channel setup",
104
104
  handshake_timeout: "the session handshake timed out; check the local channel and retry on the next turn",
105
105
  // Since the contract gate shipped, this reason means the version now serving
@@ -0,0 +1,32 @@
1
+ import { shellQuote } from "@agents-can-communicate/adapter-sdk";
2
+ import { describeNativeReason } from "@agents-can-communicate/installer";
3
+
4
+ export function recordedText(message, delivery) {
5
+ const diagnostics = delivery.map(item => item.outcome === "offered"
6
+ ? `live offered to ${item.recipientParticipantId} via ${item.transport}`
7
+ : item.outcome === "woken" && item.pendingApproval === true
8
+ ? `sent a wake to ${item.recipientParticipantId} via ${item.transport}, which its session holds `
9
+ + "for approval because it bypasses permission prompts; the message arrives with its next turn"
10
+ : item.outcome === "woken"
11
+ ? `woke ${item.recipientParticipantId} via ${item.transport}; the message arrives with its next turn`
12
+ : item.outcome === "queued"
13
+ ? item.errorCode === "transport_permission_denied"
14
+ ? "live offer blocked by sender permissions; message remains queued; retry the same command "
15
+ + "through approved execution"
16
+ + (typeof message.clientMessageId === "string"
17
+ ? ` with --client-message-id ${shellQuote(message.clientMessageId)}` : " with its original retry key")
18
+ : item.errorCode === "no_live_transport"
19
+ ? `${item.recipientParticipantId} has no live transport`
20
+ + `${item.nativeReasonCode ? ` (${describeNativeReason(item.nativeReasonCode,
21
+ { launchOption: item.nativeLaunchOption })})` : ""}`
22
+ + "; the message waits in its inbox"
23
+ : item.errorCode === "recipient_offline" && item.endReason === "clear"
24
+ ? `${item.recipientParticipantId}'s conversation was cleared (/clear); the message waits `
25
+ + "until that conversation resumes"
26
+ : item.errorCode === "recipient_offline"
27
+ ? `${item.recipientParticipantId} has no open session; the message waits until it starts `
28
+ + "or resumes one"
29
+ : `live offer unavailable (${item.errorCode ?? "durable_fallback"})`
30
+ : `delivery already ${item.outcome}`);
31
+ return [`recorded ${message.messageId}`, ...diagnostics].join("; ");
32
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -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.11.0",
3
+ "version": "0.11.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -45,9 +45,12 @@ function safeTransport(value, opaqueEndpointRef) {
45
45
  return opaqueEndpointRef === "live-adapter" ? "native-live" : "live-adapter";
46
46
  }
47
47
 
48
- export function createDeliveryRouter({ service, adapters, clock, readLivePolicy,
48
+ export function createDeliveryRouter({ service, adapters, loadAdapter, clock, readLivePolicy,
49
49
  readNativeReason = input => lastNativeReason({ runtimeDir: service.store?.root, ...input }) }) {
50
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);
51
54
  const observeOffer = (binding, reasonCode = null) => {
52
55
  // Capture time and order now, but never await optional diagnostic I/O on
53
56
  // the delivery path. The SDK bounds its own lifetime for short-lived CLIs.
@@ -119,7 +122,7 @@ export function createDeliveryRouter({ service, adapters, clock, readLivePolicy,
119
122
  // that bound no live transport. It reads its inbox, so it is not gone.
120
123
  if (bindings.length === 0) return withoutLiveTransport(participantId, target);
121
124
  const evaluated = await Promise.all(bindings.map(async binding => {
122
- const adapter = registry.get(binding.adapterId);
125
+ const adapter = await adapterFor(binding.adapterId);
123
126
  return { binding, adapter, policy: await policyFor(adapter, binding) };
124
127
  }));
125
128
  const permitted = evaluated.filter(({ policy }) => permits(policy, message.kind));
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.11.0",
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.11.0",
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/"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/mcp-server",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/protocol",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {