agents-can-communicate 0.3.0 → 0.4.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 (157) hide show
  1. package/README.md +112 -91
  2. package/SECURITY.md +8 -5
  3. package/bin/acc-bootstrap.mjs +5 -54
  4. package/bin/acc-claude-channel.mjs +8 -175
  5. package/bin/acc-hook.mjs +4 -133
  6. package/bin/acc-mcp.mjs +4 -59
  7. package/bin/acc-update-worker.mjs +8 -0
  8. package/bin/acc.mjs +5 -39
  9. package/bin/entrypoints/acc-bootstrap.mjs +47 -0
  10. package/bin/entrypoints/acc-claude-channel.mjs +149 -0
  11. package/bin/entrypoints/acc-hook.mjs +58 -0
  12. package/bin/entrypoints/acc-mcp.mjs +70 -0
  13. package/bin/entrypoints/acc.mjs +51 -0
  14. package/bin/entrypoints/claude-channel-stdio.mjs +29 -0
  15. package/bin/entrypoints/hook-output.mjs +73 -0
  16. package/docs/ADAPTER_AUTHORING.md +62 -24
  17. package/docs/ARCHITECTURE.md +79 -20
  18. package/docs/CAPABILITIES.md +52 -30
  19. package/docs/CLI.md +164 -35
  20. package/docs/CONCEPTS.md +97 -93
  21. package/docs/CONFIGURATION.md +66 -45
  22. package/docs/DESIGN_DECISIONS.md +24 -15
  23. package/docs/GETTING_STARTED.md +124 -118
  24. package/docs/GLOSSARY.md +12 -6
  25. package/docs/HOW_IT_WORKS.md +136 -229
  26. package/docs/MCP.md +98 -31
  27. package/docs/PROTOCOL.md +76 -14
  28. package/docs/RELEASING.md +128 -86
  29. package/docs/SECURITY_MODEL.md +52 -15
  30. package/docs/TROUBLESHOOTING.md +88 -18
  31. package/docs/UPGRADING.md +119 -0
  32. package/docs/WHY_ACC.md +19 -16
  33. package/docs/index.md +40 -24
  34. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  35. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +112 -9
  36. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +6 -6
  37. package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +2 -1
  38. package/node_modules/@agents-can-communicate/adapter-codex/certification.json +38 -13
  39. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +220 -0
  40. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-current-binding-product-evidence.json +1549 -0
  41. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-current-binding-product.json +27 -0
  42. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-product-evidence.json +1549 -0
  43. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-product.json +27 -0
  44. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-transport-evidence.json +259 -0
  45. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-transport.json +21 -0
  46. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-current-binding-product-evidence.json +1549 -0
  47. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-current-binding-product.json +27 -0
  48. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-product-evidence.json +1549 -0
  49. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-product.json +27 -0
  50. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-transport-evidence.json +259 -0
  51. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-transport.json +21 -0
  52. package/node_modules/@agents-can-communicate/adapter-codex/package.json +14 -2
  53. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +111 -8
  54. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +26 -44
  55. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +70 -17
  56. package/node_modules/@agents-can-communicate/adapter-codex/src/config-block.mjs +231 -0
  57. package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +1 -1
  58. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +71 -63
  59. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +107 -103
  60. package/node_modules/@agents-can-communicate/adapter-codex/src/native-endpoint.mjs +96 -0
  61. package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +3 -2
  62. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  63. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +111 -8
  64. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  65. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +2 -2
  66. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  67. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +111 -8
  68. package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +2 -2
  69. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  70. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +111 -8
  71. package/node_modules/@agents-can-communicate/adapter-kimi/src/hooks.mjs +1 -1
  72. package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +2 -2
  73. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  74. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +5 -0
  75. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +26 -4
  76. package/node_modules/@agents-can-communicate/adapter-sdk/src/decision-text.mjs +19 -0
  77. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +5 -3
  78. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -0
  79. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +9 -2
  80. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +2 -6
  81. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +29 -9
  82. package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-block.mjs +8 -6
  83. package/node_modules/@agents-can-communicate/cli/package.json +4 -2
  84. package/node_modules/@agents-can-communicate/cli/src/args.mjs +49 -22
  85. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +11 -4
  86. package/node_modules/@agents-can-communicate/cli/src/git-probe.mjs +9 -3
  87. package/node_modules/@agents-can-communicate/cli/src/help.mjs +109 -14
  88. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +66 -18
  89. package/node_modules/@agents-can-communicate/cli/src/main.mjs +35 -12
  90. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +110 -0
  91. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/command.mjs +63 -0
  92. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +71 -0
  93. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +110 -0
  94. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/generation.mjs +167 -0
  95. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/install.mjs +73 -0
  96. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +60 -0
  97. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/leases.mjs +60 -0
  98. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/location.mjs +55 -0
  99. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/mutex.mjs +145 -0
  100. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/policy.mjs +4 -0
  101. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +40 -0
  102. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +21 -0
  103. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +122 -0
  104. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +65 -0
  105. package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +33 -108
  106. package/node_modules/@agents-can-communicate/cli/src/update-command.mjs +25 -7
  107. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  108. package/node_modules/@agents-can-communicate/core/src/attention.mjs +3 -0
  109. package/node_modules/@agents-can-communicate/core/src/claims.mjs +13 -8
  110. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +48 -24
  111. package/node_modules/@agents-can-communicate/core/src/decision-changes.mjs +36 -0
  112. package/node_modules/@agents-can-communicate/core/src/decision-state.mjs +42 -0
  113. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +11 -4
  114. package/node_modules/@agents-can-communicate/core/src/inbox.mjs +48 -17
  115. package/node_modules/@agents-can-communicate/core/src/intents.mjs +35 -15
  116. package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +7 -5
  117. package/node_modules/@agents-can-communicate/core/src/message-pages.mjs +75 -0
  118. package/node_modules/@agents-can-communicate/core/src/next-turn-delivery.mjs +31 -0
  119. package/node_modules/@agents-can-communicate/core/src/session-opening.mjs +128 -0
  120. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +58 -132
  121. package/node_modules/@agents-can-communicate/core/src/sync.mjs +31 -2
  122. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  123. package/node_modules/@agents-can-communicate/delivery-router/src/refresh-binding.mjs +38 -0
  124. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +86 -10
  125. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  126. package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +1 -0
  127. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +6 -4
  128. package/node_modules/@agents-can-communicate/hook-runner/src/native-retirement.mjs +48 -0
  129. package/node_modules/@agents-can-communicate/hook-runner/src/process-table.mjs +1 -1
  130. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +175 -67
  131. package/node_modules/@agents-can-communicate/hook-runner/src/session-lifecycle.mjs +13 -0
  132. package/node_modules/@agents-can-communicate/installer/package.json +4 -2
  133. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +17 -2
  134. package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +11 -5
  135. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +1 -1
  136. package/node_modules/@agents-can-communicate/installer/src/index.mjs +1 -0
  137. package/node_modules/@agents-can-communicate/installer/src/live-policy.mjs +21 -0
  138. package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +20 -6
  139. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +10 -4
  140. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +3 -3
  141. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  142. package/node_modules/@agents-can-communicate/mcp-server/src/protocol.mjs +85 -0
  143. package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +3 -2
  144. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +59 -57
  145. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +47 -33
  146. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  147. package/node_modules/@agents-can-communicate/protocol/src/conversations.mjs +3 -0
  148. package/node_modules/@agents-can-communicate/protocol/src/decision-changes.mjs +15 -0
  149. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +3 -1
  150. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +14 -5
  151. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  152. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +5 -1
  153. package/node_modules/@agents-can-communicate/storage-filesystem/src/deadline.mjs +7 -0
  154. package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +2 -0
  155. package/node_modules/@agents-can-communicate/storage-filesystem/src/session-read.mjs +25 -0
  156. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +28 -25
  157. package/package.json +2 -1
