agents-can-communicate 0.4.2 → 0.4.4

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 (67) hide show
  1. package/README.md +12 -4
  2. package/bin/acc-maintenance-worker.mjs +6 -0
  3. package/docs/ARCHITECTURE.md +12 -5
  4. package/docs/CLI.md +13 -6
  5. package/docs/CONCEPTS.md +2 -1
  6. package/docs/CONFIGURATION.md +27 -1
  7. package/docs/GETTING_STARTED.md +10 -4
  8. package/docs/HOW_IT_WORKS.md +2 -1
  9. package/docs/PROTOCOL.md +4 -0
  10. package/docs/RELEASING.md +1 -1
  11. package/docs/SECURITY_MODEL.md +19 -5
  12. package/docs/TROUBLESHOOTING.md +22 -4
  13. package/docs/UPGRADING.md +60 -14
  14. package/docs/WHY_ACC.md +1 -1
  15. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  16. package/node_modules/@agents-can-communicate/adapter-claude-code/src/channel.mjs +2 -4
  17. package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +3 -1
  18. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  19. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +6 -2
  20. package/node_modules/@agents-can-communicate/adapter-codex/src/config-block.mjs +4 -88
  21. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +36 -15
  22. package/node_modules/@agents-can-communicate/adapter-codex/src/live-permissions.mjs +110 -0
  23. package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +120 -0
  24. package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-threads.mjs +49 -0
  25. package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance.mjs +108 -0
  26. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +1 -0
  27. package/node_modules/@agents-can-communicate/adapter-codex/src/permission-ownership.mjs +109 -0
  28. package/node_modules/@agents-can-communicate/adapter-codex/src/toml-scan.mjs +99 -0
  29. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  30. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  31. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  32. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  33. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  34. package/node_modules/@agents-can-communicate/adapter-sdk/src/channel-directory.mjs +8 -0
  35. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -0
  36. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  37. package/node_modules/@agents-can-communicate/cli/src/args.mjs +1 -1
  38. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +5 -4
  39. package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -0
  40. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +3 -0
  41. package/node_modules/@agents-can-communicate/cli/src/main.mjs +8 -4
  42. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +45 -7
  43. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/command.mjs +15 -0
  44. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/diagnostics.mjs +35 -0
  45. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +40 -8
  46. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/legacy-maintenance.mjs +47 -0
  47. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-state.mjs +57 -0
  48. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-worker.mjs +110 -0
  49. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +92 -0
  50. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/management-recovery.mjs +71 -0
  51. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +6 -7
  52. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +4 -2
  53. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +16 -4
  54. package/node_modules/@agents-can-communicate/cli/src/native-delivery-status.mjs +1 -1
  55. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  56. package/node_modules/@agents-can-communicate/core/src/receipts.mjs +1 -0
  57. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  58. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +1 -1
  59. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  60. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  61. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +4 -2
  62. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +5 -2
  63. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +15 -3
  64. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  65. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  66. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  67. package/package.json +2 -1
