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.
- package/LICENSE +21 -0
- package/README.md +176 -0
- package/bin/acc-hook.mjs +53 -0
- package/bin/acc-mcp.mjs +46 -0
- package/bin/acc.mjs +24 -0
- package/docs/CAPABILITIES.md +153 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.claude-plugin/plugin.json +5 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/hooks/hooks.json +56 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +64 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +106 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +278 -0
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +17 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/hooks.json +55 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +81 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +133 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +235 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +6 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/hooks/hooks.json +69 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +74 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/hooks.mjs +109 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +149 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/.kimi-plugin/plugin.json +9 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +73 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/hooks.mjs +125 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +216 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +101 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/config-merge.mjs +215 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +217 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/events.mjs +55 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +148 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +16 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +95 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-block.mjs +71 -0
- package/node_modules/@agents-can-communicate/cli/package.json +12 -0
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +163 -0
- package/node_modules/@agents-can-communicate/cli/src/claim-spelling.mjs +79 -0
- package/node_modules/@agents-can-communicate/cli/src/config-command.mjs +149 -0
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +85 -0
- package/node_modules/@agents-can-communicate/cli/src/git-probe.mjs +41 -0
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +78 -0
- package/node_modules/@agents-can-communicate/cli/src/index.mjs +10 -0
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +107 -0
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +344 -0
- package/node_modules/@agents-can-communicate/cli/src/platform-paths.mjs +109 -0
- package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +58 -0
- package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +126 -0
- package/node_modules/@agents-can-communicate/cli/src/workspace-discovery.mjs +151 -0
- package/node_modules/@agents-can-communicate/core/package.json +12 -0
- package/node_modules/@agents-can-communicate/core/src/claims.mjs +180 -0
- package/node_modules/@agents-can-communicate/core/src/communication.mjs +334 -0
- package/node_modules/@agents-can-communicate/core/src/index.mjs +7 -0
- package/node_modules/@agents-can-communicate/core/src/intents.mjs +75 -0
- package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +86 -0
- package/node_modules/@agents-can-communicate/core/src/notify.mjs +95 -0
- package/node_modules/@agents-can-communicate/core/src/participants.mjs +48 -0
- package/node_modules/@agents-can-communicate/core/src/ports.mjs +56 -0
- package/node_modules/@agents-can-communicate/core/src/service.mjs +44 -0
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +190 -0
- package/node_modules/@agents-can-communicate/core/src/status.mjs +132 -0
- package/node_modules/@agents-can-communicate/core/src/sync.mjs +273 -0
- package/node_modules/@agents-can-communicate/core/src/tasks.mjs +238 -0
- package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +109 -0
- package/node_modules/@agents-can-communicate/hook-runner/package.json +12 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +379 -0
- package/node_modules/@agents-can-communicate/installer/package.json +10 -0
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +58 -0
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +79 -0
- package/node_modules/@agents-can-communicate/installer/src/index.mjs +6 -0
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +162 -0
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +62 -0
- package/node_modules/@agents-can-communicate/mcp-server/package.json +12 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +58 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +250 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +201 -0
- package/node_modules/@agents-can-communicate/protocol/package.json +12 -0
- package/node_modules/@agents-can-communicate/protocol/src/config.mjs +151 -0
- package/node_modules/@agents-can-communicate/protocol/src/envelopes.mjs +25 -0
- package/node_modules/@agents-can-communicate/protocol/src/errors.mjs +30 -0
- package/node_modules/@agents-can-communicate/protocol/src/fields.mjs +103 -0
- package/node_modules/@agents-can-communicate/protocol/src/ids.mjs +25 -0
- package/node_modules/@agents-can-communicate/protocol/src/index.mjs +9 -0
- package/node_modules/@agents-can-communicate/protocol/src/resources.mjs +74 -0
- package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +175 -0
- package/node_modules/@agents-can-communicate/protocol/src/states.mjs +55 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +12 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +135 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +67 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +4 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +87 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/record-id.mjs +44 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +114 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +80 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +55 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +292 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +87 -0
- 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";
|