agents-can-communicate 0.10.4 → 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 (81) 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/WHY_ACC.md +14 -8
  9. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
  10. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  11. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/skills/acc/SKILL.md +29 -5
  12. package/node_modules/@agents-can-communicate/adapter-antigravity/src/adapter.mjs +3 -0
  13. package/node_modules/@agents-can-communicate/adapter-antigravity/src/indicator-install.mjs +80 -0
  14. package/node_modules/@agents-can-communicate/adapter-antigravity/src/indicator-render.mjs +8 -0
  15. package/node_modules/@agents-can-communicate/adapter-antigravity/src/install.mjs +7 -4
  16. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  17. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/hooks/indicator.mjs +67 -0
  18. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +29 -5
  19. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +1 -0
  20. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +16 -5
  21. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  22. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +29 -5
  23. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  24. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +29 -5
  25. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  26. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  27. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +29 -5
  28. package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +4 -0
  29. package/node_modules/@agents-can-communicate/adapter-grok/src/indicator-install.mjs +30 -0
  30. package/node_modules/@agents-can-communicate/adapter-grok/src/indicator.mjs +23 -0
  31. package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +9 -5
  32. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  33. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +29 -5
  34. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +4 -0
  35. package/node_modules/@agents-can-communicate/adapter-kimi/src/indicator-install.mjs +30 -0
  36. package/node_modules/@agents-can-communicate/adapter-kimi/src/indicator.mjs +17 -0
  37. package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +12 -6
  38. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  39. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +1 -0
  40. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +5 -1
  41. package/node_modules/@agents-can-communicate/adapter-sdk/src/indicator-command.mjs +14 -0
  42. package/node_modules/@agents-can-communicate/adapter-sdk/src/indicator-text.mjs +13 -0
  43. package/node_modules/@agents-can-communicate/adapter-sdk/src/offer-observation-order.mjs +12 -0
  44. package/node_modules/@agents-can-communicate/adapter-sdk/src/offer-observation-writer.mjs +37 -0
  45. package/node_modules/@agents-can-communicate/adapter-sdk/src/offer-observation.mjs +47 -0
  46. package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-indicator.mjs +141 -0
  47. package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-scan.mjs +99 -0
  48. package/node_modules/@agents-can-communicate/cli/package.json +2 -1
  49. package/node_modules/@agents-can-communicate/cli/src/args.mjs +5 -3
  50. package/node_modules/@agents-can-communicate/cli/src/claim-command.mjs +64 -0
  51. package/node_modules/@agents-can-communicate/cli/src/help.mjs +15 -8
  52. package/node_modules/@agents-can-communicate/cli/src/index.mjs +1 -0
  53. package/node_modules/@agents-can-communicate/cli/src/indicator-command.mjs +65 -0
  54. package/node_modules/@agents-can-communicate/cli/src/indicator-previous-command.mjs +84 -0
  55. package/node_modules/@agents-can-communicate/cli/src/indicator-previous-windows.mjs +8 -0
  56. package/node_modules/@agents-can-communicate/cli/src/indicator.mjs +112 -0
  57. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +4 -0
  58. package/node_modules/@agents-can-communicate/cli/src/main.mjs +3 -53
  59. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +19 -0
  60. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +5 -3
  61. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  62. package/node_modules/@agents-can-communicate/core/src/claim-selectors.mjs +62 -0
  63. package/node_modules/@agents-can-communicate/core/src/claims.mjs +88 -81
  64. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  65. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +14 -5
  66. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  67. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  68. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +3 -0
  69. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +2 -1
  70. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +11 -2
  71. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  72. package/node_modules/@agents-can-communicate/mcp-server/src/claim-tools.mjs +56 -0
  73. package/node_modules/@agents-can-communicate/mcp-server/src/input-validator.mjs +9 -0
  74. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +7 -8
  75. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +27 -9
  76. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  77. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +1 -0
  78. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  79. package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +1 -1
  80. package/node_modules/@agents-can-communicate/storage-filesystem/src/session-read.mjs +13 -0
  81. 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
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/delivery-router",
3
- "version": "0.10.4",
3
+ "version": "0.11.0",
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
@@ -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) {
@@ -135,7 +142,7 @@ export function createDeliveryRouter({ service, adapters, clock, readLivePolicy,
135
142
  if (!refreshed.refreshed) {
136
143
  if (refreshed.errorCode === "transport_permission_denied") {
137
144
  await recordFailure(binding, message, participantId, "live-adapter", refreshed.errorCode);
138
- }
145
+ } else observeOffer(binding, refreshed.errorCode);
139
146
  return durable(participantId, refreshed.errorCode);
140
147
  }
141
148
  const current = (await service.listDeliveryBindings({
@@ -216,6 +223,7 @@ export function createDeliveryRouter({ service, adapters, clock, readLivePolicy,
216
223
  // shown and leave it out of the very turn the wake started. The receipt
217
224
  // stays queued; the hook records the offer once its stdout carried the body.
218
225
  if (adapter.nativeDelivery?.offerKind === "wake") {
226
+ observeOffer(binding, response.pendingApproval === true ? "inbound_approval_required" : null);
219
227
  // The client took the wake but holds it for its user's approval.
220
228
  return { recipientParticipantId: participantId, outcome: "woken", transport,
221
229
  ...(response.pendingApproval === true ? { pendingApproval: true } : {}) };
@@ -229,6 +237,7 @@ export function createDeliveryRouter({ service, adapters, clock, readLivePolicy,
229
237
  await recordFailure(binding, message, participantId, transport, "transport_error");
230
238
  return durable(participantId, "transport_error");
231
239
  }
240
+ observeOffer(binding);
232
241
  return { recipientParticipantId: participantId, outcome: "offered", transport };
233
242
  }
234
243
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.10.4",
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.4",
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 ?? []),
@@ -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
  }),
@@ -14,7 +14,7 @@ import { LIVE_POLICIES, describeActivation, describeDeactivation, planActivation
14
14
  */
15
15
  export function planInstallation({ adapters, detected, context, action = "install",
16
16
  recorded = [], accVersion = null, allowDowngrade = false, requested = [],
17
- deliveryByAdapter = {}, deliveryDecisionByAdapter = {}, allowServiceSetup = false }) {
17
+ deliveryByAdapter = {}, deliveryDecisionByAdapter = {}, allowServiceSetup = false, indicator }) {
18
18
  if (!["install", "uninstall"].includes(action)) {
19
19
  throw new AccError(EXIT.USAGE, `unknown installation action: ${action}`, { action });
20
20
  }
@@ -157,7 +157,13 @@ export function planInstallation({ adapters, detected, context, action = "instal
157
157
  : null;
158
158
  const deliverySummary = action === "install"
159
159
  ? describeInstallDelivery(entry, delivery, effectiveLivePolicy) : null;
160
- const installContext = { ...context, requestedLivePolicy: delivery,
160
+ const indicatorPreference = indicator ?? recordedById.get(entry.adapterId)?.indicator ?? "off";
161
+ const indicatorDiagnostic = indicatorPreference !== "on" ? null : !adapter.statusIndicator
162
+ ? `${adapter.displayName} indicator: not supported in this client's native status line`
163
+ : entry.version == null || isOlder(entry.version, adapter.statusIndicator.minimumVersion)
164
+ ? `${adapter.displayName} indicator requires client ${adapter.statusIndicator.minimumVersion} or later`
165
+ : `${adapter.displayName} indicator: enabled; ${adapter.statusIndicator.reload}`;
166
+ const installContext = { ...context, indicator: indicatorPreference, requestedLivePolicy: delivery,
161
167
  livePolicy: configuredLivePolicy, clientVersion: entry.version, platform: entry.platform };
162
168
  const nativeServiceSetup = action === "install" && allowServiceSetup && delivery !== "off"
163
169
  && deliveryDecision.completeSetup === true ? entry.nativeServiceSetup : undefined;
@@ -192,6 +198,8 @@ export function planInstallation({ adapters, detected, context, action = "instal
192
198
  clientPresent: entry.present === true,
193
199
  alreadyInstalled: entry.installed === true,
194
200
  livePolicy: delivery,
201
+ indicator: indicatorPreference,
202
+ ...(indicatorDiagnostic === null ? {} : { indicatorDiagnostic }),
195
203
  effectiveLivePolicy,
196
204
  ...(action === "install" ? { deliveryDecision } : {}),
197
205
  ...(retainedActivation ? { configuredLivePolicy, retainedNativeActivation: retainedActivation } : {}),
@@ -205,6 +213,7 @@ export function planInstallation({ adapters, detected, context, action = "instal
205
213
  // Said in the operator's terms, not in paths: which files ACC creates
206
214
  // outright and which belong to the user and are only edited.
207
215
  summary: [
216
+ ...(indicatorDiagnostic === null ? [] : [indicatorDiagnostic]),
208
217
  ...(entry.present ? [] : [`${adapter.displayName ?? adapter.id} is no longer on `
209
218
  + "this machine; removing what ACC recorded writing"]),
210
219
  ...(deliverySummary === null ? [] : [deliverySummary]),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/mcp-server",
3
- "version": "0.10.4",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,56 @@
1
+ import { AccError, EXIT, assertMatchableResource, assertPortableId,
2
+ assertResourceUri, normaliseResource } from "@agents-can-communicate/protocol";
3
+
4
+ const invalid = message => { throw new AccError(EXIT.USAGE, message); };
5
+
6
+ function resourceSelector(resource) {
7
+ assertMatchableResource(resource, invalid);
8
+ return assertResourceUri(normaliseResource(resource), "resource");
9
+ }
10
+
11
+ function claimIdSelector(claimId, field) {
12
+ try { return assertPortableId(claimId, field); }
13
+ catch (error) {
14
+ if (!(error instanceof AccError)) throw error;
15
+ invalid(`${field} must be a portable identifier`);
16
+ }
17
+ }
18
+
19
+ // URI normalization and identifier validation must precede resolving a session:
20
+ // an invalid selector must not create presence or a persistent session binding.
21
+ export function validateClaimSelectors(name, args) {
22
+ if (name !== "acc_claim" && name !== "acc_release") return args;
23
+ const result = { ...args };
24
+ if (args.resource !== undefined) result.resource = resourceSelector(args.resource);
25
+ if (args.resources !== undefined) {
26
+ result.resources = args.resources.map(resourceSelector);
27
+ if (new Set(result.resources).size !== result.resources.length) {
28
+ invalid("arguments.resources contains duplicate selectors after normalization");
29
+ }
30
+ }
31
+ if (args.claimId !== undefined) claimIdSelector(args.claimId, "arguments.claimId");
32
+ args.claimIds?.forEach((id, index) => claimIdSelector(id, `arguments.claimIds[${index}]`));
33
+ return result;
34
+ }
35
+
36
+ export async function claimTool(service, owner, args) {
37
+ if (args.action === "renew") return service.renewClaim({ ...owner,
38
+ claimId: args.claimId, leaseSeconds: args.leaseSeconds });
39
+ const input = { ...owner, mode: args.mode ?? "exclusive", enforcement: "advisory",
40
+ reason: args.reason ?? "unspecified", leaseSeconds: args.leaseSeconds };
41
+ if (args.resources !== undefined) {
42
+ return { claims: await service.acquireClaims({ ...input, resources: args.resources }) };
43
+ }
44
+ return service.acquireClaim({ ...input, resource: args.resource });
45
+ }
46
+
47
+ export async function releaseTool(service, owner, args) {
48
+ if (args.claimId !== undefined) {
49
+ await service.releaseClaim({ ...owner, claimId: args.claimId });
50
+ return { released: args.claimId };
51
+ }
52
+ const selected = args.resource !== undefined ? { resources: [args.resource] } : args;
53
+ const claims = await service.releaseClaims({ ...owner, ...selected });
54
+ const released = claims.map(claim => claim.claimId);
55
+ return { released: args.resource !== undefined ? released[0] : released };
56
+ }
@@ -43,6 +43,12 @@ function validateSchema(schema, value, path) {
43
43
  }
44
44
  } else if (schema.type === "array") {
45
45
  if (!Array.isArray(value)) fail(path, "must be an array");
46
+ if (schema.minItems !== undefined && value.length < schema.minItems) {
47
+ fail(path, `must contain at least ${schema.minItems} item(s)`);
48
+ }
49
+ if (schema.uniqueItems === true && new Set(value).size !== value.length) {
50
+ fail(path, "must contain unique items");
51
+ }
46
52
  if (schema.items !== undefined) {
47
53
  value.forEach((item, index) => validateSchema(schema.items, item, `${path}[${index}]`));
48
54
  }
@@ -53,6 +59,9 @@ function validateSchema(schema, value, path) {
53
59
  } else if (schema.type === "integer" && !Number.isInteger(value)) {
54
60
  fail(path, "must be an integer");
55
61
  }
62
+ if (schema.minLength !== undefined && value.length < schema.minLength) {
63
+ fail(path, `must contain at least ${schema.minLength} character(s)`);
64
+ }
56
65
 
57
66
  if (schema.minimum !== undefined && value < schema.minimum) {
58
67
  fail(path, `must be at least ${schema.minimum}`);
@@ -5,6 +5,7 @@ import { clearSessionBinding, loadSessionBinding, storeSessionBinding }
5
5
 
6
6
  import { readResource } from "./resources.mjs";
7
7
  import { validateToolInput } from "./input-validator.mjs";
8
+ import { claimTool, releaseTool, validateClaimSelectors } from "./claim-tools.mjs";
8
9
  import { PUBLIC_TOOLS, RESOURCES } from "./tools.mjs";
9
10
  import { createProtocol } from "./protocol.mjs";
10
11
 
@@ -133,14 +134,9 @@ async function callTool(name, args, context) {
133
134
  return service.setIntent({ ...owner, summary: args.summary, mode: args.mode,
134
135
  state: args.state, resourceHints: args.resourceHints ?? [] });
135
136
  case "acc_claim":
136
- if (args.action === "renew") return service.renewClaim({ ...owner,
137
- claimId: args.claimId, leaseSeconds: args.leaseSeconds });
138
- return service.acquireClaim({ ...owner, resource: args.resource,
139
- mode: args.mode ?? "exclusive", enforcement: "advisory",
140
- reason: args.reason ?? "unspecified", leaseSeconds: args.leaseSeconds });
137
+ return claimTool(service, owner, args);
141
138
  case "acc_release":
142
- await service.releaseClaim({ ...owner, claimId: args.claimId });
143
- return { released: args.claimId };
139
+ return releaseTool(service, owner, args);
144
140
  case "acc_message": {
145
141
  const kind = args.kind ?? "note";
146
142
  const toParticipantIds = args.to ?? [];
@@ -219,13 +215,16 @@ async function handle(message, context) {
219
215
  throw new AccError(EXIT.USAGE, `unknown tool: ${params.name}`, { name: params.name });
220
216
  }
221
217
  validateToolInput(tool.inputSchema, args);
222
- const value = await callTool(params.name, args, context);
218
+ const value = await callTool(params.name, validateClaimSelectors(params.name, args), context);
223
219
  return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
224
220
  structuredContent: value };
225
221
  } catch (error) {
226
222
  // A failing operation is a tool result, not a transport failure: the
227
223
  // model must see it and be able to react.
224
+ const batchConflict = params.name === "acc_claim"
225
+ && params.arguments?.resources !== undefined && error.details?.conflicts !== undefined;
228
226
  return { isError: true,
227
+ ...(batchConflict ? { structuredContent: { code: error.code, details: error.details } } : {}),
229
228
  content: [{ type: "text", text: `${params.name}: ${error.message}` }] };
230
229
  }
231
230
  }
@@ -19,6 +19,10 @@ const object = (properties, required = []) => ({
19
19
 
20
20
  const string = description => ({ type: "string", description });
21
21
  const stringList = description => ({ type: "array", items: { type: "string" }, description });
22
+ const selector = description => ({ ...string(description), minLength: 1 });
23
+ const selectors = description => ({ type: "array", items: { type: "string", minLength: 1 },
24
+ minItems: 1, uniqueItems: true, description });
25
+ const releaseFields = ["claimId", "claimIds", "resource", "resources", "all"];
22
26
 
23
27
  export const PUBLIC_TOOLS = Object.freeze([
24
28
  {
@@ -68,31 +72,45 @@ export const PUBLIC_TOOLS = Object.freeze([
68
72
  },
69
73
  {
70
74
  name: "acc_claim",
71
- description: `Acquire or renew a claim on a resource URI. Claims are `
75
+ description: `Acquire one resource or atomically acquire a nonempty resources array; renew by claimId. `
76
+ + `resource returns a Claim; resources returns {claims: [...]}, including one-item arrays. `
77
+ + `A conflicting batch acquires nothing and lists every conflicting holder in structuredContent.details.conflicts. Claims are `
72
78
  + `workspace-wide and advisory here: this client has no write guard, so a claim `
73
79
  + `informs peers rather than preventing an edit. ${MUTATION_POLL}`,
74
80
  inputSchema: { ...object({
75
- resource: string("Resource URI, for example file:packages/core/**."),
81
+ resource: selector("One resource URI, exclusive with resources; for example file:packages/core/**."),
82
+ resources: selectors("Resource URIs for one atomic acquisition; unique after normalization and exclusive with resource."),
76
83
  action: { type: "string", enum: ["acquire", "renew"] },
77
84
  mode: { type: "string", enum: ["shared", "exclusive"] },
78
85
  reason: string("Why the resource is being claimed."),
79
86
  leaseSeconds: { type: "integer", minimum: 1,
80
87
  description: "Lease length; the claim expires without renewal." },
81
- claimId: string("Required for renew."),
88
+ claimId: selector("Required for renew; renew accepts one claimId only."),
82
89
  }, ["action"]), oneOf: [
83
90
  { properties: { action: { const: "acquire" } }, required: ["resource"],
84
- not: { required: ["claimId"] } },
91
+ not: { anyOf: ["resources", "claimId"].map(field => ({ required: [field] })) } },
92
+ { properties: { action: { const: "acquire" } }, required: ["resources"],
93
+ not: { anyOf: ["resource", "claimId"].map(field => ({ required: [field] })) } },
85
94
  { properties: { action: { const: "renew" } }, required: ["claimId"],
86
- not: { anyOf: ["resource", "mode", "reason"]
95
+ not: { anyOf: ["resource", "resources", "mode", "reason"]
87
96
  .map(field => ({ required: [field] })) } },
88
97
  ] },
89
98
  },
90
99
  {
91
100
  name: "acc_release",
92
- description: `Release a claim this session owns. ${MUTATION_POLL}`,
93
- inputSchema: object({
94
- claimId: string("The claim to release."),
95
- }, ["claimId"]),
101
+ description: `Release only claims this session owns, with exactly one of claimId, claimIds, resource, `
102
+ + `resources, or all: true. Arrays release atomically. Scalar selectors return {released: id}; `
103
+ + `arrays and all return {released: [ids]}, including an empty all result. `
104
+ + `all keeps this session's presence and intent open. ${MUTATION_POLL}`,
105
+ inputSchema: { ...object({
106
+ claimId: selector("One claim to release."),
107
+ claimIds: selectors("Claim IDs to release atomically."),
108
+ resource: selector("One exact resource URI this session holds."),
109
+ resources: selectors("Exact resource URIs to release atomically; unique after normalization."),
110
+ all: { type: "boolean", const: true, description: "Release every claim owned by this session." },
111
+ }), oneOf: releaseFields.map(field => ({ required: [field],
112
+ not: { anyOf: releaseFields.filter(other => other !== field)
113
+ .map(other => ({ required: [other] })) } })) },
96
114
  },
97
115
  {
98
116
  name: "acc_message",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/protocol",
3
- "version": "0.10.4",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,5 +11,6 @@ export { CONFIG_FILENAME, CONFIG_SCHEMA_VERSION, RUNTIME_KEYS, defaultProjectCon
11
11
  validateProjectConfig } from "./config.mjs";
12
12
  export { RECEIPT_STATES, advanceReceipt } from "./states.mjs";
13
13
  export { assertMatchableResource, normaliseResource } from "./resources.mjs";
14
+ export { resourceUri as assertResourceUri } from "./fields.mjs";
14
15
  export { assertDecisionChange } from "./decision-changes.mjs";
15
16
  export { TRANSACTION_INDEXES, assertIndexTuple, indexKeysFor } from "./transaction-indexes.mjs";
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/storage-filesystem",
3
- "version": "0.10.4",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -3,6 +3,6 @@ export { openFilesystemStore, storePaths, ZERO_CURSOR } from "./store.mjs";
3
3
  export { diagnoseFilesystemStore, repairFilesystemStore } from "./recovery.mjs";
4
4
  export { readStoreIdentity, requireStoreIdentity, STORE_VERSION } from "./identity.mjs";
5
5
  export { withWriterMutex } from "./writer-mutex.mjs";
6
- export { readSessionRecord } from "./session-read.mjs";
6
+ export { readSessionRecord, readDeliveryBindingRecord } from "./session-read.mjs";
7
7
  export { openNoFollow } from "./portable-fs.mjs";
8
8
  export { migrateFilesystemStore } from "./store-migration.mjs";