@@ -0,0 +1,109 @@
1
+ import { createHash } from "node:crypto";
2
+ import { scanConfig, unsafe } from "./toml-scan.mjs";
3
+
4
+ const BEGIN = "# ACC native permissions begin ";
5
+ const END = "# ACC native permissions end ";
6
+ const META = "# ACC native permissions restore ";
7
+ const hash = (body, record) => createHash("sha256")
8
+ .update(JSON.stringify({ body, id: record.id, before: record.before, table: record.table })).digest("hex");
9
+ const marker = (source, prefix) => source.startsWith(prefix) ? source.slice(prefix.length) : null;
10
+
11
+ // Permission selection, proxy and network grants form one ownership unit. If
12
+ // any component changes, none can be removed independently: enabled networking
13
+ // without its proxy has a different security meaning to Codex.
14
+ export function inspectPermissions(source) {
15
+ const blocks = [];
16
+ let open = null, metadata = null;
17
+ for (const entry of scanConfig(source)) {
18
+ const comment = entry.code ? "" : entry.raw.trim();
19
+ const start = marker(comment, BEGIN), end = marker(comment, END);
20
+ if (start !== null) {
21
+ if (open || !/^[a-z]+$/.test(start) || blocks.some(block => block.id === start)) {
22
+ unsafe("duplicate or nested native permission marker");
23
+ }
24
+ open = { id: start, start: entry.start, table: entry.table, contentStart: entry.end };
25
+ } else if (comment.startsWith(META)) {
26
+ if (!open || open.id !== "selection" || metadata !== null || entry.start !== open.contentStart) {
27
+ unsafe("misplaced permission metadata");
28
+ }
29
+ try { metadata = JSON.parse(comment.slice(META.length)); }
30
+ catch { unsafe("invalid permission metadata"); }
31
+ open.contentStart = entry.end;
32
+ } else if (end !== null) {
33
+ if (!open || open.id !== end) unsafe("unmatched native permission marker");
34
+ blocks.push({ ...open, end: entry.end, body: source.slice(open.contentStart, entry.start) });
35
+ open = null;
36
+ }
37
+ }
38
+ if (open) return { source, state: "customized" };
39
+ if (!blocks.length && !metadata) return { source, state: "absent" };
40
+ if (!Array.isArray(metadata) || metadata.length !== blocks.length
41
+ || metadata.some(item => !item || typeof item !== "object" || Array.isArray(item))
42
+ || new Set(metadata.map(item => item.id)).size !== metadata.length) return { source, state: "customized" };
43
+ for (const block of blocks) {
44
+ const record = metadata.find(item => item.id === block.id);
45
+ if (!record || Object.keys(record).sort().join() !== "before,hash,id,table"
46
+ || typeof record.before !== "string" || record.hash !== hash(block.body, record)
47
+ || (block.id === "selection" && block.table.length !== 0)
48
+ || JSON.stringify(block.table) !== JSON.stringify(record.table)) {
49
+ return { source, state: "customized" };
50
+ }
51
+ }
52
+ // A retained reference or declaration must not be left pointing at a deleted
53
+ // profile. Ignore peer-looking text inside strings/comments as markers.
54
+ const entries = scanConfig(source);
55
+ const inside = entry => blocks.some(block => entry.start >= block.start && entry.end <= block.end);
56
+ const generatedTables = entries.filter(entry => entry.header && inside(entry)).map(entry => JSON.stringify(entry.keys));
57
+ const outside = entries.filter(entry => !inside(entry));
58
+ // New profile declarations and selectors are custom policy, regardless of
59
+ // how TOML spells their values (including escaped or multiline references).
60
+ if (outside.some(entry => entry.code && (entry.keys.includes("default_permissions")
61
+ || entry.keys[0] === "permissions"
62
+ || (!entry.header && generatedTables.includes(JSON.stringify(entry.table)))))) {
63
+ return { source, state: "customized" };
64
+ }
65
+ let restored = source;
66
+ for (const block of blocks.toSorted((a, b) => b.start - a.start)) {
67
+ restored = restored.slice(0, block.start) + metadata.find(item => item.id === block.id).before
68
+ + restored.slice(block.end);
69
+ }
70
+ // Inserting restored headers can reparent foreign assignments added after
71
+ // a removal marker. Verify the candidate's scopes before restoring any byte.
72
+ let restoredEntries;
73
+ try { restoredEntries = new Map(scanConfig(restored).map(entry => [entry.start, entry])); }
74
+ catch { return { source, state: "customized" }; }
75
+ for (const entry of outside.filter(item => item.code)) {
76
+ const delta = blocks.filter(block => block.end <= entry.start).reduce((total, block) =>
77
+ total + metadata.find(item => item.id === block.id).before.length - (block.end - block.start), 0);
78
+ const candidate = restoredEntries.get(entry.start + delta);
79
+ if (!candidate || JSON.stringify(candidate.keys) !== JSON.stringify(entry.keys)) {
80
+ return { source, state: "customized" };
81
+ }
82
+ }
83
+ return { source: restored, state: "owned" };
84
+ }
85
+
86
+ export function addPermissions(source, edits) {
87
+ const metadata = edits.map(({ id, start, end, body, table = [] }) => {
88
+ const record = { id, before: source.slice(start, end), table };
89
+ return { ...record, hash: hash(body, record) };
90
+ });
91
+ const initialMetadata = JSON.stringify(metadata);
92
+ // Insertions at the same offset retain caller order (selection always first).
93
+ for (const edit of edits.map((item, index) => ({ ...item, index }))
94
+ .sort((a, b) => b.start - a.start || b.index - a.index)) {
95
+ const block = `${BEGIN}${edit.id}\n`
96
+ + (edit.id === "selection" ? `${META}${JSON.stringify(metadata)}\n` : "")
97
+ + edit.body + `${END}${edit.id}\n`;
98
+ source = source.slice(0, edit.start) + block + source.slice(edit.end);
99
+ }
100
+ // Record the installed scope, which can differ after removing a legacy table.
101
+ for (const entry of scanConfig(source)) {
102
+ const id = !entry.code ? marker(entry.raw.trim(), BEGIN) : null;
103
+ const record = metadata.find(item => item.id === id);
104
+ if (!record) continue;
105
+ record.table = entry.table;
106
+ record.hash = hash(edits.find(item => item.id === id).body, record);
107
+ }
108
+ return source.replace(`${META}${initialMetadata}`, `${META}${JSON.stringify(metadata)}`);
109
+ }
@@ -0,0 +1,99 @@
1
+ import { AccError, EXIT } from "@agents-can-communicate/protocol";
2
+
3
+ export const unsafe = (reason = "unrecognized TOML declaration") => {
4
+ throw new AccError(EXIT.CONFLICT, `cannot safely edit Codex config: ${reason}; `
5
+ + "repair ambiguous TOML or ACC markers before retrying", { reason });
6
+ };
7
+ // A bounded lexical scan, not a TOML value parser. Newlines inside strings,
8
+ // arrays and inline tables cannot introduce a header or a managed marker.
9
+ export function* statements(source) {
10
+ let start = 0, code = "", quote = null;
11
+ const stack = [];
12
+ for (let i = 0; i < source.length;) {
13
+ const char = source[i];
14
+ if (quote) {
15
+ if (quote[0] === '"' && char === "\\") {
16
+ code += source.slice(i, i + 2); i += 2; continue;
17
+ }
18
+ if (source.startsWith(quote, i)) {
19
+ let count = quote.length;
20
+ if (quote.length === 3) {
21
+ while (source[i + count] === quote[0]) count++;
22
+ if (count > 5) unsafe("unsupported string quote delimiter");
23
+ }
24
+ code += source.slice(i, i + count); i += count; quote = null; continue;
25
+ }
26
+ if (quote.length === 1 && /[\r\n]/.test(char)) unsafe("newline in single-line string");
27
+ code += char; i++; continue;
28
+ }
29
+ if (char === "#") {
30
+ while (i < source.length && source[i] !== "\n") i++;
31
+ continue;
32
+ }
33
+ if (char === '"' || char === "'") {
34
+ quote = source.startsWith(char.repeat(3), i) ? char.repeat(3) : char;
35
+ code += quote; i += quote.length; continue;
36
+ }
37
+ if (char === "[" || char === "{") stack.push(char);
38
+ if (char === "]" || char === "}") {
39
+ if (stack.pop() !== (char === "]" ? "[" : "{")) unsafe("mismatched collection delimiter");
40
+ }
41
+ code += char; i++;
42
+ if (char === "\n" && stack.length === 0) {
43
+ yield { raw: source.slice(start, i), code: code.trim(), start, end: i };
44
+ start = i; code = "";
45
+ }
46
+ }
47
+ if (quote || stack.length) unsafe(quote ? "unclosed string" : "unclosed collection");
48
+ if (start < source.length) yield { raw: source.slice(start), code: code.trim(), start, end: source.length };
49
+ }
50
+
51
+ function decodeKey(key) {
52
+ if (key[0] === "'") return key.slice(1, -1);
53
+ if (key[0] !== '"') return key;
54
+ return key.slice(1, -1).replace(/\\(U[\da-fA-F]{8}|u[\da-fA-F]{4}|[btnfr"\\])|\\./g,
55
+ (match, escape) => {
56
+ if (!escape) unsafe("unsupported quoted-key escape");
57
+ if (/^[uU]/.test(escape)) {
58
+ const point = Number.parseInt(escape.slice(1), 16);
59
+ if (point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff)) {
60
+ unsafe("invalid quoted-key Unicode escape");
61
+ }
62
+ return String.fromCodePoint(point);
63
+ }
64
+ return ({ b: "\b", t: "\t", n: "\n", f: "\f", r: "\r", '"': '"', "\\": "\\" })[escape];
65
+ });
66
+ }
67
+
68
+ function keyPath(code) {
69
+ const keys = [];
70
+ let rest = code.trimStart();
71
+ for (;;) {
72
+ const match = /^(?:[A-Za-z0-9_-]+|'[^'\r\n]*'|"(?:[^"\\\r\n]|\\[^\r\n])*")/.exec(rest);
73
+ if (!match) unsafe("unrecognized TOML key");
74
+ keys.push(decodeKey(match[0]));
75
+ rest = rest.slice(match[0].length).trimStart();
76
+ if (!rest.startsWith(".")) return { keys, rest };
77
+ rest = rest.slice(1).trimStart();
78
+ }
79
+ }
80
+
81
+ export function declaration(code) {
82
+ const header = code.startsWith("[");
83
+ const array = header && code.startsWith("[[");
84
+ const { keys, rest } = keyPath(header ? code.slice(array ? 2 : 1) : code);
85
+ if (header ? rest !== (array ? "]]" : "]") : !rest.startsWith("=")) unsafe();
86
+ return { keys, header, array, value: header ? null : rest.slice(1).trim() };
87
+ }
88
+
89
+ // Resolved declaration paths are only structural facts, never parsed user values.
90
+ export function scanConfig(source) {
91
+ let table = [];
92
+ return [...statements(source)].map(item => {
93
+ if (!item.code) return { ...item, table: [...table], keys: [] };
94
+ const entry = declaration(item.code);
95
+ if (entry.header) table = entry.keys;
96
+ return { ...item, ...entry, table: [...table],
97
+ keys: entry.header ? entry.keys : [...table, ...entry.keys] };
98
+ });
99
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Coordinate this Gemini CLI session with other AI agent sessions working in the same workspace.",
5
5
  "contextFileName": "skills/acc/SKILL.md"
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-gemini-cli",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-grok",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-kimi",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-sdk",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,8 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
4
+ // macOS Unix paths are capped at 104 bytes. A stable, short per-user namespace
5
+ // also lets a sandbox grant access regardless of each client's TMPDIR value.
6
+ export const channelSocketDirectory = () => path.join(
7
+ process.platform === "darwin" ? "/tmp" : os.tmpdir(),
8
+ `acc-ch-${typeof process.getuid === "function" ? process.getuid() : "u"}`);
@@ -25,3 +25,4 @@ export { clearSessionBinding, listSessionBindings, loadSessionBinding,
25
25
  from "./session-binding.mjs";
26
26
 
27
27
  export { clearNativeAttempt, loadNativeAttempt, storeNativeAttempt } from "./native-attempt.mjs";
28
+ export { channelSocketDirectory } from "./channel-directory.mjs";
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/cli",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -58,7 +58,7 @@ export const COMMANDS = Object.freeze({
58
58
  uninstall: { required: [], optional: ["home"], repeated: ["adapter"], flags: ["dry-run"] },
59
59
  // Asking npm whether there is a newer ACC. The one command that touches the
60
60
  // network, and never on the hook path.
61
- update: { required: [], optional: ["auto", "pin"], flags: ["check", "apply"] },
61
+ update: { required: [], optional: ["auto", "pin"], flags: ["check", "apply", "yes"] },
62
62
  // The two things a person types first after installing from a registry. The
63
63
  // CLI answered neither: `acc --version` and `acc --help` were both "unknown
64
64
  // command", and `acc` on its own asked for a command without naming one.
@@ -15,6 +15,7 @@ import { ALL_ADAPTERS, clientContext, probeTimeout } from "./install-command.mjs
15
15
  import { describePresence } from "./main.mjs";
16
16
  import { platformPaths } from "./platform-paths.mjs";
17
17
  import { noticeUpdate } from "./update-check.mjs";
18
+ import { managedUpdateDiagnostic } from "./managed-runtime/diagnostics.mjs";
18
19
  import { readControl } from "./managed-runtime/state.mjs";
19
20
  import { diagnoseFilesystemStore, repairFilesystemStore }
20
21
  from "@agents-can-communicate/storage-filesystem";
@@ -207,9 +208,7 @@ export async function runDoctor({ options, context, runtime }) {
207
208
  const update = manager === null
208
209
  ? await noticeUpdate({ dataHome, running, env: runtime?.env ?? {},
209
210
  now: Date.parse(clock.now()), get: runtime?.fetch, io: { readFile, writeFile, mkdir } })
210
- : { checked: false, running, latest: manager.pending?.version ?? null,
211
- newer: manager.pending !== null, auto: manager.auto, pin: manager.pin,
212
- pending: manager.pending?.version ?? null, phase: manager.phase, notice: manager.notice };
211
+ : await managedUpdateDiagnostic(runtime.managerRoot, manager, running);
213
212
 
214
213
  // Before the store is read for anything else. `collectStatus` reads every
215
214
  // record, so on the store this command exists to describe it threw first and
@@ -273,8 +272,10 @@ export async function runDoctor({ options, context, runtime }) {
273
272
  + `${describeNative(adapter.nativeDelivery, { clientVersion: adapter.version })}; `
274
273
  + `fallback: ${describeDeliveryFallback(adapter)}`),
275
274
  ...nativeSessionLines(adapters),
275
+ ...adapters.filter(adapter => adapter.present && adapter.outgoingDelivery)
276
+ .map(adapter => ` ${adapter.displayName} ${adapter.outgoingDelivery.diagnostic}`),
276
277
  ...(manager === null ? [] : [` automatic updates ${manager.auto ? "on" : "off"}; ACC ${manager.active.version}`
277
- + (manager.pin ? `; pinned to ${manager.pin}` : ""), ...(manager.notice ? [` ${manager.notice}`] : [])]),
278
+ + (manager.pin ? `; pinned to ${manager.pin}` : ""), ...(update.notice ? [` ${update.notice}`] : [])]),
278
279
  ...data.remediation.map(line => ` ${line}`),
279
280
  // `0 of 4` is a true line that reads as a broken machine, and on an
280
281
  // MCP-only one it would read that way on every run forever. The server needs
@@ -106,6 +106,8 @@ const NOTES = Object.freeze({
106
106
  "Use acc update --check to only report whether a newer release exists.",
107
107
  "An initial acc install enables automatic updates; reinstalling preserves your choice. Activation waits for active clients and ACC processes.",
108
108
  "--auto on|off changes automatic updates; --pin VERSION holds an exact stable version; --pin none follows stable releases.",
109
+ "An eligible Codex service restart asks once; a detached worker waits for idle work and verifies completion. Open clients disconnect; acc doctor reports progress.",
110
+ "--yes explicitly consents to that restart without a prompt; --check never schedules maintenance.",
109
111
  "--apply remains accepted as a compatibility alias for acc update."],
110
112
  });
111
113
 
@@ -153,6 +153,8 @@ export function describeOutcome({ action, acted, failed = [], skipped = [],
153
153
  ...operations.filter(operation => operation.applied)
154
154
  .flatMap(operation => (operation.needsAction ?? [])
155
155
  .map(step => ` ${operation.adapterId}: ${step}`)),
156
+ ...operations.filter(operation => operation.applied)
157
+ .flatMap(operation => (operation.setupNotes ?? []).map(note => ` ${note}`)),
156
158
  ...skipped.map(entry => ` skip ${entry.adapterId}: ${entry.reason}`),
157
159
  ...failed.map(entry => ` ${entry.adapterId}: ${entry.error}`),
158
160
  // Said once, where it is needed: the reader has just been shown a list of
@@ -212,6 +214,7 @@ function questionFor(entry) {
212
214
  return [
213
215
  `Let ${entry.displayName} answer peer requests while idle? (experimental)`,
214
216
  " Yes: automatic turns can spend tokens without waiting for you.",
217
+ ...(entry.outgoingDelivery?.setup ? [` ${entry.outgoingDelivery.setup}`] : []),
215
218
  ...(warns ? [" Allow development channels when prompted; check the client's startup notice."] : []),
216
219
  ...(entry.nativeDelivery.state !== "eligible"
217
220
  ? [" Save consent now; delivery waits for an available client service."] : []),
@@ -18,6 +18,7 @@ import { runUpdateCommand } from "./update-command.mjs";
18
18
  import { runConfigCommand } from "./config-command.mjs";
19
19
  import { runInstallCommand } from "./install-command.mjs";
20
20
  import { runDoctor } from "./doctor-command.mjs";
21
+ import { runManagementDoctor } from "./managed-runtime/diagnostics.mjs";
21
22
  import { createGitProbe } from "./git-probe.mjs";
22
23
  import { canonicalClaim } from "./claim-spelling.mjs";
23
24
  import { platformDataHome, runtimePaths } from "./runtime-paths.mjs";
@@ -137,7 +138,9 @@ function recordedText(message, delivery) {
137
138
  const diagnostics = delivery.map(item => item.outcome === "offered"
138
139
  ? `live offered to ${item.recipientParticipantId} via ${item.transport}`
139
140
  : item.outcome === "queued"
140
- ? `live offer unavailable (${item.errorCode ?? "durable_fallback"})`
141
+ ? item.errorCode === "transport_permission_denied"
142
+ ? "live offer blocked by sender permissions; message remains queued; run acc doctor in the sending session"
143
+ : `live offer unavailable (${item.errorCode ?? "durable_fallback"})`
141
144
  : `delivery already ${item.outcome}`);
142
145
  return [`recorded ${message.messageId}`, ...diagnostics].join("; ");
143
146
  }
@@ -410,7 +413,8 @@ export async function main(argv, runtime) {
410
413
  let parsed;
411
414
  try {
412
415
  parsed = parseArgs(argv);
413
- if (runtime.managementOnly && !["help", "version", "update"].includes(parsed.command)) {
416
+ const managementDoctor = runtime.managementOnly && parsed.command === "doctor" && !parsed.options.repair;
417
+ if (runtime.managementOnly && !managementDoctor && !["help", "version", "update"].includes(parsed.command)) {
414
418
  throw new AccError(EXIT.DATA, "runtime unavailable; retry after the update or run acc update to recover");
415
419
  }
416
420
  // `config` is the one command that must work on a workspace ACC cannot
@@ -421,7 +425,7 @@ export async function main(argv, runtime) {
421
425
  // discovery cannot open the workspace - that is what a user is trying to
422
426
  // find out - install touches client configuration rather than ACC state,
423
427
  // and `help` has to answer in a directory that is no workspace at all.
424
- const context = NO_WORKSPACE.includes(parsed.command)
428
+ const context = managementDoctor || NO_WORKSPACE.includes(parsed.command)
425
429
  ? null
426
430
  : parsed.command === "doctor"
427
431
  ? await openDiagnosticContext(parsed.options, runtime)
@@ -432,7 +436,7 @@ export async function main(argv, runtime) {
432
436
  const options = context === null ? parsed.options
433
437
  : await resolveOwner({ command: parsed.command, options: parsed.options,
434
438
  context, env: runtime.env });
435
- const { data, text, error: outcome } = await HANDLERS[parsed.command](
439
+ const { data, text, error: outcome } = await (managementDoctor ? runManagementDoctor : HANDLERS[parsed.command])(
436
440
  { options, context, runtime });
437
441
  // A handler may have done real work and still failed: `acc install` writes
438
442
  // for the clients it could and reports the one it could not. The data is
@@ -53,11 +53,49 @@ export async function listNativeHolds(root, { pidIsAlive = defaultPidIsAlive } =
53
53
  return holds;
54
54
  }
55
55
 
56
+ /** Activation calls this under the admission mutex; diagnostics are a snapshot.
57
+ * The updater may ignore its own leases, never a native client's lifetime.
58
+ * Project only process facts: leases and bindings carry private owner tokens.
59
+ */
60
+ export async function listActivationBlockers(root, { pidIsAlive = defaultPidIsAlive, ignorePid = null } = {}) {
61
+ const leases = (await listRuntimeHolds(root, { pidIsAlive })).filter(lease => lease.pid !== ignorePid);
62
+ const native = await listNativeHolds(root, { pidIsAlive });
63
+ const holds = [...leases.map(({ pid, kind }) => ({ pid, kind, nativeBindings: 0 })),
64
+ ...native.map(hold => ({ ...hold, nativeBindings: hold.reason === "unknown_workspace" ? 0 : 1 }))];
65
+ const byPid = new Map(), unidentified = [];
66
+ for (const hold of holds) {
67
+ const pid = hold.pid ?? null;
68
+ const nativeBindings = hold.nativeBindings;
69
+ if (pid === null) {
70
+ unidentified.push({ pid, kinds: [hold.kind], reason: hold.reason, nativeBindings });
71
+ continue;
72
+ }
73
+ const blocker = byPid.get(pid) ?? { pid, kinds: [], reason: "process_running", nativeBindings: 0 };
74
+ if (!blocker.kinds.includes(hold.kind)) blocker.kinds.push(hold.kind);
75
+ blocker.nativeBindings += nativeBindings;
76
+ byPid.set(pid, blocker);
77
+ }
78
+ return [...byPid.values()].sort((a, b) => a.pid - b.pid)
79
+ .map(blocker => ({ ...blocker, kinds: blocker.kinds.sort() })).concat(unidentified);
80
+ }
81
+
82
+ export function activationBlockerNotice(version, blockers) {
83
+ if (!blockers.length) return `ACC ${version} is ready; no active or unidentified processes are blocking activation.`;
84
+ const details = blockers.slice(0, 10).map(blocker => {
85
+ const facts = [blocker.kinds.map(safeText).join(", ")];
86
+ if (blocker.nativeBindings) facts.push(`${blocker.nativeBindings} native binding${blocker.nativeBindings === 1 ? "" : "s"}`);
87
+ if (blocker.pid === null) facts.push(blocker.reason.replaceAll("_", " "));
88
+ return `${blocker.pid === null ? "unidentified process" : `PID ${blocker.pid}`} (${facts.join("; ")})`;
89
+ });
90
+ return `ACC ${version} is ready; waiting for ${blockers.length} active or unidentified process(es): `
91
+ + details.join("; ") + (blockers.length > 10 ? `; and ${blockers.length - 10} more` : "") + ".";
92
+ }
93
+
56
94
  async function prepareCandidate(control, root, env) {
57
95
  const file = path.join(control.pending.root, "node_modules", "@agents-can-communicate", "cli",
58
96
  "src", "managed-runtime", "refresh.mjs");
59
97
  const { prepareRefresh } = await import(pathToFileURL(file).href);
60
- return prepareRefresh({ control, root, env });
98
+ return prepareRefresh({ control, root, env, callerProtocol: 2 });
61
99
  }
62
100
  // JSON parser diagnostics may quote private config bytes. Keep the cause and
63
101
  // known artifact paths, never the offending input or terminal control bytes.
@@ -71,7 +109,7 @@ const recipe = c => JSON.stringify([c.active, c.pending, c.home, c.targets, c.au
71
109
 
72
110
  /** Detection is outside the fence; every integration write and commit stays inside it. */
73
111
  export async function activatePending(root, { prepare = prepareCandidate, env = process.env,
74
- pidIsAlive = defaultPidIsAlive, ignorePid = null } = {}) {
112
+ pidIsAlive = defaultPidIsAlive, ignorePid = null, beforeActivation = null } = {}) {
75
113
  root = await canonicalManagerRoot(root);
76
114
  const before = await readControl(root);
77
115
  if (!before?.pending) return { activated: false, reason: "no_pending_update" };
@@ -79,12 +117,12 @@ export async function activatePending(root, { prepare = prepareCandidate, env =
79
117
  return withManagerLock(root, async () => {
80
118
  const current = await readControl(root);
81
119
  if (!current || recipe(current) !== recipe(before)) return { activated: false, reason: "state_changed" };
82
- const holds = (await listRuntimeHolds(root, { pidIsAlive })).filter(lease => lease.pid !== ignorePid);
83
- holds.push(...await listNativeHolds(root, { pidIsAlive }));
84
- if (holds.length) {
85
- const notice = `ACC ${current.pending.version} is ready; waiting for ${holds.length} active or unidentified process(es).`;
120
+ if (beforeActivation) await beforeActivation(current);
121
+ const blockers = await listActivationBlockers(root, { pidIsAlive, ignorePid });
122
+ if (blockers.length) {
123
+ const notice = activationBlockerNotice(current.pending.version, blockers);
86
124
  await writeControl(root, { ...current, notice });
87
- return { activated: false, reason: "processes_active", holds: holds.length, notice };
125
+ return { activated: false, reason: "processes_active", holds: blockers.length, blockers, notice };
88
126
  }
89
127
  const activating = { ...current, phase: "activating", notice: "Finishing integration refresh." };
90
128
  await writeControl(root, activating);
@@ -4,8 +4,13 @@ import { withManagerLock } from "./mutex.mjs";
4
4
  import { scheduleWorker } from "./schedule.mjs";
5
5
  import { readControl, writeControl } from "./state.mjs";
6
6
  import { performUpdate, runWorker } from "./worker.mjs";
7
+ import { requestMaintenance } from "./maintenance.mjs";
8
+ import { retireLegacyUpdateWorker, stageNewerManagementRuntime } from "./management-recovery.mjs";
7
9
 
8
10
  export function validateUpdateOptions(options) {
11
+ if (options.yes && (options.check || options.auto !== undefined || options.pin !== undefined)) {
12
+ throw new AccError(EXIT.USAGE, "--yes only consents to client maintenance during acc update");
13
+ }
9
14
  if (options.auto !== undefined && !["on", "off"].includes(options.auto)) {
10
15
  throw new AccError(EXIT.USAGE, "--auto expects on or off");
11
16
  }
@@ -40,9 +45,15 @@ export async function runManagedUpdate({ options, runtime }) {
40
45
  return { data: { auto: control.auto, pin: control.pin, running: control.active.version },
41
46
  text: `Automatic updates ${control.auto ? "on" : "off"}. ${control.pin ? `Pinned to ${control.pin}.` : "Following stable releases."}` };
42
47
  }
48
+ if (!options.check) await stageNewerManagementRuntime(root, runtime);
43
49
  const result = options.check
44
50
  ? await performUpdate(root, { check: true, env })
45
51
  : await runWorker(root, { force: true, env, ignorePid: process.pid });
52
+ if (!options.check && (result.reason === "processes_active" || result.reason === "maintenance_pending"
53
+ || result.activated || result.checked && !result.newer)) {
54
+ const maintenance = await requestMaintenance({ root, control: await readControl(root), options, runtime });
55
+ if (maintenance) return maintenance;
56
+ }
46
57
  const text = result.activated ? `Updated ACC to ${result.version}; integrations refreshed.`
47
58
  : result.notice && result.reason === "processes_active" ? result.notice
48
59
  : result.reason === "refresh_failed" ? [result.notice,
@@ -57,6 +68,10 @@ export async function runManagedUpdate({ options, runtime }) {
57
68
  ...(result.reason === "refresh_failed" ? { error: new AccError(EXIT.DATA, text) } : {}) };
58
69
  } catch (error) {
59
70
  if (/manager lock held/.test(error.message)) {
71
+ if (!options.check && options.auto === undefined && options.pin === undefined && !runtime.legacyWorkerRetried
72
+ && await retireLegacyUpdateWorker(root).catch(() => false)) {
73
+ return runManagedUpdate({ options, runtime: { ...runtime, legacyWorkerRetried: true } });
74
+ }
60
75
  return { data: { inProgress: true }, text: "An update is already in progress; ACC will activate it when running clients exit." };
61
76
  }
62
77
  throw error instanceof AccError ? error : new AccError(EXIT.DATA, error.message);
@@ -0,0 +1,35 @@
1
+ import { activationBlockerNotice, listActivationBlockers } from "./activation.mjs";
2
+ import { MAINTENANCE_ACTIVE, maintenanceNotice, maintenanceReport, readMaintenance } from "./maintenance-state.mjs";
3
+ import { readControl } from "./state.mjs";
4
+
5
+ /** Available when workspace admission is fenced, and from a newer management
6
+ * implementation before activation. Never opens a workspace or probes clients.
7
+ */
8
+ export async function runManagementDoctor({ runtime }) {
9
+ const manager = await readControl(runtime.managerRoot);
10
+ if (!manager) throw new Error("managed update state is unavailable; run acc update to recover");
11
+ const update = await managedUpdateDiagnostic(runtime.managerRoot, manager, manager.active.version);
12
+ return { data: { scope: "update", workspaceInspection: "unavailable_during_update", update,
13
+ remediation: ["acc update"] },
14
+ text: ["Update diagnostics only; workspace and integrations were not inspected.",
15
+ update.notice ?? `ACC ${manager.active.version}; update phase ${manager.phase}.`,
16
+ "Run acc update to finish, then acc doctor for the full workspace report."].join("\n") };
17
+ }
18
+
19
+ export async function managedUpdateDiagnostic(root, manager, running) {
20
+ const blockers = manager.pending ? await listActivationBlockers(root, { ignorePid: process.pid }) : null;
21
+ const update = { checked: false, running, latest: manager.pending?.version ?? null,
22
+ newer: manager.pending !== null, auto: manager.auto, pin: manager.pin,
23
+ pending: manager.pending?.version ?? null, phase: manager.phase,
24
+ notice: blockers === null ? manager.notice : manager.phase === "activating" && !blockers.length
25
+ ? "Integration refresh is incomplete; run acc update to recover."
26
+ : activationBlockerNotice(manager.pending.version, blockers),
27
+ ...(blockers === null ? {} : { holds: blockers.length, blockers }) };
28
+ const maintenance = await readMaintenance(root);
29
+ if (maintenance && (maintenance.target.root === (manager.pending ?? manager.active).root
30
+ || MAINTENANCE_ACTIVE.includes(maintenance.status))) {
31
+ update.maintenance = maintenanceReport(maintenance);
32
+ update.notice = maintenanceNotice(maintenance);
33
+ }
34
+ return update;
35
+ }
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
4
  import { scheduleWorker } from "./schedule.mjs";
5
5
  import { acquireRuntime } from "./leases.mjs";
6
- import { canonicalManagerRoot, readControl } from "./state.mjs";
6
+ import { canonicalManagerRoot, readControl, readManagedJson } from "./state.mjs";
7
7
 
8
8
  export const ENTRY_KINDS = Object.freeze([
9
9
  "acc", "acc-hook", "acc-mcp", "acc-bootstrap", "acc-claude-channel",
@@ -53,6 +53,31 @@ function unavailable(kind) {
53
53
  }
54
54
  }
55
55
 
56
+ const stableVersion = value => typeof value === "string" && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value);
57
+ function newerVersion(left, right) {
58
+ if (!stableVersion(left) || !stableVersion(right)) return false;
59
+ const a = left.split(".").map(BigInt), b = right.split(".").map(BigInt);
60
+ for (let index = 0; index < 3; index++) if (a[index] !== b[index]) return a[index] > b[index];
61
+ return false;
62
+ }
63
+
64
+ /** Pending means downloaded and verified. Older packages do not advertise this
65
+ * management-only protocol; never load them speculatively for workspace work.
66
+ */
67
+ async function updateImplementation(packageRoot, control) {
68
+ let selected = null;
69
+ for (const candidate of [{ root: packageRoot }, control.pending].filter(candidate => candidate?.root)) {
70
+ try {
71
+ const manifest = await readManagedJson(path.join(candidate.root, "package.json"));
72
+ if (manifest?.name !== "agents-can-communicate" || manifest.accManagedUpdateProtocol !== 2
73
+ || candidate.version !== undefined && manifest.version !== candidate.version
74
+ || !newerVersion(manifest.version, selected?.version ?? control.active.version)) continue;
75
+ selected = { root: candidate.root, version: manifest.version };
76
+ } catch { /* Unreadable alternatives leave the active implementation available. */ }
77
+ }
78
+ return selected?.root ?? null;
79
+ }
80
+
56
81
  /** The lease lasts until OS process death, including callbacks after main returns. */
57
82
  export async function runEntry({ kind, packageRoot, managerRoot, managedRequired = false }) {
58
83
  if (!ENTRY_KINDS.includes(kind)) throw new Error("unknown ACC entry point");
@@ -66,6 +91,7 @@ export async function runEntry({ kind, packageRoot, managerRoot, managedRequired
66
91
  }
67
92
  let selected = packageRoot;
68
93
  let managed = null;
94
+ let managementOnly = false;
69
95
  let root;
70
96
  try {
71
97
  const requestedDataHome = bootstrapOptions === null ? null : await canonicalManagerRoot(bootstrapOptions.dataHome);
@@ -81,12 +107,18 @@ export async function runEntry({ kind, packageRoot, managerRoot, managedRequired
81
107
  if (control !== null) {
82
108
  selected = control.active.root;
83
109
  managed = root;
84
- const lease = await acquireRuntime(root, { pid: process.pid, kind });
85
- selected = lease.runtime.root;
86
- managed = root;
87
- const quiet = kind === "acc" && ["update", "install", "uninstall", "help", "version",
88
- "--help", "-h", "--version", "-v", "-V"].includes(process.argv[2]);
89
- if (!quiet) await scheduleWorker(root, control);
110
+ const update = kind === "acc" && ["update", "doctor"].includes(process.argv[2])
111
+ ? await updateImplementation(packageRoot, control) : null;
112
+ if (update !== null) {
113
+ selected = update;
114
+ managementOnly = true;
115
+ } else {
116
+ const lease = await acquireRuntime(root, { pid: process.pid, kind });
117
+ selected = lease.runtime.root;
118
+ const quiet = kind === "acc" && ["update", "install", "uninstall", "help", "version",
119
+ "--help", "-h", "--version", "-v", "-V"].includes(process.argv[2]);
120
+ if (!quiet) await scheduleWorker(root, control);
121
+ }
90
122
  } else if (managedRequired) throw new Error("managed runtime is not initialized");
91
123
  } catch {
92
124
  // Help and update recovery must remain reachable when state cannot admit a
@@ -106,5 +138,5 @@ export async function runEntry({ kind, packageRoot, managerRoot, managedRequired
106
138
  return;
107
139
  }
108
140
  const runtime = await import(pathToFileURL(path.join(selected, "bin", "entrypoints", `${kind}.mjs`)).href);
109
- await runtime.main({ managerRoot: managed, packageRoot: selected });
141
+ await runtime.main({ managerRoot: managed, packageRoot: selected, ...(managementOnly ? { managementOnly } : {}) });
110
142
  }