@@ -0,0 +1,122 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, mkdir, open, realpath, rename, rm } from "node:fs/promises";
4
+ import path from "node:path";
5
+
6
+ /** Resolve existing ancestors without creating an uninitialized manager. */
7
+ export async function canonicalManagerRoot(root) {
8
+ if (typeof root !== "string" || !path.isAbsolute(root)) throw new Error("manager root must be absolute");
9
+ let current = path.resolve(root);
10
+ const tail = [];
11
+ for (;;) {
12
+ try { return path.join(await realpath(current), ...tail); }
13
+ catch (error) {
14
+ if (error.code !== "ENOENT") throw error;
15
+ const parent = path.dirname(current);
16
+ if (parent === current) throw error;
17
+ tail.unshift(path.basename(current));
18
+ current = parent;
19
+ }
20
+ }
21
+ }
22
+
23
+ export async function managedDirectory(directory, { create = false } = {}) {
24
+ if (create) await mkdir(directory, { recursive: true, mode: 0o700 });
25
+ try {
26
+ const info = await lstat(directory);
27
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("managed directory is not a regular directory");
28
+ if (create) {
29
+ const handle = await open(directory, constants.O_RDONLY | constants.O_NOFOLLOW);
30
+ try { await handle.chmod(0o700); } finally { await handle.close(); }
31
+ }
32
+ return true;
33
+ } catch (error) {
34
+ if (!create && error.code === "ENOENT") return false;
35
+ throw error;
36
+ }
37
+ }
38
+
39
+ export async function syncDirectory(directory) {
40
+ const handle = await open(directory, constants.O_RDONLY | constants.O_NOFOLLOW);
41
+ try { await handle.sync(); } finally { await handle.close(); }
42
+ }
43
+
44
+ /** Internal callers validate every managed parent before opening a record. */
45
+ export async function readManagedJson(file) {
46
+ let handle;
47
+ try {
48
+ handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
49
+ if (!(await handle.stat()).isFile()) throw new Error("managed record must be a regular file");
50
+ return JSON.parse(await handle.readFile("utf8"));
51
+ } catch (error) {
52
+ if (error.code === "ENOENT") return undefined;
53
+ throw error;
54
+ } finally { await handle?.close(); }
55
+ }
56
+
57
+ export async function writeManagedJson(file, value) {
58
+ const temporary = path.join(path.dirname(file), `.record-${randomUUID()}.tmp`);
59
+ let handle;
60
+ try {
61
+ handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
62
+ await handle.writeFile(`${JSON.stringify(value)}\n`);
63
+ await handle.sync();
64
+ await handle.close();
65
+ handle = null;
66
+ await rename(temporary, file);
67
+ await syncDirectory(path.dirname(file));
68
+ } finally {
69
+ await handle?.close();
70
+ await rm(temporary, { force: true });
71
+ }
72
+ }
73
+
74
+ const nonempty = value => typeof value === "string" && value.length > 0;
75
+ const nullableString = value => value === null || nonempty(value);
76
+
77
+ export async function validateRuntime(root, runtime) {
78
+ if (!runtime || !nonempty(runtime.version) || typeof runtime.root !== "string"
79
+ || !path.isAbsolute(runtime.root)) throw new Error("invalid control generation");
80
+ const generations = path.join(root, "generations");
81
+ const resolved = await canonicalManagerRoot(runtime.root);
82
+ const relative = path.relative(generations, resolved);
83
+ if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
84
+ throw new Error("control runtime must be inside manager generations");
85
+ }
86
+ // An internal symlink can redirect all generations outside the manager even
87
+ // when a lexical pointer appears contained.
88
+ if (await managedDirectory(generations)) {
89
+ if (await realpath(generations) !== generations) throw new Error("invalid control generations directory");
90
+ }
91
+ return { version: runtime.version, root: resolved };
92
+ }
93
+
94
+ async function validateControl(root, value) {
95
+ if (!value || value.schemaVersion !== 1 || !["ready", "activating"].includes(value.phase)
96
+ || typeof value.auto !== "boolean" || !nullableString(value.pin)
97
+ || !nullableString(value.checkedAt) || !nullableString(value.notice)
98
+ || !nonempty(value.home) || !path.isAbsolute(value.home)
99
+ || !Array.isArray(value.targets) || !value.targets.every(nonempty)
100
+ || !(value.pending === null || typeof value.pending === "object")) {
101
+ throw new Error("invalid managed runtime control");
102
+ }
103
+ const active = await validateRuntime(root, value.active);
104
+ const pending = value.pending === null ? null : await validateRuntime(root, value.pending);
105
+ return { ...value, active, pending };
106
+ }
107
+
108
+ export async function readControl(root) {
109
+ root = await canonicalManagerRoot(root);
110
+ if (!await managedDirectory(root)) return null;
111
+ const value = await readManagedJson(path.join(root, "control.json"));
112
+ return value === undefined ? null : validateControl(root, value);
113
+ }
114
+
115
+ /** Caller holds withManagerLock for any read-modify-write transaction. */
116
+ export async function writeControl(root, value) {
117
+ root = await canonicalManagerRoot(root);
118
+ const validated = await validateControl(root, value);
119
+ await managedDirectory(root, { create: true });
120
+ await writeManagedJson(path.join(root, "control.json"), validated);
121
+ return validated;
122
+ }
@@ -0,0 +1,65 @@
1
+ import { activatePending } from "./activation.mjs";
2
+ import { downloadRelease, fetchRelease, newerVersion } from "./download.mjs";
3
+ import { withManagerLock } from "./mutex.mjs";
4
+ import { readControl, writeControl } from "./state.mjs";
5
+
6
+ import { checkDue, networkDisabled } from "./policy.mjs";
7
+ export { CHECK_INTERVAL_MS, networkDisabled } from "./policy.mjs";
8
+
9
+ /** One pass. The worker lock owns scheduling; admission is held only for state changes. */
10
+ export async function performUpdate(root, { force = false, check = false, env = process.env,
11
+ discover = fetchRelease, download = downloadRelease, activate = activatePending,
12
+ ignorePid = null } = {}) {
13
+ const initial = await readControl(root);
14
+ if (initial === null) throw new Error("ACC is not enrolled; run acc install first");
15
+ const data = { running: initial.active.version, auto: initial.auto, pin: initial.pin,
16
+ pending: initial.pending?.version ?? null, notice: initial.notice };
17
+ if (!force && !check && !initial.auto) return { ...data, checked: false, reason: "auto_off" };
18
+ // Recovery is local; the hard no-network override must not strand a partial refresh.
19
+ if (initial.pending && !check && (force || !networkDisabled(env))) {
20
+ return { ...data, ...await activate(root, { env, ignorePid }), checked: false };
21
+ }
22
+ if (networkDisabled(env)) return { ...data, checked: false, reason: "network_disabled" };
23
+ if (!force && !check && !checkDue(initial)) return { ...data, checked: false, reason: "not_due" };
24
+ if (!check) await withManagerLock(root, async () => {
25
+ const current = await readControl(root);
26
+ await writeControl(root, { ...current, checkedAt: new Date().toISOString() });
27
+ });
28
+ const release = await discover({ pin: initial.pin, env });
29
+ const newer = newerVersion(release.version, initial.active.version);
30
+ const checked = { ...data, checked: true, latest: release.version, newer };
31
+ if (check || !newer) return checked;
32
+ const generation = await download(root, release, { env });
33
+ const published = await withManagerLock(root, async () => {
34
+ const current = await readControl(root);
35
+ if (current.phase !== "ready" || current.active.root !== initial.active.root
36
+ || current.pin !== initial.pin || !force && !current.auto) return false;
37
+ await writeControl(root, { ...current, pending: generation,
38
+ notice: `ACC ${generation.version} is downloaded and verified.` });
39
+ return true;
40
+ });
41
+ if (!published) return { ...checked, reason: "state_changed" };
42
+ return { ...checked, pending: generation.version, ...await activate(root, { env, ignorePid }) };
43
+ }
44
+
45
+ /** A single detached worker may wait cheaply for native clients to exit. */
46
+ export async function runWorker(root, { force = false, env = process.env, wait = false, ignorePid = null } = {}) {
47
+ return withManagerLock(`${root}/worker`, async () => {
48
+ for (;;) {
49
+ const result = await performUpdate(root, { force, env, ignorePid });
50
+ if (!wait || result.reason !== "processes_active") return result;
51
+ await new Promise(resolve => setTimeout(resolve, 60_000));
52
+ if (!(await readControl(root))?.auto || networkDisabled(env)) return result;
53
+ }
54
+ }, { timeoutMs: 100 });
55
+ }
56
+
57
+ export async function recordWorkerFailure(root, error) {
58
+ // A second scheduled worker losing the lock is ordinary, not a failed update.
59
+ if (/manager lock held/.test(error?.message ?? "")) return;
60
+ await withManagerLock(root, async () => {
61
+ const control = await readControl(root);
62
+ if (control) await writeControl(root, { ...control,
63
+ notice: "Automatic update could not finish; the previous runtime was kept. Run acc update for details." });
64
+ }).catch(() => {});
65
+ }
@@ -1,126 +1,51 @@
1
- import { listSessionBindings } from "@agents-can-communicate/adapter-sdk";
2
1
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
3
2
 
