agents-can-communicate 0.1.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 (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +176 -0
  3. package/bin/acc-hook.mjs +53 -0
  4. package/bin/acc-mcp.mjs +46 -0
  5. package/bin/acc.mjs +24 -0
  6. package/docs/CAPABILITIES.md +153 -0
  7. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +13 -0
  8. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.claude-plugin/plugin.json +5 -0
  9. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/hooks/hooks.json +56 -0
  10. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +217 -0
  11. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +64 -0
  12. package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +106 -0
  13. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +278 -0
  14. package/node_modules/@agents-can-communicate/adapter-codex/package.json +13 -0
  15. package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +17 -0
  16. package/node_modules/@agents-can-communicate/adapter-codex/plugin/hooks.json +55 -0
  17. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +217 -0
  18. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +81 -0
  19. package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +133 -0
  20. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +235 -0
  21. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +6 -0
  22. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/hooks/hooks.json +69 -0
  23. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +217 -0
  24. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +13 -0
  25. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +74 -0
  26. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/hooks.mjs +109 -0
  27. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +149 -0
  28. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +13 -0
  29. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/.kimi-plugin/plugin.json +9 -0
  30. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +217 -0
  31. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +73 -0
  32. package/node_modules/@agents-can-communicate/adapter-kimi/src/hooks.mjs +125 -0
  33. package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +216 -0
  34. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +12 -0
  35. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +101 -0
  36. package/node_modules/@agents-can-communicate/adapter-sdk/src/config-merge.mjs +215 -0
  37. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +217 -0
  38. package/node_modules/@agents-can-communicate/adapter-sdk/src/events.mjs +55 -0
  39. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +148 -0
  40. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +16 -0
  41. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +95 -0
  42. package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-block.mjs +71 -0
  43. package/node_modules/@agents-can-communicate/cli/package.json +12 -0
  44. package/node_modules/@agents-can-communicate/cli/src/args.mjs +163 -0
  45. package/node_modules/@agents-can-communicate/cli/src/claim-spelling.mjs +79 -0
  46. package/node_modules/@agents-can-communicate/cli/src/config-command.mjs +149 -0
  47. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +85 -0
  48. package/node_modules/@agents-can-communicate/cli/src/git-probe.mjs +41 -0
  49. package/node_modules/@agents-can-communicate/cli/src/help.mjs +78 -0
  50. package/node_modules/@agents-can-communicate/cli/src/index.mjs +10 -0
  51. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +107 -0
  52. package/node_modules/@agents-can-communicate/cli/src/main.mjs +344 -0
  53. package/node_modules/@agents-can-communicate/cli/src/platform-paths.mjs +109 -0
  54. package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +58 -0
  55. package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +126 -0
  56. package/node_modules/@agents-can-communicate/cli/src/workspace-discovery.mjs +151 -0
  57. package/node_modules/@agents-can-communicate/core/package.json +12 -0
  58. package/node_modules/@agents-can-communicate/core/src/claims.mjs +180 -0
  59. package/node_modules/@agents-can-communicate/core/src/communication.mjs +334 -0
  60. package/node_modules/@agents-can-communicate/core/src/index.mjs +7 -0
  61. package/node_modules/@agents-can-communicate/core/src/intents.mjs +75 -0
  62. package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +86 -0
  63. package/node_modules/@agents-can-communicate/core/src/notify.mjs +95 -0
  64. package/node_modules/@agents-can-communicate/core/src/participants.mjs +48 -0
  65. package/node_modules/@agents-can-communicate/core/src/ports.mjs +56 -0
  66. package/node_modules/@agents-can-communicate/core/src/service.mjs +44 -0
  67. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +190 -0
  68. package/node_modules/@agents-can-communicate/core/src/status.mjs +132 -0
  69. package/node_modules/@agents-can-communicate/core/src/sync.mjs +273 -0
  70. package/node_modules/@agents-can-communicate/core/src/tasks.mjs +238 -0
  71. package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +109 -0
  72. package/node_modules/@agents-can-communicate/hook-runner/package.json +12 -0
  73. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +379 -0
  74. package/node_modules/@agents-can-communicate/installer/package.json +10 -0
  75. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +58 -0
  76. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +79 -0
  77. package/node_modules/@agents-can-communicate/installer/src/index.mjs +6 -0
  78. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +162 -0
  79. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +62 -0
  80. package/node_modules/@agents-can-communicate/mcp-server/package.json +12 -0
  81. package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +58 -0
  82. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +250 -0
  83. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +201 -0
  84. package/node_modules/@agents-can-communicate/protocol/package.json +12 -0
  85. package/node_modules/@agents-can-communicate/protocol/src/config.mjs +151 -0
  86. package/node_modules/@agents-can-communicate/protocol/src/envelopes.mjs +25 -0
  87. package/node_modules/@agents-can-communicate/protocol/src/errors.mjs +30 -0
  88. package/node_modules/@agents-can-communicate/protocol/src/fields.mjs +103 -0
  89. package/node_modules/@agents-can-communicate/protocol/src/ids.mjs +25 -0
  90. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +9 -0
  91. package/node_modules/@agents-can-communicate/protocol/src/resources.mjs +74 -0
  92. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +175 -0
  93. package/node_modules/@agents-can-communicate/protocol/src/states.mjs +55 -0
  94. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +12 -0
  95. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +135 -0
  96. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +67 -0
  97. package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +4 -0
  98. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +87 -0
  99. package/node_modules/@agents-can-communicate/storage-filesystem/src/record-id.mjs +44 -0
  100. package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +114 -0
  101. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +80 -0
  102. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +55 -0
  103. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +292 -0
  104. package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +87 -0
  105. package/package.json +81 -0
@@ -0,0 +1,163 @@
1
+ import { AccError, EXIT } from "@agents-can-communicate/protocol";
2
+
3
+ // Commands the model is offered stay few and high level; attach, heartbeat, and
4
+ // detach exist for adapters and are deliberately not advertised as model tools.
5
+ //
6
+ // `--session` and `--generation` are optional on every agent-facing command:
7
+ // the CLI works out which session is calling it (see session-owner.mjs). They
8
+ // stay accepted because an adapter, a script, or an agent holding a session id
9
+ // from `acc status --json` has a reason to be explicit. They remain required on
10
+ // attach, heartbeat and detach, which are the adapter's own lifecycle calls.
11
+ export const COMMANDS = Object.freeze({
12
+ attach: { required: ["participant"], optional: ["harness", "cadence", "parent", "session"] },
13
+ heartbeat: { required: ["session", "generation"], optional: [] },
14
+ detach: { required: ["session", "generation"], optional: [] },
15
+ sync: { required: [], optional: ["session", "cursor", "limit", "scope"] },
16
+ work: { required: [], optional: ["session", "generation", "summary", "mode",
17
+ "state", "workstream"], repeated: ["hint"], flags: ["clear"] },
18
+ claim: { required: ["resource"],
19
+ optional: ["session", "generation", "mode", "enforcement", "reason", "lease"] },
20
+ release: { required: ["claim"],
21
+ optional: ["session", "generation", "authority", "reason"] },
22
+ message: { required: ["subject", "body"],
23
+ optional: ["session", "generation", "type", "priority", "workstream"],
24
+ repeated: ["to"], flags: ["requires-ack"] },
25
+ // Asking another agent to do something: one call, because a task nobody was
26
+ // told about and a message pointing at no task are each useless.
27
+ request: { required: ["to", "title"],
28
+ optional: ["session", "generation", "detail", "workstream", "priority"],
29
+ repeated: ["depends-on"] },
30
+ // Create a task, or act on one with --task. A workstream is optional: small
31
+ // requests should not have to invent a project first.
32
+ task: { required: [], optional: ["session", "generation", "workstream", "title",
33
+ "detail", "assignee", "state", "task", "reason"],
34
+ repeated: ["depends-on"], flags: ["take", "decline", "force"] },
35
+ // Create one, or take and hand back the coordination of one that exists.
36
+ // Creating a workstream raised `coordinator_missing` on every turn from then
37
+ // on, and nothing could answer it: the two core operations that do had no
38
+ // surface at all.
39
+ workstream: { required: [], optional: ["session", "generation", "title", "objective",
40
+ "workstream"], flags: ["take", "release"] },
41
+ // Messages not tied to a task need a way to be answered too. Without one a
42
+ // `requiresAck` message raised an attention item nothing could ever clear.
43
+ ack: { required: ["message"], optional: ["session", "generation", "state"] },
44
+ // Recording what was settled, so the next session does not reopen it. The
45
+ // protocol has described this object from the start and nothing could make
46
+ // one: no command, no tool.
47
+ decide: { required: ["title", "outcome"],
48
+ optional: ["session", "generation", "authority", "workstream", "supersedes"],
49
+ repeated: ["decided-by"], flags: ["human"] },
50
+ finish: { required: ["goal"], optional: ["session", "generation", "status", "to"],
51
+ repeated: ["completed", "remaining", "blocker"] },
52
+ status: { required: [], optional: ["participant"], flags: ["all"] },
53
+ doctor: { required: [], optional: ["home"], flags: ["repair"] },
54
+ // The one command with a subcommand. Kept as an explicit list rather than a
55
+ // free positional: `acc config delete` should fail at the parser, not deep
56
+ // inside a handler that has already decided what to do.
57
+ config: { required: [], optional: [], flags: ["yes", "force"],
58
+ subcommands: ["init", "validate"] },
59
+ install: { required: [], optional: ["adapter", "home"], flags: ["dry-run", "yes"] },
60
+ uninstall: { required: [], optional: ["adapter", "home"], flags: ["yes"] },
61
+ // The two things a person types first after installing from a registry. The
62
+ // CLI answered neither: `acc --version` and `acc --help` were both "unknown
63
+ // command", and `acc` on its own asked for a command without naming one.
64
+ help: { required: [], optional: [] },
65
+ version: { required: [], optional: [] },
66
+ });
67
+
68
+ // Spelled as the commands they mean, and only in first position. A message body
69
+ // legitimately begins with "--" - exchanging diffs is the point of this tool -
70
+ // so reading them anywhere in the argv would make `acc message --body "--help"`
71
+ // print the help instead of sending it.
72
+ const ALIASES = Object.freeze({ "--help": "help", "-h": "help",
73
+ "--version": "version", "-v": "version", "-V": "version" });
74
+
75
+ const GLOBAL = Object.freeze(["json", "workspace", "cwd"]);
76
+
77
+ function usage(message, details = {}) {
78
+ throw new AccError(EXIT.USAGE, message, details);
79
+ }
80
+
81
+ const camel = name => name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
82
+
83
+ export function parseArgs(argv) {
84
+ if (!Array.isArray(argv) || argv.length === 0) {
85
+ usage("a command is required - `acc help` lists them");
86
+ }
87
+ const [first, ...rest] = argv;
88
+ const command = ALIASES[first] ?? first;
89
+ const spec = COMMANDS[command];
90
+ if (spec === undefined) {
91
+ usage(`unknown command: ${command} - \`acc help\` lists them`, { command });
92
+ }
93
+
94
+ let tokens = rest;
95
+ let subcommand;
96
+ if (spec.subcommands !== undefined) {
97
+ [subcommand, ...tokens] = rest;
98
+ if (subcommand === undefined || !spec.subcommands.includes(subcommand)) {
99
+ usage(`${command} requires one of: ${spec.subcommands.join(", ")}`,
100
+ { command, subcommand: subcommand ?? null });
101
+ }
102
+ }
103
+
104
+ const repeated = new Set(spec.repeated ?? []);
105
+ const flags = new Set([...(spec.flags ?? []), "json"]);
106
+ const known = new Set([...(spec.required ?? []), ...(spec.optional ?? []),
107
+ ...repeated, ...(spec.flags ?? []), ...GLOBAL]);
108
+ const options = {};
109
+
110
+ // Free-text values legitimately start with "--": a message body carrying a
111
+ // diff begins "--- a/file", and agents exchanging evidence is the point of
112
+ // this tool. So a following token is only treated as a missing value when it
113
+ // names a real option of this command, which still catches the actual typo
114
+ // (--subject --body x). --name=value is the unambiguous form for the rest.
115
+ const looksLikeOption = value => typeof value === "string" && value.startsWith("--")
116
+ && known.has(value.slice(2).split("=", 1)[0]);
117
+
118
+ for (let index = 0; index < tokens.length; index += 1) {
119
+ const token = tokens[index];
120
+ if (!token.startsWith("--") || token.length === 2) usage(`unexpected argument: ${token}`);
121
+ const separator = token.indexOf("=");
122
+ const name = separator === -1 ? token.slice(2) : token.slice(2, separator);
123
+ const inline = separator === -1 ? undefined : token.slice(separator + 1);
124
+ if (!known.has(name)) usage(`unknown option for ${command}: --${name}`, { command, name });
125
+ const key = camel(name);
126
+
127
+ if (flags.has(name)) {
128
+ if (inline !== undefined) usage(`option --${name} does not take a value`);
129
+ if (Object.hasOwn(options, key)) usage(`option --${name} may be used only once`);
130
+ options[key] = true;
131
+ continue;
132
+ }
133
+
134
+ let value = inline;
135
+ if (value === undefined) {
136
+ const next = tokens[index + 1];
137
+ if (next === undefined || looksLikeOption(next)) {
138
+ usage(`option --${name} requires a value`);
139
+ }
140
+ value = next;
141
+ index += 1;
142
+ }
143
+ if (value === "") usage(`option --${name} requires a value`);
144
+ if (repeated.has(name)) (options[key] ??= []).push(value);
145
+ else if (Object.hasOwn(options, key)) usage(`option --${name} may be used only once`);
146
+ else options[key] = value;
147
+ }
148
+
149
+ for (const name of spec.required ?? []) {
150
+ if (!Object.hasOwn(options, camel(name))) {
151
+ usage(`${command} requires --${name}`, { command, option: name });
152
+ }
153
+ }
154
+ return { command, options: subcommand === undefined ? options : { ...options, subcommand } };
155
+ }
156
+
157
+ export function positiveNumber(value, name) {
158
+ const parsed = Number(value);
159
+ if (!Number.isFinite(parsed) || parsed <= 0) {
160
+ usage(`--${name} must be a positive number`, { value });
161
+ }
162
+ return parsed;
163
+ }
@@ -0,0 +1,79 @@
1
+ import { realpath, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { AccError, EXIT, normaliseResource } from "@agents-can-communicate/protocol";
5
+
6
+ /**
7
+ * The spelling the filesystem itself uses.
8
+ *
9
+ * `normaliseResource` settles `./`, `//` and `..`, which is all a string can
10
+ * settle. Letter case it cannot: on the filesystem this project is certified on,
11
+ * `src/Physics.mjs` and `src/physics.mjs` are the same file, and a claim on one
12
+ * did not cover a write to the other. Measured - the claim was taken, `acc
13
+ * status` said `protection guarded`, and the write went through.
14
+ *
15
+ * Asking the filesystem answers it on both kinds of machine at once, with no
16
+ * case rule anywhere: macOS `realpath` returns the name as stored, so both
17
+ * spellings arrive at the same resource, and on Linux they are genuinely two
18
+ * files and stay two resources. The guard canonicalises its targets the same
19
+ * way, so the two sides meet.
20
+ *
21
+ * The path usually does not exist yet - claiming before creating is the point -
22
+ * so the deepest existing ancestor is resolved and the rest appended.
23
+ */
24
+ async function onDisk(absolute) {
25
+ let current = absolute;
26
+ const trailing = [];
27
+ for (;;) {
28
+ try {
29
+ return path.join(await realpath(current), ...trailing);
30
+ } catch (error) {
31
+ if (error.code !== "ENOENT" && error.code !== "ENOTDIR") return absolute;
32
+ const parent = path.dirname(current);
33
+ if (parent === current) return absolute;
34
+ trailing.unshift(path.basename(current));
35
+ current = parent;
36
+ }
37
+ }
38
+ }
39
+
40
+ const GLOB = "/**";
41
+
42
+ /**
43
+ * A `file:` claim, spelled the way the project spells it. Anything the
44
+ * filesystem cannot answer for - another scheme, a path outside the workspace -
45
+ * comes back as it went in rather than being invented into something else.
46
+ */
47
+ export async function canonicalClaim(resource, descriptor) {
48
+ const normalised = normaliseResource(resource);
49
+ if (typeof normalised !== "string" || !normalised.startsWith("file:")) return normalised;
50
+ const root = descriptor?.source === "git" && descriptor.git !== undefined
51
+ ? descriptor.git.worktreeRoot
52
+ : descriptor?.roots?.[0];
53
+ if (typeof root !== "string") return normalised;
54
+
55
+ const rest = normalised.slice("file:".length);
56
+ const glob = rest.endsWith(GLOB);
57
+ const body = glob ? rest.slice(0, -GLOB.length) : rest;
58
+ if (body === "") return normalised;
59
+
60
+ const resolved = await onDisk(path.resolve(root, body));
61
+ // A directory claimed without the glob covers exactly itself, which is to say
62
+ // nothing anyone writes. Saying so beats storing a claim that reads like
63
+ // protection and is not.
64
+ //
65
+ // The protocol refuses the shapes a string alone can condemn - a trailing
66
+ // slash, a `*` that is not the understood glob - so that a surface with no
67
+ // filesystem still refuses them. This is the half only the filesystem knows:
68
+ // whether `file:src` happens to be a directory.
69
+ if (!glob && await stat(resolved).then(entry => entry.isDirectory(), () => false)) {
70
+ throw new AccError(EXIT.USAGE,
71
+ `${normalised} is a directory; claim ${normalised}/** to cover what is in it`,
72
+ { resource });
73
+ }
74
+ const relative = path.relative(await onDisk(root), resolved);
75
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
76
+ return normalised;
77
+ }
78
+ return `file:${relative.split(path.sep).join("/")}${glob ? GLOB : ""}`;
79
+ }
@@ -0,0 +1,149 @@
1
+ import { constants } from "node:fs";
2
+ import { open, readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { AccError, CONFIG_FILENAME, EXIT, createId, defaultProjectConfig,
6
+ validateProjectConfig } from "@agents-can-communicate/protocol";
7
+
8
+ const configPathIn = cwd => path.join(cwd, CONFIG_FILENAME);
9
+
10
+ /**
11
+ * The file `init` would write, rendered once and used for both the preview and
12
+ * the write. Rendering twice invites a preview that differs from what lands,
13
+ * which is worse than showing nothing.
14
+ */
15
+ function renderConfig({ displayName, ids }) {
16
+ return `${JSON.stringify({
17
+ schemaVersion: 1,
18
+ // Generated rather than derived from the path: the whole point of writing
19
+ // this file is an identity that survives the directory being moved,
20
+ // renamed, or cloned somewhere else.
21
+ workspaceId: ids.next("workspace"),
22
+ displayName,
23
+ roots: ["."],
24
+ policy: { claimMode: "advisory", contextBudgetBytes: 6000 },
25
+ requiredAdapters: [],
26
+ }, null, 2)}\n`;
27
+ }
28
+
29
+ async function readConfig(cwd) {
30
+ const file = configPathIn(cwd);
31
+ let handle;
32
+ try {
33
+ // Same rule as discovery: a config reached through a symlink may point
34
+ // anywhere, so it is refused rather than followed.
35
+ handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
36
+ } catch (error) {
37
+ if (error.code === "ENOENT") return null;
38
+ throw new AccError(EXIT.DATA, "cannot safely read the workspace config",
39
+ { file, cause: error.message });
40
+ }
41
+ try {
42
+ return JSON.parse(await handle.readFile("utf8"));
43
+ } catch (error) {
44
+ throw new AccError(EXIT.DATA, "the workspace config is not valid JSON",
45
+ { file, cause: error.message });
46
+ } finally {
47
+ await handle.close();
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Sessions that would stop seeing each other the moment this file lands.
53
+ *
54
+ * The config carries its own `workspaceId`, so writing one moves the project to
55
+ * a new workspace. Sessions attached to the old one keep heartbeating it and
56
+ * vanish from everyone else's roster - measured: `1 live` before, `0 live`
57
+ * after. They do not recover on their own either: a session re-attaches at
58
+ * SessionStart and at nothing else, so the client has to be restarted.
59
+ *
60
+ * A coordination tool that silently stops coordinating is the worst way for
61
+ * this to be found, so it is refused unless the caller says to go ahead.
62
+ */
63
+ async function attachedSessions(probeWorkspace) {
64
+ if (typeof probeWorkspace !== "function") return [];
65
+ try {
66
+ const context = await probeWorkspace();
67
+ const status = await context.service.collectStatus({});
68
+ return status.participants.filter(item => item.presence !== "offline");
69
+ } catch {
70
+ // Nothing to warn about if the workspace cannot be opened at all - and this
71
+ // command must still run on a workspace ACC cannot read, which is half of
72
+ // what it is for.
73
+ return [];
74
+ }
75
+ }
76
+
77
+ async function initConfig({ cwd, interactive, yes, confirm, ids, displayName,
78
+ force = false, probeWorkspace }) {
79
+ const file = configPathIn(cwd);
80
+ if (await readConfig(cwd) !== null) {
81
+ // A committed identity is shared by everyone on the project. Replacing it
82
+ // on a mistyped command would split one workspace into two.
83
+ throw new AccError(EXIT.CONFLICT, `${CONFIG_FILENAME} already exists`, { file });
84
+ }
85
+
86
+ const attached = await attachedSessions(probeWorkspace);
87
+ if (attached.length > 0 && !force) {
88
+ const who = attached.map(item => `${item.participantId} (${item.harness})`).join(", ");
89
+ throw new AccError(EXIT.CONFLICT,
90
+ `${attached.length} session(s) are attached here and would stop seeing each `
91
+ + `other: ${who}. They re-attach only when their client starts, so close them `
92
+ + "first, or pass --force to write anyway.",
93
+ { file, sessions: attached.map(item => item.sessionId) });
94
+ }
95
+
96
+ const preview = renderConfig({ ids, displayName: displayName ?? path.basename(cwd) });
97
+
98
+ if (!yes) {
99
+ if (!interactive) {
100
+ throw new AccError(EXIT.USAGE,
101
+ "refusing to write without confirmation; pass --yes in a non-interactive run",
102
+ { file });
103
+ }
104
+ const approved = await confirm(`write ${file}?\n\n${preview}`);
105
+ if (!approved) return { subcommand: "init", written: false, file, preview };
106
+ }
107
+
108
+ // Written with O_EXCL: between the existence check and here, another process
109
+ // may have created the file, and clobbering it would be the same mistake the
110
+ // check exists to prevent.
111
+ const handle = await open(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL);
112
+ try {
113
+ await handle.writeFile(preview, "utf8");
114
+ } finally {
115
+ await handle.close();
116
+ }
117
+ return { subcommand: "init", written: true, file, preview };
118
+ }
119
+
120
+ async function validateConfig({ cwd }) {
121
+ const file = configPathIn(cwd);
122
+ const raw = await readConfig(cwd);
123
+ if (raw === null) {
124
+ // Not an error. Config is optional, and the policy that applies without one
125
+ // is worth reporting rather than leaving the reader to guess.
126
+ return { subcommand: "validate", valid: true, present: false, file,
127
+ config: defaultProjectConfig() };
128
+ }
129
+ return { subcommand: "validate", valid: true, present: true, file,
130
+ config: validateProjectConfig(raw, { source: file }) };
131
+ }
132
+
133
+ /**
134
+ * `acc config init` and `acc config validate`.
135
+ *
136
+ * `init` never writes without agreement: it asks in an interactive run and
137
+ * demands `--yes` otherwise. `validate` only reads - a command a user runs to
138
+ * find out what is wrong must not change the thing it is inspecting.
139
+ */
140
+ export async function runConfigCommand({ subcommand, cwd, interactive = true, yes = false,
141
+ confirm, displayName, force = false, probeWorkspace,
142
+ ids = { next: kind => createId(kind) } }) {
143
+ if (subcommand === "init") {
144
+ return initConfig({ cwd, interactive, yes, confirm, ids, displayName,
145
+ force, probeWorkspace });
146
+ }
147
+ if (subcommand === "validate") return validateConfig({ cwd });
148
+ throw new AccError(EXIT.USAGE, `unknown config subcommand: ${subcommand}`, { subcommand });
149
+ }
@@ -0,0 +1,85 @@
1
+ import { homedir } from "node:os";
2
+
3
+ import { detectInstallation, verifyOwned } from "@agents-can-communicate/installer";
4
+ import { AccError, EXIT } from "@agents-can-communicate/protocol";
5
+
6
+ import { ALL_ADAPTERS, clientContext } from "./install-command.mjs";
7
+ import { platformPaths } from "./platform-paths.mjs";
8
+ import { diagnoseFilesystemStore, repairFilesystemStore }
9
+ from "@agents-can-communicate/storage-filesystem";
10
+
11
+ /**
12
+ * Doctor composes the store's own report with core health rules. Repair fails
13
+ * closed: anything blocked or corrupt stops the run rather than being repaired
14
+ * on top of state the tool cannot even read.
15
+ */
16
+ async function diagnoseAdapters({ options, runtime }) {
17
+ // The same home `acc install --home` writes to, or the real one. Reading a
18
+ // different home than install wrote to reports every adapter as missing.
19
+ const home = options?.home ?? runtime?.env?.HOME ?? homedir();
20
+ const clients = clientContext(home);
21
+ const { data: dataHome } = platformPaths({ platform: runtime?.platform,
22
+ env: runtime?.env ?? {} });
23
+ const adapters = ALL_ADAPTERS();
24
+ const detected = await detectInstallation({ adapters, context: clients });
25
+
26
+ return Promise.all(detected.map(async entry => {
27
+ // Compared against what ACC recorded writing, so a plugin someone has since
28
+ // edited reads as theirs rather than as a healthy ACC install.
29
+ const owned = await verifyOwned({ dataHome, adapterId: entry.adapterId });
30
+ const remediation = [];
31
+ if (entry.present && !entry.installed) {
32
+ remediation.push(`acc install --adapter ${entry.adapterId}`);
33
+ }
34
+ if (owned.modified.length > 0) {
35
+ remediation.push(`acc install --adapter ${entry.adapterId} # files were edited`);
36
+ }
37
+ if (owned.missing.length > 0) {
38
+ remediation.push(`acc install --adapter ${entry.adapterId} # files are missing`);
39
+ }
40
+ return { ...entry, owned: { modified: owned.modified, missing: owned.missing,
41
+ intact: owned.intact.length }, remediation };
42
+ }));
43
+ }
44
+
45
+ export async function runDoctor({ options, context, runtime }) {
46
+ const root = context.paths.root;
47
+ const report = options.repair === true
48
+ ? await repairFilesystemStore({ root, clock: context.service.clock })
49
+ : await diagnoseFilesystemStore({ root });
50
+ const adapters = await diagnoseAdapters({ options, runtime });
51
+
52
+ // Before the store is read for anything else. `collectStatus` reads every
53
+ // record, so on the store this command exists to describe it threw first and
54
+ // took the diagnosis with it: one truncated file and `acc doctor` answered
55
+ // "invalid JSON record", naming nothing, while `inspect` had already found
56
+ // the file and put it in a list nobody ever saw.
57
+ if (!report.healthy) {
58
+ const broken = [...report.blocked, ...report.corrupt];
59
+ throw new AccError(EXIT.DATA,
60
+ `store state is ambiguous; repair is blocked. ${broken.length} unreadable:\n `
61
+ + `${broken.slice(0, 10).join("\n ")}`
62
+ + (broken.length > 10 ? `\n and ${broken.length - 10} more` : ""),
63
+ { workspaceId: context.descriptor.id, source: context.descriptor.source,
64
+ runtimeRoot: root, store: report, adapters,
65
+ remediation: ["inspect blocked and corrupt paths before repairing"] });
66
+ }
67
+
68
+ const status = await context.service.collectStatus({});
69
+
70
+ const data = {
71
+ workspaceId: context.descriptor.id,
72
+ source: context.descriptor.source,
73
+ runtimeRoot: root,
74
+ materialised: status.materialised,
75
+ protection: status.protection,
76
+ store: report,
77
+ // Capabilities are reported from what is actually installed, never assumed.
78
+ adapters,
79
+ remediation: adapters.flatMap(adapter => adapter.remediation),
80
+ };
81
+ const installed = adapters.filter(adapter => adapter.installed).length;
82
+ const text = `store healthy; ${status.counts.live} live session(s); `
83
+ + `protection ${status.protection}; ${installed} of ${adapters.length} adapter(s) installed`;
84
+ return { data, text };
85
+ }
@@ -0,0 +1,41 @@
1
+ import { execFile } from "node:child_process";
2
+ import path from "node:path";
3
+ import { promisify } from "node:util";
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ /**
8
+ * Git exports GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE and friends into the
9
+ * environment of everything it runs, hooks included. Inheriting them makes a
10
+ * probe describe whichever repository invoked us rather than the workspace at
11
+ * `cwd` - which, during the reconciliation, is exactly how a test suite running
12
+ * under a pre-push hook mutated the repository it was meant to be testing.
13
+ * ACC adapters are hook-driven by design, so the default probe strips them.
14
+ */
15
+ export function hermeticEnv(env = process.env) {
16
+ return Object.fromEntries(Object.entries(env).filter(([key]) => !key.startsWith("GIT_")));
17
+ }
18
+
19
+ export function createGitProbe({ run } = {}) {
20
+ const exec = run ?? (async (cwd, args) =>
21
+ (await execFileAsync("git", args, { cwd, env: hermeticEnv() })).stdout.trim());
22
+
23
+ return async function gitProbe({ cwd }) {
24
+ const commonDir = await exec(cwd, ["rev-parse", "--path-format=absolute",
25
+ "--git-common-dir"]);
26
+ if (typeof commonDir !== "string" || commonDir.length === 0) return null;
27
+ const worktreeRoot = await exec(cwd, ["rev-parse", "--show-toplevel"]);
28
+ // A detached head or an unborn branch is normal, not an error: enrichment
29
+ // reports what exists and leaves the rest null.
30
+ const branch = await exec(cwd, ["branch", "--show-current"]).catch(() => "");
31
+ const head = await exec(cwd, ["rev-parse", "HEAD"]).catch(() => "");
32
+ const remote = await exec(cwd, ["remote", "get-url", "origin"]).catch(() => "");
33
+ return {
34
+ commonDir: path.resolve(cwd, commonDir),
35
+ worktreeRoot: path.resolve(cwd, worktreeRoot),
36
+ branch: branch.length > 0 ? branch : null,
37
+ head: head.length > 0 ? head : null,
38
+ remote: remote.length > 0 ? remote : null,
39
+ };
40
+ };
41
+ }
@@ -0,0 +1,78 @@
1
+ import { COMMANDS } from "./args.mjs";
2
+
3
+ /**
4
+ * What `acc` says to a person who has just installed it.
5
+ *
6
+ * The list of commands is read from the command table, so a command that exists
7
+ * is always listed. Only the heading a command sits under and its one line are
8
+ * written here, and the tests prove both cover every command in the table.
9
+ *
10
+ * The required options are deliberately not repeated: the parser already
11
+ * answers `acc claim` with "claim requires --resource", which is the same
12
+ * answer at the moment it is actually needed.
13
+ */
14
+ const GROUPS = Object.freeze([
15
+ ["Set up", ["install", "uninstall", "doctor", "config"]],
16
+ ["In a session", ["status", "sync", "work", "claim", "release", "ack", "message",
17
+ "request", "task", "workstream", "decide", "finish"]],
18
+ ["Driven by adapters, not by people", ["attach", "heartbeat", "detach"]],
19
+ ["About acc", ["help", "version"]],
20
+ ]);
21
+
22
+ const SUMMARY = Object.freeze({
23
+ install: "install adapters for the clients on this machine",
24
+ uninstall: "remove what acc wrote, keep what you edited",
25
+ doctor: "clients, versions, install health, and what to run next",
26
+ config: "write or check acc.workspace.json (init | validate)",
27
+ status: "who else is here, what they hold, how protected this workspace is",
28
+ sync: "what has happened since a cursor; silent while you are alone",
29
+ work: "publish what this session is doing, or --clear when it has stopped",
30
+ claim: "reserve a resource; exit 5 when someone else already holds it",
31
+ release: "give a claim back",
32
+ ack: "answer a message that asked for one, so it stops asking",
33
+ message: "send a typed message to named participants",
34
+ request: "ask another agent to do something: the work and the why, in one call",
35
+ task: "create work, --take it, or move its --state along",
36
+ workstream: "group related work, and steer it with --take / --release",
37
+ decide: "record what was settled, so the next session does not reopen it",
38
+ finish: "write the handoff and release what this session held",
39
+ attach: "open a session; an adapter calls this, not a person",
40
+ heartbeat: "say the session is still alive",
41
+ detach: "close a session",
42
+ help: "this list",
43
+ version: "print the version that is installed",
44
+ });
45
+
46
+ /** The same list `acc help --json` returns, so a tool can read it too. */
47
+ export function describeCommands() {
48
+ return GROUPS.map(([heading, names]) => ({
49
+ heading,
50
+ commands: names.map(name => ({
51
+ name,
52
+ summary: SUMMARY[name],
53
+ required: COMMANDS[name].required ?? [],
54
+ subcommands: COMMANDS[name].subcommands ?? [],
55
+ })),
56
+ }));
57
+ }
58
+
59
+ const DOCS = "https://github.com/automatis-tools/agents-can-communicate"
60
+ + "/blob/main/docs/CLI.md";
61
+
62
+ export function helpText() {
63
+ const width = Math.max(...Object.keys(COMMANDS).map(name => name.length)) + 4;
64
+ const lines = ["acc - several agents in one workspace, none of them in charge", ""];
65
+ for (const { heading, commands } of describeCommands()) {
66
+ lines.push(heading);
67
+ for (const { name, summary } of commands) {
68
+ lines.push(` ${`acc ${name}`.padEnd(width)} ${summary}`);
69
+ }
70
+ lines.push("");
71
+ }
72
+ // A URL rather than a path: only `docs/CAPABILITIES.md` is packed, so a
73
+ // reference to `docs/CLI.md` would name a file the installed package does not
74
+ // have. The test alongside holds this to the repository the manifest names.
75
+ lines.push("Every command takes --json for machine output and --cwd to choose the workspace.",
76
+ `Full reference: ${DOCS}`);
77
+ return lines.join("\n");
78
+ }
@@ -0,0 +1,10 @@
1
+ // Composition root: discovery, runtime locations, and the CLI surface.
2
+ export { main } from "./main.mjs";
3
+ export { COMMANDS, parseArgs } from "./args.mjs";
4
+ // Exported so a test can hold every adapter to where it plans to write and
5
+ // which binary decides it runs at all.
6
+ export { ALL_ADAPTERS, clientContext } from "./install-command.mjs";
7
+ export { discoverWorkspace } from "./workspace-discovery.mjs";
8
+ export { createGitProbe, hermeticEnv } from "./git-probe.mjs";
9
+ export { platformDataHome, runtimePaths } from "./runtime-paths.mjs";
10
+ export { platformPaths } from "./platform-paths.mjs";