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
@@ -33,8 +33,8 @@ const SUMMARY = Object.freeze({
33
33
  prune: "report what this workspace no longer needs; --apply to reclaim it",
34
34
  sync: "events since a cursor, or bounded message history",
35
35
  work: "publish what this session is doing, or --clear when it has stopped",
36
- claim: "reserve a resource; exit 5 when someone else already holds it",
37
- release: "give a claim back",
36
+ claim: "reserve resources atomically; exit 5 when a peer holds one",
37
+ release: "give selected claims back, or --all for this session",
38
38
  ack: "acknowledge receipt without accepting work or writing an answer",
39
39
  message: "send a typed message to named participants",
40
40
  inbox: "list pending message headers; --message reads one complete message",
@@ -72,7 +72,7 @@ const CHOICES = Object.freeze({
72
72
  finish: { status: HANDOFF_STATUSES },
73
73
  message: { type: GENERIC_MESSAGE_KINDS, obligation: OBLIGATIONS },
74
74
  sync: { scope: ["delta", "full", "history"], type: MESSAGE_KINDS },
75
- install: { delivery: LIVE_POLICIES, adapter: ADAPTER_IDS },
75
+ install: { delivery: LIVE_POLICIES, adapter: ADAPTER_IDS, indicator: ["on", "off"] },
76
76
  uninstall: { adapter: ADAPTER_IDS },
77
77
  });
78
78
 
@@ -80,6 +80,7 @@ const NOTES = Object.freeze({
80
80
  doctor: ["--repair completes recoverable writes without changing the store contract.",
81
81
  "--migrate-store explicitly changes contract 6 to 7 after old clients and unmanaged store users stop; it cannot be combined with --repair."],
82
82
  install: ["Without --delivery, one default-No question covers all selected clients that need a decision.",
83
+ "--indicator on|off controls the compact indicator in supported clients; omission preserves the saved choice (initially off).",
83
84
  "--delivery actionable|all gives explicit complete-setup consent; --delivery off disables incoming automatic requests.",
84
85
  "A dry run does not prompt, write configuration, or start a service. Service readiness does not establish a session binding."],
85
86
  attach: ["Native hooks already manage their own session. Attach only for a manual CLI session.",
@@ -87,8 +88,12 @@ const NOTES = Object.freeze({
87
88
  "--cadence is a positive interval in milliseconds."],
88
89
  work: ["Provide --summary unless using --clear. Default mode: edit; default state: active."],
89
90
  claim: ["--lease is in seconds. Default mode: exclusive; default enforcement: advisory.",
90
- "For files, use file:src/a.mjs or file:src/**. A claim may conflict with a peer."],
91
- release: ["Provide --claim or --resource. Releasing another owner's claim requires human or policy authority."],
91
+ "Repeat --resource for an atomic batch. A conflict changes no claims, including existing leases.",
92
+ "For files, use file:src/a.mjs or file:src/**. Duplicate canonical resources are rejected."],
93
+ release: ["Choose repeated --claim, repeated --resource, or --all. Never combine selector families.",
94
+ "Explicit lists succeed completely or change no claims. --all releases only this session's claims and keeps it open.",
95
+ "An empty --all succeeds. A repeated explicit release of missing claims remains an error.",
96
+ "Releasing another owner's claim requires human or policy authority and a single selector."],
92
97
  message: ["Default type: note. question/request require --to and obligation reply; note uses none.",
93
98
  "decision permits none, or acknowledge when addressed. Use reply for answers and finish for handoffs.",
94
99
  "For decisions, repeat --supersedes or --withdraws with 1..16 prior decision IDs; never combine the two.",
@@ -148,11 +153,13 @@ export function commandHelpText(command) {
148
153
  const lines = [`acc ${name} - ${summary}`, "", `Usage: acc ${name}${suffix} [options]`, ""];
149
154
  for (const [key, label] of [["required", "Required"], ["optional", "Optional"],
150
155
  ["repeated", "Repeatable"], ["flags", "Flags"]]) {
151
- if (command[key].length === 0) continue;
156
+ const options = command[key].filter(option => key !== "repeated" || !command.required.includes(option));
157
+ if (options.length === 0) continue;
152
158
  lines.push(`${label}:`);
153
- for (const option of command[key]) {
159
+ for (const option of options) {
154
160
  const value = key === "flags" ? "" : ` <${choices[option]?.join("|") ?? "value"}>`;
155
- lines.push(` --${option}${value}`);
161
+ const repeatable = key === "required" && command.repeated.includes(option) ? " (repeatable)" : "";
162
+ lines.push(` --${option}${value}${repeatable}`);
156
163
  }
157
164
  lines.push("");
158
165
  }
@@ -1,5 +1,6 @@
1
1
  // Composition root: discovery, runtime locations, and the CLI surface.
2
2
  export { main } from "./main.mjs";
3
+ export { readIndicator } from "./indicator.mjs";
3
4
  export { COMMANDS, parseArgs } from "./args.mjs";
4
5
  export { askConfirmation } from "./confirm.mjs";
5
6
  // Exported so a test can hold every adapter to where it plans to write and
@@ -0,0 +1,65 @@
1
+ import { readTomlIndicatorSettings } from "@agents-can-communicate/adapter-sdk";
2
+ import { previousOutput } from "./indicator-previous-command.mjs";
3
+ import { readFile } from "node:fs/promises";
4
+ import { readIndicator, indicatorFailure } from "./indicator.mjs";
5
+ import { ALL_ADAPTERS } from "./install-command.mjs";
6
+
7
+ function output(report, options, context) {
8
+ if (options.json) return JSON.stringify(report);
9
+ if (options.details) return [report.label, report.detail, report.action].filter(Boolean).join("\n");
10
+ const extension = context.extension;
11
+ let label = report.label;
12
+ try { label = extension?.renderText?.(report) ?? label; }
13
+ catch { /* A display extension cannot prevent the diagnostic. */ }
14
+ const indicator = label + (report.health === "problem" ? " · acc doctor" : "");
15
+ try {
16
+ if (extension?.composeText) return extension.composeText({ ...context, indicator });
17
+ } catch { /* Keep the diagnostic if the footer cannot be composed. */ }
18
+ return [context.previous, indicator].filter(Boolean).join("\n");
19
+ }
20
+
21
+ async function input(stream) {
22
+ let source = "";
23
+ for await (const chunk of stream) {
24
+ source += chunk;
25
+ if (Buffer.byteLength(source) > 64 * 1024) throw new Error("indicator input too large");
26
+ }
27
+ return source;
28
+ }
29
+
30
+ export async function runIndicator({ argv = process.argv.slice(2), stdin = process.stdin,
31
+ stdout = process.stdout } = {}) {
32
+ const options = {}, context = { payload: {}, previous: "", settings: {} };
33
+ let report = indicatorFailure(), timeout, expired = false;
34
+ const controller = new AbortController();
35
+ try {
36
+ for (let i = 0; i < argv.length; i++) {
37
+ const flag = argv[i];
38
+ if (["--json", "--details"].includes(flag)) options[flag.slice(2)] = true;
39
+ else if (["--adapter", "--native-session", "--previous"].includes(flag)
40
+ && typeof argv[i + 1] === "string") options[flag.slice(2)] = argv[++i];
41
+ else throw new Error("invalid indicator option");
42
+ }
43
+ context.extension = ALL_ADAPTERS().find(adapter => adapter.id === options.adapter)?.statusIndicator;
44
+ const plain = options.json || options.details;
45
+ timeout = setTimeout(() => {
46
+ expired = true;
47
+ controller.abort();
48
+ stdout.write(output(indicatorFailure(), options, context) + "\n", () => process.exit(0));
49
+ }, plain ? 1500 : context.extension?.budgetMs ?? 1500);
50
+ const source = options["native-session"] ? "{}" : await input(stdin);
51
+ context.payload = source.trim() ? JSON.parse(source) : {};
52
+ report = await readIndicator({ adapterId: options.adapter,
53
+ nativeSessionId: options["native-session"] ?? (context.extension?.nativeSessionId
54
+ ? context.extension.nativeSessionId(context.payload)
55
+ : context.payload.conversation_id ?? context.payload.session_id) });
56
+ if (options.previous && !plain) {
57
+ const saved = JSON.parse(await readFile(options.previous, "utf8"));
58
+ context.settings = await readTomlIndicatorSettings(saved);
59
+ context.previous = await previousOutput(saved.previous, source,
60
+ context.extension?.previousTimeoutMs ?? 700, controller.signal);
61
+ }
62
+ } catch { /* A status renderer fails open with an actionable diagnostic. */ }
63
+ finally { clearTimeout(timeout); controller.abort(); }
64
+ if (!expired) stdout.write(output(report, options, context) + "\n");
65
+ }
@@ -0,0 +1,84 @@
1
+ import { spawn } from "node:child_process";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const LIMIT = 64 * 1024;
6
+
7
+ function stopTree(child) {
8
+ if (!Number.isInteger(child?.pid) || child.pid <= 0) return;
9
+ if (process.platform !== "win32") {
10
+ // detached creates a new group for this command, never for the host client.
11
+ try { process.kill(-child.pid, "SIGKILL"); } catch { /* Already gone. */ }
12
+ return;
13
+ }
14
+ // Keep the owned launcher alive until taskkill discovers its descendants.
15
+ // Killing it first loses that relationship; an exited PID must never be reused.
16
+ if (child.exitCode !== null || child.signalCode !== null) return;
17
+ const root = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows";
18
+ return new Promise(resolve => {
19
+ // Launcher and cleanup must survive the outer deadline: libuv kills
20
+ // non-detached Windows children when their spawning Node process exits.
21
+ const killer = spawn(path.win32.join(root, "System32", "taskkill.exe"),
22
+ ["/pid", String(child.pid), "/T", "/F"], { detached: true, windowsHide: true, stdio: "ignore" });
23
+ const timer = setTimeout(() => { killer.kill(); resolve(); }, 1000);
24
+ const done = () => { clearTimeout(timer); resolve(); };
25
+ killer.once("error", done);
26
+ killer.once("exit", done);
27
+ killer.unref();
28
+ });
29
+ }
30
+
31
+ // The original command receives the original stdin. Cancellation belongs to
32
+ // the indicator request; its timeout must not leave another polling process.
33
+ export async function previousOutput(previous, payload, timeout, signal) {
34
+ if (signal?.aborted || previous?.enabled === false
35
+ || ["builtin", "disabled", "off", "none", "hidden"].includes(previous?.type)
36
+ || typeof previous?.command !== "string" || !previous.command) return "";
37
+ return new Promise(resolve => {
38
+ let child, timer, settled = false, text = "";
39
+ const finish = () => {
40
+ if (settled) return;
41
+ settled = true;
42
+ clearTimeout(timer);
43
+ signal?.removeEventListener("abort", finish);
44
+ // Continue draining stdout until Windows has stopped the tree. Closing
45
+ // it first can make the shell exit before taskkill inspects its children.
46
+ let cleanup;
47
+ try { cleanup = stopTree(child); } catch { /* Cleanup must fail open. */ }
48
+ Promise.resolve(cleanup).catch(() => {}).finally(() => {
49
+ child?.stdin?.destroy();
50
+ child?.stdout?.destroy();
51
+ if (child?.connected) child.disconnect();
52
+ child?.unref();
53
+ resolve(text.slice(0, LIMIT).trimEnd());
54
+ });
55
+ };
56
+ try {
57
+ const windows = process.platform === "win32";
58
+ child = windows
59
+ ? spawn(process.execPath, [fileURLToPath(new URL("./indicator-previous-windows.mjs", import.meta.url)), previous.command],
60
+ { detached: true, stdio: ["pipe", "pipe", "ignore", "ipc"], windowsHide: true })
61
+ : spawn(previous.command, { shell: true, detached: true,
62
+ stdio: ["pipe", "pipe", "ignore"], windowsHide: true });
63
+ child.stdout.on("data", chunk => {
64
+ if (settled) return;
65
+ text += chunk;
66
+ if (Buffer.byteLength(text) > LIMIT) finish();
67
+ });
68
+ child.stdin.on("error", () => {});
69
+ child.once("error", finish);
70
+ child.once("close", finish);
71
+ signal?.addEventListener("abort", finish, { once: true });
72
+ // The command budget starts when the Windows launcher starts the shell.
73
+ // Startup is bounded too; the enclosing indicator deadline can abort both.
74
+ timer = setTimeout(finish, windows ? 1000 : timeout);
75
+ if (windows) child.once("message", message => {
76
+ if (settled || message !== "started") return;
77
+ clearTimeout(timer);
78
+ timer = setTimeout(finish, timeout);
79
+ });
80
+ child.stdin.end(payload);
81
+ if (signal?.aborted) finish();
82
+ } catch { finish(); }
83
+ });
84
+ }
@@ -0,0 +1,8 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ // A detached cmd.exe loses its standard handles. Keep the owned Node launcher
4
+ // detached, and let a normal hidden shell inherit its original stdin/stdout.
5
+ const child = spawn(process.argv[2], { shell: true, windowsHide: true, stdio: "inherit" });
6
+ child.once("spawn", () => process.send?.("started", () => {}));
7
+ child.once("error", () => process.exit(0));
8
+ child.once("exit", () => process.exit(0));
@@ -0,0 +1,112 @@
1
+ import { createHash } from "node:crypto";
2
+ import path from "node:path";
3
+ import { assertPortableId } from "@agents-can-communicate/protocol";
4
+ import { effectiveCapabilities, loadNativeAttempt, loadSessionBinding, loadOfferObservation }
5
+ from "@agents-can-communicate/adapter-sdk";
6
+ import { readDeliveryBindingRecord, readSessionRecord } from "@agents-can-communicate/storage-filesystem";
7
+ import { readInstalledLivePolicyState } from "@agents-can-communicate/installer";
8
+ import { ALL_ADAPTERS } from "./install-command.mjs";
9
+ import { canonicalManagerRoot, managedDirectory, readManagedJson } from "./managed-runtime/state.mjs";
10
+ import { platformDataHome, runtimePaths } from "./runtime-paths.mjs";
11
+
12
+ const receptionText = {
13
+ automatic: "This chat can receive addressed requests without another user prompt. A busy client may queue them.",
14
+ turn: "Messages reach this chat on its next normal turn.",
15
+ inbox: "Messages are available when the agent reads acc inbox; no polling schedule is implied.",
16
+ };
17
+
18
+ function result({ health = "ready", reception = null, reasonCode = null,
19
+ detail, action = null, leaseCurrent = null }) {
20
+ const qualifier = reception && reception !== "automatic" ? ` · ${reception}` : "";
21
+ return { health, reception, reasonCode, leaseCurrent,
22
+ label: `ACC ${health === "ready" ? "●" : "!"}${qualifier}`,
23
+ detail: [detail, receptionText[reception]].filter(Boolean).join(" "), action };
24
+ }
25
+
26
+ export const indicatorFailure = (reasonCode = "state_unreadable") => result({
27
+ health: "problem", reasonCode,
28
+ detail: reasonCode === "not_registered" ? "This chat is not registered with ACC."
29
+ : "ACC could not read this chat's local integration state.",
30
+ action: reasonCode === "not_registered"
31
+ ? "Send a prompt to run the session hooks. If ACC is still absent, run acc doctor and check the plugin is enabled."
32
+ : "Run acc doctor in this project to identify and repair the local state problem.",
33
+ });
34
+
35
+ const actions = {
36
+ client_session_embedded: "This session has no shared delivery service. Run acc doctor to see the launch option and how to open a supported session.",
37
+ client_process_unknown: "Open a new client session so ACC can identify its process; then run acc doctor.",
38
+ transport_permission_denied: "Allow the sending command to access the local delivery channel; acc doctor shows the affected session.",
39
+ handshake_version_mismatch: "Update the client's delivery service to a supported version; acc doctor shows the version in use.",
40
+ native_policy_off: "Automatic delivery is disabled in ACC settings. Run acc install --delivery actionable to enable it.",
41
+ inbound_approval_required: "Approve the pending inbound request in the receiving client, or review its incoming-message setting.",
42
+ };
43
+
44
+ /** A display observation, never CLI ownership. Native ids select one saved room
45
+ * and one binding; there is no roster scan, Git probe, store open, socket probe,
46
+ * lease refresh, heartbeat, update worker or managed-runtime process lease. */
47
+ export async function readIndicator({ adapterId, nativeSessionId,
48
+ dataHome = platformDataHome(), now = new Date().toISOString() }) {
49
+ try {
50
+ const adapter = ALL_ADAPTERS().find(item => item.id === adapterId);
51
+ if (!adapter || typeof nativeSessionId !== "string" || !nativeSessionId
52
+ || nativeSessionId.length > 4096) return indicatorFailure("not_registered");
53
+ const key = createHash("sha256").update(JSON.stringify([adapterId, nativeSessionId])).digest("hex");
54
+ const accRoot = await canonicalManagerRoot(path.join(dataHome, "acc"));
55
+ const rooms = path.join(accRoot, "native-workspaces");
56
+ if (!await managedDirectory(rooms)) return indicatorFailure("not_registered");
57
+ const room = await readManagedJson(path.join(rooms, `${key}.json`));
58
+ if (!room) return indicatorFailure("not_registered");
59
+ if (room.schemaVersion !== 1 || room.adapterId !== adapterId
60
+ || room.nativeSessionId !== nativeSessionId) throw new Error("invalid room");
61
+ assertPortableId(room.workspaceId, "workspace id");
62
+ const root = runtimePaths({ dataHome: path.dirname(accRoot), workspaceId: room.workspaceId }).root;
63
+ const owner = await loadSessionBinding({ runtimeDir: root, harnessSessionId: nativeSessionId });
64
+ if (!owner) return indicatorFailure("not_registered");
65
+ const input = { root, workspaceId: room.workspaceId, sessionId: owner.accSessionId };
66
+ const session = await readSessionRecord(input);
67
+ if (session?.state !== "open" || session.generation !== owner.generation
68
+ || session.harness !== adapterId) return indicatorFailure("not_registered");
69
+ const [binding, handshake, policy, offer] = await Promise.all([
70
+ readDeliveryBindingRecord(input),
71
+ loadNativeAttempt({ runtimeDir: root, harnessSessionId: nativeSessionId, ...owner }),
72
+ readInstalledLivePolicyState({ dataHome: path.dirname(accRoot), adapterId }),
73
+ loadOfferObservation({ runtimeDir: root, sessionId: owner.accSessionId, generation: owner.generation }),
74
+ ]);
75
+ const attempt = offer && (!handshake || Date.parse(offer.at) >= Date.parse(handshake.at)) ? offer : handshake;
76
+ const current = await readSessionRecord(input);
77
+ if (current?.state !== "open" || current.generation !== owner.generation) {
78
+ return indicatorFailure("not_registered");
79
+ }
80
+ const capabilities = effectiveCapabilities(adapter, {
81
+ clientName: owner.clientName ?? adapter.client.certificationName,
82
+ clientVersion: owner.clientVersion, platform: owner.platform,
83
+ });
84
+ const fallback = capabilities.delivery.nextTurn ? "turn" : "inbox";
85
+ const bound = binding?.generation === owner.generation && binding.adapterId === adapterId
86
+ && binding.retiredAt == null && binding.availableModes.includes("livePush");
87
+ const leaseCurrent = bound ? Date.parse(binding.leaseUntil) > Date.parse(now) : null;
88
+ if (["missing", "invalid", "unavailable"].includes(policy.policyStatus)) {
89
+ return result({ health: "problem", reception: fallback, reasonCode: "installation_unavailable",
90
+ detail: "ACC cannot find a valid delivery setting for this integration.",
91
+ action: `Run acc install --adapter ${adapterId} to repair the integration.`, leaseCurrent });
92
+ }
93
+ if (policy.policy === "off") return result({ reception: fallback, reasonCode: "native_policy_off",
94
+ detail: "Automatic delivery is disabled in ACC settings.", leaseCurrent });
95
+ if (attempt?.state === "degraded") return result({ health: "problem", reception: fallback,
96
+ reasonCode: attempt.reasonCode ?? "handshake_failed", leaseCurrent,
97
+ detail: "ACC could not establish automatic delivery for this chat.",
98
+ action: actions[attempt.reasonCode]
99
+ ?? "Run acc doctor in this project for the channel failure and the client's recovery steps.",
100
+ });
101
+ // Lease expiry alone is neither disconnection nor an operation in progress.
102
+ // The delivery router verifies the endpoint and renews it on the next send.
103
+ if (bound && binding.livePolicy !== "off") return result({ reception: "automatic", leaseCurrent });
104
+ if (attempt?.state === "unsupported" || !capabilities.delivery.livePush) {
105
+ return result({ reception: fallback, reasonCode: "native_delivery_unsupported",
106
+ detail: "This client session does not support automatic wake-up." });
107
+ }
108
+ return result({ health: "problem", reception: fallback, reasonCode: "native_unbound",
109
+ detail: "This chat has no automatic delivery channel.",
110
+ action: "Send a prompt to let ACC establish the channel; if it remains unavailable, run acc doctor." });
111
+ } catch { return indicatorFailure(); }
112
+ }
@@ -224,6 +224,9 @@ export function actedOn(result) {
224
224
  }
225
225
 
226
226
  export async function runInstallCommand({ options, runtime, action = "install" }) {
227
+ if (options.indicator !== undefined && !["on", "off"].includes(options.indicator)) {
228
+ throw new AccError(EXIT.USAGE, "--indicator must be on or off");
229
+ }
227
230
  const adapters = selectAdapters(options.adapter);
228
231
  const home = options.home ?? runtime.env?.HOME ?? homedir();
229
232
  const { data: dataHome } = platformPaths({ platform: runtime.platform,
@@ -261,6 +264,7 @@ export async function runInstallCommand({ options, runtime, action = "install" }
261
264
  ? await decideDelivery({ options, detected, recorded, runtime, dryRun })
262
265
  : { deliveryByAdapter: {}, asked: [], notes: [] };
263
266
  const plan = planInstallation({ adapters, detected, context, action, recorded,
267
+ indicator: options.indicator,
264
268
  accVersion, allowDowngrade: options.downgrade === true, requested,
265
269
  deliveryByAdapter: decided.deliveryByAdapter,
266
270
  deliveryDecisionByAdapter: decided.deliveryDecisionByAdapter,
@@ -23,7 +23,7 @@ import { runDoctor } from "./doctor-command.mjs";
23
23
  import { runManagementDoctor } from "./managed-runtime/diagnostics.mjs";
24
24
  import { runStoreMigration } from "./store-migration-command.mjs";
25
25
  import { createGitProbe } from "./git-probe.mjs";
26
- import { canonicalClaim } from "./claim-spelling.mjs";
26
+ import { runClaimCommand, runReleaseCommand } from "./claim-command.mjs";
27
27
  import { platformDataHome, runtimePaths } from "./runtime-paths.mjs";
28
28
  import { resolveOwner } from "./session-owner.mjs";
29
29
  import { discoverWorkspace } from "./workspace-discovery.mjs";
@@ -31,39 +31,6 @@ import { resolveSavedHookWorkspace } from "./hook-workspace.mjs";
31
31
 
32
32
  const DEFAULT_CADENCE_MS = 30_000;
33
33
 
34
- /**
35
- * The claim a caller means when they name a resource instead of an id.
36
- *
37
- * `acc claim` takes `--resource`; `acc release` took `--claim`. The asymmetry
38
- * cost every caller - a person and an agent alike - a round trip through
39
- * `acc status --json` to find an id they never chose. The id is still accepted,
40
- * and is still the precise answer when two sessions hold the same resource and
41
- * an authority is releasing someone else's.
42
- */
43
- async function claimOn(options, context) {
44
- if (options.resource === undefined) {
45
- throw new AccError(EXIT.USAGE,
46
- "release needs the claim to give back: --resource is what you claimed, "
47
- + "--claim is its id");
48
- }
49
- const resource = await canonicalClaim(options.resource, context.descriptor);
50
- const { claims } = await context.service.collectStatus({});
51
- // An authority releasing another session's claim is not looking for its own.
52
- const mine = claims.filter(claim => claim.resource === resource
53
- && (options.authority !== undefined || claim.ownerSessionId === options.session));
54
-
55
- if (mine.length === 0) {
56
- throw new AccError(EXIT.DATA, `no claim on ${resource} to release`,
57
- { resource, held: claims.map(claim => claim.resource) });
58
- }
59
- if (mine.length > 1) {
60
- throw new AccError(EXIT.DATA,
61
- `${mine.length} claims on ${resource}; name the one you mean with --claim`,
62
- { resource, claims: mine.map(claim => claim.claimId) });
63
- }
64
- return mine[0].claimId;
65
- }
66
-
67
34
  /**
68
35
  * Where this workspace's state lives, without reading a byte of it.
69
36
  *
@@ -327,25 +294,8 @@ const HANDLERS = Object.freeze({
327
294
  return { data: intent, text: `intent: ${intent.summary}` };
328
295
  },
329
296
 
330
- claim: async ({ options, context }) => {
331
- const claim = await context.service.acquireClaim({ sessionId: options.session,
332
- generation: options.generation,
333
- resource: await canonicalClaim(options.resource, context.descriptor),
334
- mode: options.mode ?? "exclusive", enforcement: options.enforcement ?? "advisory",
335
- reason: options.reason ?? "unspecified",
336
- leaseSeconds: options.lease ? positiveNumber(options.lease, "lease") : undefined,
337
- descriptor: context.descriptor });
338
- return { data: claim, text: `claimed ${claim.resource}` };
339
- },
340
-
341
- release: async ({ options, context }) => {
342
- const claimId = options.claim ?? await claimOn(options, context);
343
- const request = { claimId, sessionId: options.session,
344
- generation: options.generation, authority: options.authority, reason: options.reason };
345
- if (options.authority === undefined) await context.service.releaseClaim(request);
346
- else await context.service.forceReleaseClaim(request);
347
- return { data: { claimId }, text: `released ${claimId}` };
348
- },
297
+ claim: runClaimCommand,
298
+ release: runReleaseCommand,
349
299
 
350
300
  message: async ({ options, context }) => {
351
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);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.10.4",
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
+ }