agents-can-communicate 0.10.3 → 0.11.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 (89) hide show
  1. package/README.md +29 -21
  2. package/bin/acc-indicator.mjs +5 -0
  3. package/docs/CAPABILITIES.md +3 -0
  4. package/docs/CLI.md +34 -7
  5. package/docs/MCP.md +42 -2
  6. package/docs/PROTOCOL.md +19 -0
  7. package/docs/STATUS_INDICATOR.md +128 -0
  8. package/docs/TROUBLESHOOTING.md +10 -0
  9. package/docs/UPGRADING.md +16 -0
  10. package/docs/WHY_ACC.md +14 -8
  11. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
  12. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  13. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/skills/acc/SKILL.md +29 -5
  14. package/node_modules/@agents-can-communicate/adapter-antigravity/src/adapter.mjs +3 -0
  15. package/node_modules/@agents-can-communicate/adapter-antigravity/src/indicator-install.mjs +80 -0
  16. package/node_modules/@agents-can-communicate/adapter-antigravity/src/indicator-render.mjs +8 -0
  17. package/node_modules/@agents-can-communicate/adapter-antigravity/src/install.mjs +7 -4
  18. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  19. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/hooks/indicator.mjs +67 -0
  20. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +29 -5
  21. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +1 -0
  22. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +16 -5
  23. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  24. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +48 -5
  25. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +9 -6
  26. package/node_modules/@agents-can-communicate/adapter-codex/src/native-endpoint.mjs +16 -5
  27. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  28. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +29 -5
  29. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  30. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  31. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +29 -5
  32. package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +4 -0
  33. package/node_modules/@agents-can-communicate/adapter-grok/src/indicator-install.mjs +30 -0
  34. package/node_modules/@agents-can-communicate/adapter-grok/src/indicator.mjs +23 -0
  35. package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +9 -5
  36. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  37. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +29 -5
  38. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +4 -0
  39. package/node_modules/@agents-can-communicate/adapter-kimi/src/indicator-install.mjs +30 -0
  40. package/node_modules/@agents-can-communicate/adapter-kimi/src/indicator.mjs +17 -0
  41. package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +12 -6
  42. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  43. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +1 -0
  44. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +5 -1
  45. package/node_modules/@agents-can-communicate/adapter-sdk/src/indicator-command.mjs +14 -0
  46. package/node_modules/@agents-can-communicate/adapter-sdk/src/indicator-text.mjs +13 -0
  47. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +2 -0
  48. package/node_modules/@agents-can-communicate/adapter-sdk/src/offer-observation-order.mjs +12 -0
  49. package/node_modules/@agents-can-communicate/adapter-sdk/src/offer-observation-writer.mjs +37 -0
  50. package/node_modules/@agents-can-communicate/adapter-sdk/src/offer-observation.mjs +47 -0
  51. package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-indicator.mjs +141 -0
  52. package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-scan.mjs +99 -0
  53. package/node_modules/@agents-can-communicate/cli/package.json +2 -1
  54. package/node_modules/@agents-can-communicate/cli/src/args.mjs +5 -3
  55. package/node_modules/@agents-can-communicate/cli/src/claim-command.mjs +64 -0
  56. package/node_modules/@agents-can-communicate/cli/src/help.mjs +15 -8
  57. package/node_modules/@agents-can-communicate/cli/src/index.mjs +1 -0
  58. package/node_modules/@agents-can-communicate/cli/src/indicator-command.mjs +65 -0
  59. package/node_modules/@agents-can-communicate/cli/src/indicator-previous-command.mjs +84 -0
  60. package/node_modules/@agents-can-communicate/cli/src/indicator-previous-windows.mjs +8 -0
  61. package/node_modules/@agents-can-communicate/cli/src/indicator.mjs +112 -0
  62. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +4 -0
  63. package/node_modules/@agents-can-communicate/cli/src/main.mjs +8 -54
  64. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +19 -0
  65. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +5 -3
  66. package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +2 -0
  67. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  68. package/node_modules/@agents-can-communicate/core/src/claim-selectors.mjs +62 -0
  69. package/node_modules/@agents-can-communicate/core/src/claims.mjs +88 -81
  70. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  71. package/node_modules/@agents-can-communicate/delivery-router/src/refresh-binding.mjs +9 -6
  72. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +19 -5
  73. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  74. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  75. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +3 -0
  76. package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +1 -0
  77. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +2 -1
  78. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +11 -2
  79. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  80. package/node_modules/@agents-can-communicate/mcp-server/src/claim-tools.mjs +56 -0
  81. package/node_modules/@agents-can-communicate/mcp-server/src/input-validator.mjs +9 -0
  82. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +7 -8
  83. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +27 -9
  84. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  85. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +1 -0
  86. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  87. package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +1 -1
  88. package/node_modules/@agents-can-communicate/storage-filesystem/src/session-read.mjs +13 -0
  89. package/package.json +3 -1