4
- /**
5
- * Work out which session is running this command.
6
- *
7
- * Every mutating command needs a session id and the generation that proves the
8
- * caller is that session. Until this existed, both had to be passed on the
9
- * command line, and the shipped skills told agents to pass
10
- * `--session "$ACC_SESSION" --generation "$ACC_GENERATION"` - two variables that
11
- * nothing in the system has ever set. The generation is not printed by `acc
12
- * status` either, deliberately: it is the proof of ownership, not public
13
- * information. So the documented workflow could not be carried out by any agent
14
- * on any client, and the only way through was to read ACC's own files by hand,
15
- * which the same skill forbids in as many words.
16
- *
17
- * The hook runtime already knows both. It writes them into a binding at
18
- * SessionStart, keyed by the harness's own session id. What was missing was a
19
- * way for a shell inside that session to find its own binding. Each step below
20
- * answers that from something the caller demonstrably has.
21
- */
3
+ // Inbox retrieves bodies and advances receipts, so it needs the same owner
4
+ // proof as a mutation. Public observations never need somebody else's token.
22
5
  const NEEDS_OWNER = Object.freeze(new Set(["work", "claim", "release", "message",
23
- "inbox", "reply", "request", "ack", "workstream", "task", "finish", "decide"]));
24
-
25
- /**
26
- * Reads that are answers about *you*.
27
- *
28
- * Attention is computed per session: what is addressed to you, what has become
29
- * unblocked for you. Run by hand with nothing identifying the caller, both of
30
- * these answered for nobody and returned an empty list - so an agent told at the
31
- * top of its turn that work was waiting could ask `acc status` and be told there
32
- * was none. They resolve softly: a shell with no session attached still gets the
33
- * roster and the events, which is what it came for.
34
- */
6
+ "inbox", "reply", "request", "ack", "finish"]));
35
7
  const WANTS_OWNER = Object.freeze(new Set(["sync", "status"]));