@@ -6,6 +6,7 @@ import { AccError, CONFIG_FILENAME, EXIT, GENERIC_MESSAGE_KINDS, VALID_OBLIGATIO
6
6
  failure, ok }
7
7
  from "@agents-can-communicate/protocol";
8
8
  import { createCoordinationService } from "@agents-can-communicate/core";
9
+ import { shellQuote } from "@agents-can-communicate/adapter-sdk";
9
10
  import { describeNativeReason } from "@agents-can-communicate/installer";
10
11
  import { openFilesystemStore } from "@agents-can-communicate/storage-filesystem";
11
12
 
@@ -22,7 +23,7 @@ import { runDoctor } from "./doctor-command.mjs";
22
23
  import { runManagementDoctor } from "./managed-runtime/diagnostics.mjs";
23
24
  import { runStoreMigration } from "./store-migration-command.mjs";
24
25
  import { createGitProbe } from "./git-probe.mjs";
25
- import { canonicalClaim } from "./claim-spelling.mjs";
26
+ import { runClaimCommand, runReleaseCommand } from "./claim-command.mjs";
26
27
  import { platformDataHome, runtimePaths } from "./runtime-paths.mjs";
27
28
  import { resolveOwner } from "./session-owner.mjs";
28
29
  import { discoverWorkspace } from "./workspace-discovery.mjs";
@@ -30,39 +31,6 @@ import { resolveSavedHookWorkspace } from "./hook-workspace.mjs";
30
31
 
31
32
  const DEFAULT_CADENCE_MS = 30_000;
32
33
 
33
- /**
34
- * The claim a caller means when they name a resource instead of an id.
35
- *
36
- * `acc claim` takes `--resource`; `acc release` took `--claim`. The asymmetry
37
- * cost every caller - a person and an agent alike - a round trip through
38
- * `acc status --json` to find an id they never chose. The id is still accepted,
39
- * and is still the precise answer when two sessions hold the same resource and
40
- * an authority is releasing someone else's.
41
- */
42
- async function claimOn(options, context) {
43
- if (options.resource === undefined) {
44
- throw new AccError(EXIT.USAGE,
45
- "release needs the claim to give back: --resource is what you claimed, "
46
- + "--claim is its id");
47
- }
48
- const resource = await canonicalClaim(options.resource, context.descriptor);
49
- const { claims } = await context.service.collectStatus({});
50
- // An authority releasing another session's claim is not looking for its own.
51
- const mine = claims.filter(claim => claim.resource === resource
52
- && (options.authority !== undefined || claim.ownerSessionId === options.session));
53
-
54
- if (mine.length === 0) {
55
- throw new AccError(EXIT.DATA, `no claim on ${resource} to release`,
56
- { resource, held: claims.map(claim => claim.resource) });
57
- }
58
- if (mine.length > 1) {
59
- throw new AccError(EXIT.DATA,
60
- `${mine.length} claims on ${resource}; name the one you mean with --claim`,
61
- { resource, claims: mine.map(claim => claim.claimId) });
62
- }
63
- return mine[0].claimId;
64
- }
65
-
66
34
  /**
67
35
  * Where this workspace's state lives, without reading a byte of it.
68
36
  *
@@ -162,7 +130,10 @@ export function recordedText(message, delivery) {
162
130
  ? `woke ${item.recipientParticipantId} via ${item.transport}; the message arrives with its next turn`
163
131
  : item.outcome === "queued"
164
132
  ? item.errorCode === "transport_permission_denied"
165
- ? "live offer blocked by sender permissions; message remains queued; run acc doctor in the sending session"
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")
166
137
  : item.errorCode === "no_live_transport"
167
138
  ? `${item.recipientParticipantId} has no live transport`
168
139
  + `${item.nativeReasonCode ? ` (${describeNativeReason(item.nativeReasonCode,
@@ -323,25 +294,8 @@ const HANDLERS = Object.freeze({
323
294
  return { data: intent, text: `intent: ${intent.summary}` };
324
295
  },
325
296
 
326
- claim: async ({ options, context }) => {
327
- const claim = await context.service.acquireClaim({ sessionId: options.session,
328
- generation: options.generation,
329
- resource: await canonicalClaim(options.resource, context.descriptor),
330
- mode: options.mode ?? "exclusive", enforcement: options.enforcement ?? "advisory",
331
- reason: options.reason ?? "unspecified",
332
- leaseSeconds: options.lease ? positiveNumber(options.lease, "lease") : undefined,
333
- descriptor: context.descriptor });
334
- return { data: claim, text: `claimed ${claim.resource}` };
335
- },
336
-
337
- release: async ({ options, context }) => {
338
- const claimId = options.claim ?? await claimOn(options, context);
339
- const request = { claimId, sessionId: options.session,
340
- generation: options.generation, authority: options.authority, reason: options.reason };
341
- if (options.authority === undefined) await context.service.releaseClaim(request);
342
- else await context.service.forceReleaseClaim(request);
343
- return { data: { claimId }, text: `released ${claimId}` };
344
- },
297
+ claim: runClaimCommand,
298
+ release: runReleaseCommand,
345
299
 
346
300
  message: async ({ options, context }) => {
347
301
  const kind = options.type ?? "note";
@@ -32,6 +32,25 @@ export function invokedDirectly(url) {
32
32
  catch { return false; }
33
33
  }
34
34
 
35
+ /** The display is an observation, not an admitted runtime user. A stable
36
+ * launcher resolves the active generation on every refresh, so an old plugin
37
+ * never retains a path that a same-contract update may reclaim. A concurrent
38
+ * replacement fails open and the next display refresh tries the new pointer. */
39
+ export async function runIndicatorEntry({ managerRoot }) {
40
+ try {
41
+ const root = await canonicalManagerRoot(managerRoot);
42
+ const control = await readControl(root);
43
+ if (!control) throw new Error("indicator runtime missing");
44
+ await import(pathToFileURL(path.join(control.active.root, "bin", "acc-indicator.mjs")).href);
45
+ } catch {
46
+ const report = { health: "problem", reception: null, reasonCode: "indicator_unavailable",
47
+ label: "ACC !", detail: "ACC's installed indicator is unavailable.",
48
+ action: "Run acc update to finish runtime activation, or acc install --indicator on to repair it." };
49
+ process.stdout.write(process.argv.includes("--json") ? JSON.stringify(report) + "\n"
50
+ : "ACC ! · acc doctor\n");
51
+ }
52
+ }
53
+
35
54
  function unavailable(kind) {
36
55
  // Never reflect control file content or payloads into a client diagnostic.
37
56
  if (kind === "acc-hook" || kind === "acc-antigravity-relay") {
@@ -16,6 +16,7 @@ async function durableFile(file, bytes, mode = 0o600) {
16
16
  export const stablePaths = root => ({
17
17
  cli: path.join(root, "bin", "acc.mjs"), runner: path.join(root, "bin", "acc-hook.mjs"),
18
18
  antigravityRelay: path.join(root, "bin", "acc-antigravity-relay.mjs"),
19
+ indicatorRunner: path.join(root, "bin", "acc-indicator.mjs"),
19
20
  });
20
21
 
21
22
  /** Publish immutable launcher modules, then atomically replace each tiny entry.
@@ -47,9 +48,10 @@ export async function writeLaunchers(root, packageRoot) {
47
48
  } finally { await removeTree(staging); }
48
49
  const bin = path.join(root, "bin");
49
50
  await managedDirectory(bin, { create: true });
50
- for (const kind of ENTRY_KINDS) {
51
- const bytes = `#!/usr/bin/env node\nimport { runEntry } from "../launchers/${id}/entry.mjs";\n`
52
- + `await runEntry(${JSON.stringify({ kind, packageRoot, managerRoot: root, managedRequired: true })});\n`;
51
+ for (const kind of [...ENTRY_KINDS, "acc-indicator"]) {
52
+ const entry = kind === "acc-indicator" ? "runIndicatorEntry" : "runEntry";
53
+ const bytes = `#!/usr/bin/env node\nimport { ${entry} } from "../launchers/${id}/entry.mjs";\n`
54
+ + `await ${entry}(${JSON.stringify({ kind, packageRoot, managerRoot: root, managedRequired: true })});\n`;
53
55
  const temporary = path.join(bin, `.${randomUUID()}.tmp`);
54
56
  try {
55
57
  await durableFile(temporary, bytes, 0o700);
@@ -97,6 +97,8 @@ export async function updateNativeSessions(adapters, { service, status, root, no
97
97
  }
98
98
 
99
99
  const failures = {
100
+ transport_permission_denied: "this command cannot access the local delivery channel; "
101
+ + "check the sender's command permissions",
100
102
  client_process_unknown: "the client process could not be identified; start a new client session",
101
103
  handshake_failed: "the session handshake failed; check the client's integration/channel setup",
102
104
  handshake_timeout: "the session handshake timed out; check the local channel and retry on the next turn",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.10.3",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,62 @@
1
+ import { AccError, EXIT, assertMatchableResource, assertPortableId,
2
+ assertResourceUri, normaliseResource } from "@agents-can-communicate/protocol";
3
+
4
+ const usage = message => { throw new AccError(EXIT.USAGE, message); };
5
+
6
+ export function uniqueList(value, name, normalise = item => item) {
7
+ if (!Array.isArray(value) || value.length === 0) usage(`${name} must be a nonempty array`);
8
+ const result = value.map(item => {
9
+ if (typeof item !== "string" || item.length === 0) usage(`${name} must contain nonempty strings`);
10
+ return normalise(item);
11
+ });
12
+ if (new Set(result).size !== result.length) usage(`${name} contains duplicate selectors`);
13
+ return result;
14
+ }
15
+
16
+ export function claimResources(value) {
17
+ return uniqueList(value, "resources", resource => {
18
+ assertMatchableResource(resource, usage);
19
+ const normalised = normaliseResource(resource);
20
+ try { return assertResourceUri(normalised, "resource"); }
21
+ catch (error) {
22
+ if (!(error instanceof AccError)) throw error;
23
+ throw new AccError(EXIT.USAGE, error.message, error.details);
24
+ }
25
+ });
26
+ }
27
+
28
+ export function releaseSelectors(input) {
29
+ const present = ["claimIds", "resources", "all"].filter(key => input[key] !== undefined);
30
+ if (present.length !== 1 || (present[0] === "all" && input.all !== true)) {
31
+ usage("release needs exactly one of claimIds, resources, or all: true");
32
+ }
33
+ if (input.authority !== undefined) usage("batch release does not accept force-release authority");
34
+ if (input.all === true) return { all: true };
35
+ if (input.resources !== undefined) return { resources: claimResources(input.resources) };
36
+ return { claimIds: uniqueList(input.claimIds, "claimIds", id => {
37
+ try { return assertPortableId(id, "claim id"); }
38
+ catch (error) {
39
+ if (!(error instanceof AccError)) throw error;
40
+ throw new AccError(EXIT.USAGE, error.message, error.details);
41
+ }
42
+ }) };
43
+ }
44
+
45
+ export function selectReleaseClaims(tx, selectors, sessionId) {
46
+ if (selectors.all === true) return tx.list("claim", claim => claim.ownerSessionId === sessionId);
47
+ if (selectors.claimIds !== undefined) return selectors.claimIds.map(claimId => {
48
+ const claim = tx.get("claim", claimId);
49
+ if (claim === null) throw new AccError(EXIT.CONFLICT, "the claim does not exist", { claimId });
50
+ return claim;
51
+ });
52
+ const claims = tx.list("claim");
53
+ return selectors.resources.map(resource => {
54
+ const mine = claims.filter(claim => claim.resource === resource && claim.ownerSessionId === sessionId);
55
+ if (mine.length === 0) throw new AccError(EXIT.DATA, `no claim on ${resource} to release`,
56
+ { resource, held: claims.map(claim => claim.resource) });
57
+ if (mine.length > 1) throw new AccError(EXIT.DATA,
58
+ `${mine.length} claims on ${resource}; name the one you mean with --claim`,
59
+ { resource, claims: mine.map(claim => claim.claimId) });
60
+ return mine[0];
61
+ });
62
+ }
@@ -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
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/delivery-router",
3
- "version": "0.10.3",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,7 @@
1
1
  import { validateNativeHandshake } from "@agents-can-communicate/adapter-sdk";
2
2
 
3
3
  const MAX_REFRESH_LEASE_MS = 120_000;
4
+ const refused = (errorCode = "recipient_unavailable") => ({ refreshed: false, errorCode });
4
5
 
5
6
  function sameBinding(current, expected, leaseUntil) {
6
7
  return current !== null && current !== undefined
@@ -15,24 +16,26 @@ function sameBinding(current, expected, leaseUntil) {
15
16
 
16
17
  export async function refreshExpiredBinding({ service, adapter, binding, runtimeDir,
17
18
  clock, timeoutMs = 750 }) {
18
- if (typeof adapter?.refreshNativeSession !== "function") return false;
19
+ if (typeof adapter?.refreshNativeSession !== "function") return refused();
19
20
  const now = Date.parse(clock.now());
20
- if (!Number.isFinite(now)) return false;
21
+ if (!Number.isFinite(now)) return refused();
21
22
  try {
22
23
  const handshake = await adapter.refreshNativeSession({ binding, runtimeDir, timeoutMs });
23
24
  const validated = validateNativeHandshake(adapter, {
24
25
  clientVersion: binding.clientVersion, handshake,
25
26
  });
26
- if (!validated.ok || validated.opaqueEndpointRef !== binding.opaqueEndpointRef) return false;
27
+ if (!validated.ok) return refused(validated.reasonCode === "transport_permission_denied"
28
+ ? "transport_permission_denied" : "recipient_unavailable");
29
+ if (validated.opaqueEndpointRef !== binding.opaqueEndpointRef) return refused();
27
30
  const requestedLease = Date.parse(validated.leaseUntil);
28
- if (!Number.isFinite(requestedLease) || requestedLease <= now) return false;
31
+ if (!Number.isFinite(requestedLease) || requestedLease <= now) return refused();
29
32
  const leaseUntil = new Date(Math.min(requestedLease,
30
33
  now + MAX_REFRESH_LEASE_MS)).toISOString();
31
34
  await service.refreshDeliveryBinding({ sessionId: binding.sessionId,
32
35
  generation: binding.generation, leaseUntil });
33
36
  const current = await service.store?.ephemeral?.get("deliveryBinding", binding.sessionId);
34
- return sameBinding(current, binding, leaseUntil);
37
+ return sameBinding(current, binding, leaseUntil) ? { refreshed: true, errorCode: null } : refused();
35
38
  } catch {
36
- return false;
39
+ return refused();
37
40
  }
38
41
  }
@@ -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
@@ -50,6 +48,14 @@ function safeTransport(value, opaqueEndpointRef) {
50
48
  export function createDeliveryRouter({ service, adapters, clock, readLivePolicy,
51
49
  readNativeReason = input => lastNativeReason({ runtimeDir: service.store?.root, ...input }) }) {
52
50
  const registry = adaptersById(adapters);
51
+ const observeOffer = (binding, reasonCode = null) => {
52
+ // Capture time and order now, but never await optional diagnostic I/O on
53
+ // the delivery path. The SDK bounds its own lifetime for short-lived CLIs.
54
+ void storeOfferObservation({
55
+ runtimeDir: service.store?.root, workspaceId: service.store?.workspaceId,
56
+ sessionId: binding.sessionId, generation: binding.generation, at: clock.now(),
57
+ state: reasonCode === null ? "active" : "degraded", reasonCode }).catch(() => {});
58
+ };
53
59
 
54
60
  // A session that bound no live transport may have said why: the reason its
55
61
  // own last native attempt recorded, from the closed vocabulary only, and the
@@ -82,6 +88,7 @@ export function createDeliveryRouter({ service, adapters, clock, readLivePolicy,
82
88
  targetGeneration: binding.generation,
83
89
  transport: safeTransport(transport, binding.opaqueEndpointRef), adapterId: binding.adapterId,
84
90
  clientVersion: binding.clientVersion, safeErrorCode }).catch(() => null);
91
+ observeOffer(binding, safeErrorCode);
85
92
  }
86
93
 
87
94
  async function offerTo(message, participantId, now) {
@@ -132,7 +139,12 @@ export function createDeliveryRouter({ service, adapters, clock, readLivePolicy,
132
139
  if (Date.parse(binding.leaseUntil) <= Date.parse(now)) {
133
140
  const refreshed = await refreshExpiredBinding({ service, adapter, binding,
134
141
  runtimeDir: service.store?.root, clock });
135
- if (!refreshed) return durable(participantId, "recipient_unavailable");
142
+ if (!refreshed.refreshed) {
143
+ if (refreshed.errorCode === "transport_permission_denied") {
144
+ await recordFailure(binding, message, participantId, "live-adapter", refreshed.errorCode);
145
+ } else observeOffer(binding, refreshed.errorCode);
146
+ return durable(participantId, refreshed.errorCode);
147
+ }
136
148
  const current = (await service.listDeliveryBindings({
137
149
  participantId, now: clock.now() })).filter(item => item.sessionId === binding.sessionId
138
150
  && item.generation === binding.generation
@@ -211,6 +223,7 @@ export function createDeliveryRouter({ service, adapters, clock, readLivePolicy,
211
223
  // shown and leave it out of the very turn the wake started. The receipt
212
224
  // stays queued; the hook records the offer once its stdout carried the body.
213
225
  if (adapter.nativeDelivery?.offerKind === "wake") {
226
+ observeOffer(binding, response.pendingApproval === true ? "inbound_approval_required" : null);
214
227
  // The client took the wake but holds it for its user's approval.
215
228
  return { recipientParticipantId: participantId, outcome: "woken", transport,
216
229
  ...(response.pendingApproval === true ? { pendingApproval: true } : {}) };
@@ -224,6 +237,7 @@ export function createDeliveryRouter({ service, adapters, clock, readLivePolicy,
224
237
  await recordFailure(binding, message, participantId, transport, "transport_error");
225
238
  return durable(participantId, "transport_error");
226
239
  }
240
+ observeOffer(binding);
227
241
  return { recipientParticipantId: participantId, outcome: "offered", transport };
228
242
  }
229
243
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.10.3",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/installer",
3
- "version": "0.10.3",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 ?? []),
@@ -10,6 +10,7 @@ const REASONS = Object.freeze({
10
10
  known_bad_version: "this client version has a known native delivery failure",
11
11
  native_endpoint_unavailable: "the client's local delivery service is unavailable",
12
12
  native_session_unavailable: "the local delivery service has no loaded client session",
13
+ transport_permission_denied: "the calling process lacks permission to access the local delivery channel",
13
14
  feature_probe_failed: "the native protocol probe did not succeed",
14
15
  probe_timeout: "the native protocol probe timed out",
15
16
  probe_version_mismatch: "the local service and CLI versions differ",
@@ -121,7 +121,7 @@ async function saveOwnership({ dataHome, record }) {
121
121
  */
122
122
  export async function recordInstall({ dataHome, adapterId, version, accVersion = null,
123
123
  artifacts, createdDirectories = [], deliveryPolicy, deliveryDecision,
124
- nativeActivation = null }) {
124
+ nativeActivation = null, indicator }) {
125
125
  const stamped = await Promise.all(artifacts.map(async artifact => ({
126
126
  path: artifact.path,
127
127
  kind: artifact.kind ?? "file",
@@ -141,6 +141,7 @@ export async function recordInstall({ dataHome, adapterId, version, accVersion =
141
141
  { adapterId, version, accVersion, artifacts: stamped,
142
142
  ...(directories.length === 0 ? {} : { createdDirectories: directories }),
143
143
  ...(deliveryPolicy === undefined ? {} : { deliveryPolicy }),
144
+ ...(indicator === undefined ? {} : { indicator }),
144
145
  ...(retainedDeliveryDecision === undefined ? {} : {
145
146
  deliveryDecision: retainedDeliveryDecision,
146
147
  }),