36
8
 
37
9
  export const needsOwner = command => NEEDS_OWNER.has(command);
38
10
 
11
+ const unresolved = command => new AccError(EXIT.USAGE,
12
+ `${command} could not tell which session is running it. Pass both --session and `
13
+ + "--generation from this turn's ACC hook header or your own acc attach, or use this "
14
+ + "session's ACC MCP tools. Do not copy a peer's session from acc status. Native "
15
+ + "session IDs and a shared checkout do not establish CLI ownership.",
16
+ { command, reasonCode: "caller_identity_unresolved" });
17
+
39
18
  /**
40
- * Bindings whose session is still open, carrying the session's own checkout.
19
+ * Accept caller-supplied credentials; never discover credentials from peers.
41
20
  *
42
- * A binding outlives a crash, so one that names a closed or vanished session is
43
- * a leftover. Acting as a closed session would fail deeper in the service with a
44
- * conflict; dropping it here means the *next* candidate can be recognised
45
- * instead.
21
+ * A sole binding, a matching checkout, and a public session selector all let an
22
+ * unbound client adopt another session. Native environment values do too:
23
+ * nested clients inherit their parent's IDs. Bindings identify hook payloads,
24
+ * not arbitrary shell callers. ACC_SESSION/ACC_GENERATION remain an explicit
25
+ * operator-supplied pair, not an assertion that environment provenance is known.
46
26
  */
47
- async function liveCandidates({ context }) {
48
- const bindings = await listSessionBindings({ runtimeDir: context.paths.root });
49
- if (bindings.length === 0) return [];
50
- const status = await context.service.collectStatus({});
51
- const live = new Map(status.participants
52
- .filter(participant => participant.presence !== "offline")
53
- .map(participant => [participant.sessionId, participant]));
54
- return bindings
55
- .filter(binding => live.has(binding.accSessionId))
56
- .map(binding => ({ ...binding, session: live.get(binding.accSessionId) }));
57
- }
58
-
59
- const describe = candidates => candidates
60
- .map(candidate => `${candidate.session.participantId} (${candidate.session.harness})`)
61
- .join(", ");
62
-
63
27
  export async function resolveOwner({ command, options, context, env = {} }) {
64
28
  const soft = WANTS_OWNER.has(command);
65
29
  if (!soft && !needsOwner(command)) return options;
66
- if (options.session !== undefined && options.generation !== undefined) return options;
67
- if (soft && options.participant !== undefined) return options;
68
-
69
- const candidates = await liveCandidates({ context });
70
- const take = candidate => ({ ...options, session: candidate.accSessionId,
71
- generation: candidate.generation,
72
- // `status` filters by participant rather than by session, so it is told who
73
- // the caller is in the terms it works in.
74
- participant: options.participant ?? candidate.session.participantId });
75
-
76
- // Named a session but not the generation: the id is in `acc status --json`,
77
- // so this is what an agent reaches for first, and refusing it would teach the
78
- // wrong lesson about which half is secret.
79
- if (options.session !== undefined) {
80
- // The reads never needed a generation, and a session opened by `acc attach`
81
- // has no binding to find one in. Answering for the session it was given is
82
- // both what it was asked and what it did before this resolver existed.
30
+ const explicit = options.session !== undefined && options.generation !== undefined;
31
+ const configured = typeof env.ACC_SESSION === "string" && typeof env.ACC_GENERATION === "string";
32
+ if (!explicit && !configured) {
83
33
  if (soft) return options;
84
- const named = candidates.find(candidate => candidate.accSessionId === options.session);
85
- if (named !== undefined) return take(named);
86
- throw new AccError(EXIT.USAGE,
87
- `no live session ${options.session} in this workspace`,
88
- { command, session: options.session });
34
+ throw unresolved(command);
89
35
  }
90
36
 
91
- // The documented pair, honoured for anyone who has wired it up themselves.
92
- if (typeof env.ACC_SESSION === "string" && typeof env.ACC_GENERATION === "string") {
93
- return { ...options, session: env.ACC_SESSION, generation: env.ACC_GENERATION };
37
+ const owner = explicit ? options : { session: env.ACC_SESSION, generation: env.ACC_GENERATION };
38
+ if (options.session !== undefined && options.session !== owner.session) {
39
+ // A public observation scope is allowed; it does not request credentials.
40
+ if (soft) return options;
41
+ throw unresolved(command);
94
42
  }
95
-
96
- // The client's own session id, under whatever name it exports it. Claude Code
97
- // sets CLAUDE_CODE_SESSION_ID, measured equal to the `session_id` its hooks
98
- // receive; matching on the value rather than the variable name means a client
99
- // that names it something else works without ACC knowing the name.
100
- const exported = new Set(Object.values(env).filter(value => typeof value === "string"));
101
- const byEnvironment = candidates
102
- .filter(candidate => exported.has(candidate.harnessSessionId));
103
- if (byEnvironment.length === 1) return take(byEnvironment[0]);
104
-
105
- // Two agents in one workspace are the normal case, and each is usually in its
106
- // own checkout - that is what the worktree story is. Same repository, so the
107
- // same workspace, and the checkout tells them apart.
108
- const here = context.descriptor.git?.worktreeRoot;
109
- const byCheckout = here === undefined
110
- ? []
111
- : candidates.filter(candidate => candidate.session.checkoutRoot === here);
112
- if (byCheckout.length === 1) return take(byCheckout[0]);
113
-
114
- if (candidates.length === 1) return take(candidates[0]);
115
-
116
- // Guessing between two live sessions would let one agent act as another, which
117
- // is exactly what the generation exists to prevent. So this refuses, and says
118
- // what it found.
119
- if (soft) return options;
120
- throw new AccError(EXIT.USAGE, candidates.length === 0
121
- ? `${command} could not tell which session is running it: no live session is `
122
- + "attached here. An adapter attaches one at SessionStart; `acc status` shows who is."
123
- : `${command} could not tell which of ${candidates.length} live sessions is running `
124
- + `it (${describe(candidates)}). Pass --session, which \`acc status --json\` lists.`,
125
- { command, candidates: candidates.map(candidate => candidate.accSessionId) });
43
+ // A supplied generation must reach the core unchanged so stale calls fail.
44
+ const resolved = { ...options, session: owner.session,
45
+ generation: options.generation ?? owner.generation };
46
+ if (command === "status" && options.participant === undefined) {
47
+ const { participants } = await context.service.collectStatus({});
48
+ resolved.participant = participants.find(item => item.sessionId === owner.session)?.participantId;
49
+ }
50
+ return resolved;
126
51
  }
@@ -7,6 +7,8 @@ import { AccError, EXIT } from "@agents-can-communicate/protocol";
7
7
  import { platformPaths } from "./platform-paths.mjs";
8
8
  import { checkingIsOff, fetchLatest, isNewer, writeCachedCheck } from "./update-check.mjs";
9
9
 
10
+ import { runManagedUpdate, validateUpdateOptions } from "./managed-runtime/command.mjs";
11
+
10
12
  const execFileAsync = promisify(execFile);
11
13
 
12
14
  /**
@@ -26,6 +28,14 @@ export const upgradeSteps = version => [
26
28
  const spell = ([command, argv]) => ` ${command} ${argv.join(" ")}`;
27
29
 
28
30
  export async function runUpdateCommand({ options, runtime }) {
31
+ if (options.check === true && options.apply === true) {
32
+ throw new AccError(EXIT.USAGE, "use either --check or --apply");
33
+ }
34
+ validateUpdateOptions(options);
35
+ if (runtime.managerRoot) return runManagedUpdate({ options, runtime });
36
+ if (options.auto !== undefined || options.pin !== undefined) {
37
+ throw new AccError(EXIT.USAGE, "run acc install before configuring automatic updates");
38
+ }
29
39
  const env = runtime.env ?? {};
30
40
  const { data: dataHome } = platformPaths({ platform: runtime.platform, env });
31
41
  const running = typeof runtime.version === "function"
@@ -61,26 +71,34 @@ export async function runUpdateCommand({ options, runtime }) {
61
71
  const data = { checked: true, running, latest, newer: true,
62
72
  steps: steps.map(([command, argv]) => [command, ...argv].join(" ")) };
63
73
 
64
- if (options.apply !== true) {
74
+ if (options.check === true) {
65
75
  return { data, text: [`acc ${latest} is available; you have ${running}`, "",
66
- ...steps.map(spell), "", "or run: acc update --apply"].join("\n") };
76
+ ...steps.map(spell), "", "or run: acc update"].join("\n") };
67
77
  }
68
78
 
69
79
  const spawn = runtime.spawn ?? ((command, argv) => execFileAsync(command, argv, { env }));
70
80
  const done = [];
81
+ let installation = { stdout: "", stderr: "" };
71
82
  for (const [command, argv] of steps) {
72
83
  try {
73
- await spawn(command, argv);
84
+ const output = await spawn(command, argv);
85
+ if (command === "acc") {
86
+ installation = { stdout: String(output?.stdout ?? "").trim(),
87
+ stderr: String(output?.stderr ?? "").trim() };
88
+ }
74
89
  done.push([command, ...argv].join(" "));
75
90
  } catch (error) {
76
91
  // Named rather than swallowed, and the rest of the commands are printed:
77
92
  // a global install refused for want of permission is the ordinary case,
78
93
  // and the person can finish it by hand from here.
94
+ const text = [`${command} failed: ${error.message}`, "", "finish it with:",
95
+ ...steps.slice(done.length).map(spell)].join("\n");
79
96
  return { data: { ...data, applied: done, failed: [command, ...argv].join(" ") },
80
- text: [`${command} failed: ${error.message}`, "", "finish it with:",
81
- ...steps.slice(done.length).map(spell)].join("\n"),
82
- error: undefined };
97
+ text, error: new AccError(EXIT.DATA, text) };
83
98
  }
84
99
  }
85
- return { data: { ...data, applied: done }, text: `updated to ${latest}` };
100
+ const activation = "Restart all running agent clients to load the updated ACC runtime and skills.";
101
+ return { data: { ...data, applied: done, installation, activation },
102
+ text: [`updated to ${latest}`, installation.stdout, installation.stderr, activation]
103
+ .filter(Boolean).join("\n\n") };
86
104
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -2,6 +2,7 @@ import { AccError, EXIT } from "@agents-can-communicate/protocol";
2
2
 
3
3
  import { overlaps } from "./claims.mjs";
4
4
  import { classifySessionPresence } from "./sessions.mjs";
5
+ import { decisionView, isCurrentDecision } from "./decision-state.mjs";
5
6
 
6
7
  export const ATTENTION_PRIORITY = Object.freeze({
7
8
  reply_required: 1,
@@ -95,6 +96,8 @@ export function computeAttention(snapshot, { session, participantId, now, pidIsA
95
96
  if (typeof pidIsAlive !== "function") {
96
97
  throw new AccError(EXIT.USAGE, "computeAttention requires a pidIsAlive probe", {});
97
98
  }
99
+ snapshot = { ...snapshot, messages: (snapshot.messages ?? [])
100
+ .map(decisionView(snapshot.messages ?? [])).filter(isCurrentDecision) };
98
101
  return [
99
102
  ...obligationItems(snapshot, participantId),
100
103
  ...unavailableRecipients(snapshot, participantId, now, pidIsAlive),
@@ -33,17 +33,19 @@ const isLive = (claim, now) => Date.parse(claim.expiresAt) > Date.parse(now);
33
33
  export function createClaimService(ports, sessions) {
34
34
  const { store, clock, ids, pidIsAlive } = ports;
35
35
 
36
- async function requireOwner(input, action) {
37
- const existing = await sessions.locateSession(input.sessionId, input.workspaceId);
38
- if (existing === null || existing.record.state !== "open") {
36
+ async function requireOwner(input, action, tx) {
37
+ const current = tx === undefined
38
+ ? (await sessions.locateSession(input.sessionId, input.workspaceId))?.record
39
+ : tx.get("session", input.sessionId);
40
+ if (current == null || current.state !== "open") {
39
41
  throw new AccError(EXIT.CONFLICT, `cannot ${action} from a session that is not open`,
40
42
  { sessionId: input.sessionId });
41
43
  }
42
- if (existing.record.generation !== input.generation) {
44
+ if (current.generation !== input.generation) {
43
45
  throw new AccError(EXIT.CONFLICT, `cannot ${action} with a replaced session generation`,
44
46
  { sessionId: input.sessionId });
45
47
  }
46
- return existing.record;
48
+ return current;
47
49
  }
48
50
 
49
51
  function conflictWith(existing, owner, now, snapshotSessions) {
@@ -83,6 +85,7 @@ export function createClaimService(ports, sessions) {
83
85
 
84
86
  let record = null;
85
87
  await store.transaction(async tx => {
88
+ await requireOwner(input, "claim", tx);
86
89
  const live = tx.list("claim", claim => isLive(claim, now));
87
90
  // This session's own claim on this resource, live or lapsed. Looking only
88
91
  // among the live ones left the expired record behind and made a fresh one
@@ -117,7 +120,7 @@ export function createClaimService(ports, sessions) {
117
120
  actorSessionId: session.sessionId, type: mine === undefined ? "claim.acquired"
118
121
  : "claim.renewed", occurredAt: now,
119
122
  payload: { claimId, resource, mode: record.mode } });
120
- }, { kinds: ["claim"] });
123
+ }, { kinds: ["session", "claim"] });
121
124
  return record;
122
125
  }
123
126
 
@@ -128,6 +131,7 @@ export function createClaimService(ports, sessions) {
128
131
  .toISOString();
129
132
  let record = null;
130
133
  await store.transaction(async tx => {
134
+ await requireOwner(input, "renew", tx);
131
135
  const existing = tx.get("claim", input.claimId);
132
136
  if (existing === null || existing.ownerSessionId !== session.sessionId) {
133
137
  throw new AccError(EXIT.CONFLICT, "only the owning session generation may renew a claim",
@@ -138,7 +142,7 @@ export function createClaimService(ports, sessions) {
138
142
  tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
139
143
  workspaceId: existing.workspaceId, actorSessionId: session.sessionId,
140
144
  type: "claim.renewed", occurredAt: now, payload: { claimId: input.claimId } });
141
- }, { kinds: ["claim"] });
145
+ }, { kinds: ["session", "claim"] });
142
146
  return record;
143
147
  }
144
148
 
@@ -146,6 +150,7 @@ export function createClaimService(ports, sessions) {
146
150
  const session = await requireOwner(input, "release");
147
151
  const now = clock.now();
148
152
  await store.transaction(async tx => {
153
+ await requireOwner(input, "release", tx);
149
154
  const existing = tx.get("claim", input.claimId);
150
155
  if (existing === null) {
151
156
  throw new AccError(EXIT.CONFLICT, "the claim does not exist", { claimId: input.claimId });
@@ -167,7 +172,7 @@ export function createClaimService(ports, sessions) {
167
172
  type: owned ? "claim.released" : "claim.force_released", occurredAt: now,
168
173
  payload: { claimId: input.claimId, authority: authority ?? null,
169
174
  reason: reason ?? null, replacedGeneration: existing.generation } });
170
- }, { kinds: ["claim"] });
175
+ }, { kinds: ["session", "claim"] });
171
176
  }
172
177
 
173
178
  return {
@@ -1,11 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { isDeepStrictEqual } from "node:util";
3
-
4
3
  import { AccError, EXIT, SCHEMA_VERSION, validateRecord }
5
4
  from "@agents-can-communicate/protocol";
6
5
 
7
6
  import { projectReleasedClaim, reconstructFinishRetry } from "./finish-retries.mjs";
8
7
  import { ensureMaterialised } from "./materialisation.mjs";
8
+ import { nextTurnDelivery as selectNextTurnDelivery } from "./next-turn-delivery.mjs";
9
+ import { prepareDecisionChange } from "./decision-changes.mjs";
9
10
 
10
11
  export const receiptId = (messageId, participantId) => `receipt_${createHash("sha256")
11
12
  .update(JSON.stringify([messageId, participantId])).digest("base64url")}`;
@@ -19,6 +20,7 @@ const logicalContent = message => ({
19
20
  inReplyTo: message.inReplyTo,
20
21
  artifacts: message.artifacts,
21
22
  handoff: message.handoff,
23
+ decisionChange: message.decisionChange ?? null,
22
24
  });
23
25
 
24
26
  const normalizedContent = input => logicalContent({
@@ -30,6 +32,7 @@ const normalizedContent = input => logicalContent({
30
32
  inReplyTo: input.inReplyTo ?? null,
31
33
  artifacts: input.artifacts ?? [],
32
34
  handoff: input.handoff ?? null,
35
+ decisionChange: input.decisionChange,
33
36
  });
34
37
 
35
38
  function existingMessage(tx, session, input) {
@@ -55,6 +58,41 @@ function assertCurrentSession(tx, session, action) {
55
58
  }
56
59
  }
57
60
 
61
+ /**
62
+ * Turn what the sender typed into participant ids that exist.
63
+ *
64
+ * An exact participant id is always taken as written. A name that matches no
65
+ * participant is tried as a client name - `codex`, `claude_code` - because that
66
+ * is what an agent knows about its peers: the roster it is handed names the
67
+ * client, and asking it to first look up `codex-fbqX8o` is a lookup it
68
+ * routinely skips. Every shipped example taught this form while nothing
69
+ * implemented it, so an agent following its own skill was refused on its first
70
+ * attempt to reach anyone.
71
+ *
72
+ * One live session of that client resolves. Two refuse and name them, the way
73
+ * `--session` already refuses to choose between two sessions of one client:
74
+ * guessing is how a question is answered by a session that never had the
75
+ * context. The sender's own client is excluded, or the one client an agent is
76
+ * certain is running becomes the easiest thing to address by accident.
77
+ */
78
+ function resolveAddressees(tx, requested, session) {
79
+ const known = new Set(tx.list("participant").map(item => item.participantId));
80
+ return requested.map(name => {
81
+ if (known.has(name)) return name;
82
+ const peers = [...new Set(tx.list("session", item => item.state === "open"
83
+ && item.harness === name && item.participantId !== session.participantId)
84
+ .map(item => item.participantId))].sort();
85
+ if (peers.length === 1) return peers[0];
86
+ if (peers.length > 1) {
87
+ throw new AccError(EXIT.USAGE,
88
+ `could not tell which of ${peers.length} ${name} sessions you meant: `
89
+ + `${peers.join(", ")}. Name one of them, which \`acc status --json\` lists.`,
90
+ { client: name, candidates: peers });
91
+ }
92
+ return name;
93
+ });
94
+ }
95
+
58
96
  function addressedRecipients(tx, message, session) {
59
97
  if (message.toParticipantIds.length === 0) {
60
98
  return [...new Set(tx.list("session", item => item.state === "open"
@@ -86,9 +124,14 @@ function threadFor(tx, messageId, inReplyTo) {
86
124
  return parent.threadId;
87
125
  }
88
126
 
89
- export function recordMessageInTransaction({ tx, session, input, now, messageId, ids,
127
+ export function recordMessageInTransaction({ tx, session, input: raw, now, messageId, ids,
90
128
  action = "send a message" }) {
91
129
  assertCurrentSession(tx, session, action);
130
+ // Resolved before anything else reads the recipients, so the stored message
131
+ // names real participants and a retry compares against the same content. A
132
+ // record that kept the client name would address nobody when it is read back.
133
+ const input = prepareDecisionChange(tx, { ...raw,
134
+ toParticipantIds: resolveAddressees(tx, raw.toParticipantIds ?? [], session) }, session);
92
135
  const existing = existingMessage(tx, session, input);
93
136
  if (existing !== undefined) {
94
137
  return { message: existing, recipientParticipantIds: [], created: false };
@@ -111,6 +154,7 @@ export function recordMessageInTransaction({ tx, session, input, now, messageId,
111
154
  inReplyTo,
112
155
  artifacts: input.artifacts ?? [],
113
156
  handoff: input.handoff ?? null,
157
+ ...(input.decisionChange === undefined ? {} : { decisionChange: input.decisionChange }),
114
158
  sentAt: now,
115
159
  });
116
160
  const recipientParticipantIds = addressedRecipients(tx, message, session);
@@ -248,28 +292,8 @@ export function createConversationService(ports, sessions) {
248
292
  return (await nextTurnDelivery(input)).queuedMessages;
249
293
  }
250
294
 
251
- async function nextTurnDelivery(input = {}) {
252
- if (typeof input.participantId !== "string" || input.participantId === "") {
253
- return { queuedMessages: [], liveOfferedMessageIds: [], roomMessageIds: [] };
254
- }
255
- const snapshot = await store.snapshot(input.workspaceId ?? store.workspaceId,
256
- { kinds: ["message", "receipt"] });
257
- const receiptByMessage = new Map(snapshot.receipts
258
- .filter(item => item.recipientParticipantId === input.participantId)
259
- .map(item => [item.messageId, item]));
260
- const eligible = snapshot.messages.filter(message => receiptByMessage.has(message.messageId)
261
- && message.fromSessionId !== input.exceptSessionId)
262
- .sort((left, right) => left.sentAt.localeCompare(right.sentAt)
263
- || left.messageId.localeCompare(right.messageId));
264
- return {
265
- queuedMessages: eligible.filter(message =>
266
- receiptByMessage.get(message.messageId).state === "queued"),
267
- liveOfferedMessageIds: eligible.filter(message => message.toParticipantIds.length > 0
268
- && receiptByMessage.get(message.messageId).state === "offered")
269
- .map(message => message.messageId),
270
- roomMessageIds: eligible.filter(message => message.toParticipantIds.length === 0)
271
- .map(message => message.messageId),
272
- };
295
+ function nextTurnDelivery(input) {
296
+ return selectNextTurnDelivery(store, input);
273
297
  }
274
298
 
275
299
  return { sendMessage, finishSession, pendingMessages, nextTurnDelivery };