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,107 @@
1
+ import { homedir } from "node:os";
2
+ import path from "node:path";
3
+
4
+ import { createClaudeCodeAdapter } from "@agents-can-communicate/adapter-claude-code";
5
+ import { createCodexAdapter } from "@agents-can-communicate/adapter-codex";
6
+ import { createGeminiCliAdapter } from "@agents-can-communicate/adapter-gemini-cli";
7
+ import { createKimiAdapter } from "@agents-can-communicate/adapter-kimi";
8
+ import { applyPlan, detectInstallation, planInstallation }
9
+ from "@agents-can-communicate/installer";
10
+ import { AccError, EXIT } from "@agents-can-communicate/protocol";
11
+
12
+ import { platformPaths } from "./platform-paths.mjs";
13
+
14
+ // Where each client keeps its own configuration. All of it derives from one
15
+ // home, so a test - or an operator with a second account - can point the whole
16
+ // installation somewhere else in one move.
17
+ // Each client keeps its own directory under the user's home, and an adapter
18
+ // pointed at the home itself writes beside them rather than inside them. That
19
+ // install reports success and the client never reads a byte of it.
20
+ export const clientContext = home => ({
21
+ home,
22
+ configDir: path.join(home, ".claude"),
23
+ agentsHome: home,
24
+ codexHome: path.join(home, ".codex"),
25
+ kimiHome: path.join(home, ".kimi-code"),
26
+ });
27
+
28
+ export const ALL_ADAPTERS = () => [createClaudeCodeAdapter(), createCodexAdapter(),
29
+ createGeminiCliAdapter(), createKimiAdapter()];
30
+
31
+ function selectAdapters(requested) {
32
+ const all = ALL_ADAPTERS();
33
+ if (requested === undefined) return all;
34
+ const wanted = Array.isArray(requested) ? requested : [requested];
35
+ const known = new Map(all.map(adapter => [adapter.id, adapter]));
36
+ return wanted.map(id => {
37
+ const adapter = known.get(id);
38
+ if (adapter === undefined) {
39
+ throw new AccError(EXIT.USAGE, `unknown adapter: ${id}`,
40
+ { adapter: id, known: [...known.keys()] });
41
+ }
42
+ return adapter;
43
+ });
44
+ }
45
+
46
+ /**
47
+ * `acc install`, `acc install --dry-run`, and `acc uninstall`.
48
+ *
49
+ * Detection, planning and application are three separate steps on purpose:
50
+ * detection only reads, the plan is deterministic JSON that says exactly what
51
+ * would change, and application is the only step that writes. `--dry-run` shows
52
+ * the plan and stops - so what an operator approves is the same object that is
53
+ * then carried out, not a description of it produced somewhere else.
54
+ */
55
+ /**
56
+ * What the command says it did.
57
+ *
58
+ * `installed 0 adapter(s)` was the whole report whether four clients were
59
+ * absent, one refused, or nothing was asked for. The reasons existed - the plan
60
+ * carries one per skipped adapter and the result one per failure - and only
61
+ * `--json` ever showed them.
62
+ */
63
+ export function describeOutcome({ action, acted, failed = [], skipped = [] }) {
64
+ return [`${action}ed ${acted} adapter(s)`
65
+ + (failed.length > 0 ? `; ${failed.length} failed` : ""),
66
+ ...skipped.map(entry => ` skip ${entry.adapterId}: ${entry.reason}`),
67
+ ...failed.map(entry => ` ${entry.adapterId}: ${entry.error}`)].join("\n");
68
+ }
69
+
70
+ /**
71
+ * A failed adapter ends the command.
72
+ *
73
+ * It used to be counted in a line that began with a success and exit 0, which is
74
+ * what a malformed `~/.claude/settings.json` produced: the adapter refused,
75
+ * correctly, and the script that ran the installer was told it had worked.
76
+ */
77
+ export function failureOf({ action, acted, failed = [] }) {
78
+ if (failed.length === 0) return null;
79
+ return new AccError(EXIT.DATA,
80
+ describeOutcome({ action, acted, failed }), { failed });
81
+ }
82
+
83
+ export async function runInstallCommand({ options, runtime, action = "install" }) {
84
+ const adapters = selectAdapters(options.adapter);
85
+ const home = options.home ?? runtime.env?.HOME ?? homedir();
86
+ const context = clientContext(home);
87
+ const { data: dataHome } = platformPaths({ platform: runtime.platform,
88
+ env: runtime.env ?? {} });
89
+
90
+ const detected = await detectInstallation({ adapters, context });
91
+ const plan = planInstallation({ adapters, detected, context, action });
92
+
93
+ const dryRun = options.dryRun === true;
94
+ const result = await applyPlan({ plan, adapters, context, dataHome, dryRun });
95
+
96
+ const acted = result.operations.filter(operation => operation.applied).length;
97
+ if (dryRun) {
98
+ return { data: { ...result, plan, dataHome },
99
+ text: [`would ${action}:`, ...plan.operations.flatMap(operation => operation.summary),
100
+ ...plan.skipped.map(entry => `skip ${entry.adapterId}: ${entry.reason}`)].join("\n") };
101
+ }
102
+
103
+ return { data: { ...result, plan, dataHome },
104
+ text: describeOutcome({ action, acted, failed: result.failed,
105
+ skipped: plan.skipped }),
106
+ error: failureOf({ action, acted, failed: result.failed }) };
107
+ }
@@ -0,0 +1,344 @@
1
+ import { AccError, CONFIG_FILENAME, EXIT, failure, ok }
2
+ from "@agents-can-communicate/protocol";
3
+ import { createCoordinationService } from "@agents-can-communicate/core";
4
+ import { openFilesystemStore } from "@agents-can-communicate/storage-filesystem";
5
+
6
+ import { parseArgs, positiveNumber } from "./args.mjs";
7
+
8
+ // A usage error names what is missing rather than failing deeper in a service
9
+ // with the argument already half-applied.
10
+ const usage = message => new AccError(EXIT.USAGE, message);
11
+ import { describeCommands, helpText } from "./help.mjs";
12
+ import { runConfigCommand } from "./config-command.mjs";
13
+ import { runInstallCommand } from "./install-command.mjs";
14
+ import { runDoctor } from "./doctor-command.mjs";
15
+ import { createGitProbe } from "./git-probe.mjs";
16
+ import { canonicalClaim } from "./claim-spelling.mjs";
17
+ import { platformDataHome, runtimePaths } from "./runtime-paths.mjs";
18
+ import { resolveOwner } from "./session-owner.mjs";
19
+ import { discoverWorkspace } from "./workspace-discovery.mjs";
20
+
21
+ const DEFAULT_CADENCE_MS = 30_000;
22
+
23
+ async function openContext(options, runtime) {
24
+ const descriptor = await discoverWorkspace({
25
+ cwd: options.cwd ?? runtime.cwd,
26
+ env: runtime.env,
27
+ gitProbe: runtime.gitProbe ?? createGitProbe(),
28
+ explicitConfig: options.workspace,
29
+ });
30
+ const paths = runtimePaths({
31
+ dataHome: runtime.dataHome ?? platformDataHome({ platform: runtime.platform,
32
+ env: runtime.env }),
33
+ workspaceId: descriptor.id,
34
+ workspaceRoots: descriptor.roots,
35
+ });
36
+ const store = await openFilesystemStore({ root: paths.root, clock: runtime.clock,
37
+ ids: runtime.ids, workspaceId: descriptor.id });
38
+ return { descriptor, paths,
39
+ service: createCoordinationService({ store, clock: runtime.clock, ids: runtime.ids }) };
40
+ }
41
+
42
+ const human = value => (typeof value === "string" ? value : JSON.stringify(value, null, 2));
43
+
44
+ const HANDLERS = Object.freeze({
45
+ attach: async ({ options, context }) => {
46
+ const session = await context.service.openSession({
47
+ workspaceId: context.descriptor.id,
48
+ participantId: options.participant,
49
+ displayName: options.participant,
50
+ harness: options.harness ?? "cli",
51
+ parentSessionId: options.parent ?? null,
52
+ heartbeatCadenceMs: options.cadence
53
+ ? positiveNumber(options.cadence, "cadence") : DEFAULT_CADENCE_MS,
54
+ sessionId: options.session,
55
+ descriptor: context.descriptor,
56
+ });
57
+ // The adapter needs both to prove ownership later, so both are printed.
58
+ return { data: { sessionId: session.sessionId, generation: session.generation },
59
+ text: `${session.sessionId} ${session.generation}` };
60
+ },
61
+
62
+ heartbeat: async ({ options, context }) => {
63
+ const session = await context.service.heartbeatSession({ sessionId: options.session,
64
+ generation: options.generation });
65
+ return { data: { heartbeatAt: session.heartbeatAt }, text: session.heartbeatAt };
66
+ },
67
+
68
+ detach: async ({ options, context }) => {
69
+ const session = await context.service.closeSession({ sessionId: options.session,
70
+ generation: options.generation });
71
+ return { data: { sessionId: session.sessionId, state: session.state },
72
+ text: `closed ${session.sessionId}` };
73
+ },
74
+
75
+ sync: async ({ options, context }) => {
76
+ const result = await context.service.sync({ sessionId: options.session,
77
+ cursor: options.cursor ?? null, scope: options.scope,
78
+ limit: options.limit ? positiveNumber(options.limit, "limit") : undefined });
79
+ // Solo zero-overhead: nothing to say means nothing printed, not a banner.
80
+ const text = result.solo ? "" : `${result.attention.length} attention; `
81
+ + `${result.roster.length} session(s); cursor ${result.cursor}`;
82
+ return { data: result, text };
83
+ },
84
+
85
+ work: async ({ options, context }) => {
86
+ // Saying nothing and saying "I have stopped" are different. Without this an
87
+ // intent stayed on the roster until the session closed, so peers read a
88
+ // finished piece of work as still in progress.
89
+ if (options.clear === true) {
90
+ const cleared = await context.service.clearIntent({ sessionId: options.session,
91
+ generation: options.generation });
92
+ return { data: cleared, text: "intent cleared" };
93
+ }
94
+ if (options.summary === undefined) throw usage("work requires --summary");
95
+ const intent = await context.service.setIntent({ sessionId: options.session,
96
+ generation: options.generation, summary: options.summary, mode: options.mode ?? "edit",
97
+ state: options.state, workstreamId: options.workstream ?? null,
98
+ resourceHints: options.hint ?? [] });
99
+ return { data: intent, text: `intent: ${intent.summary}` };
100
+ },
101
+
102
+ claim: async ({ options, context }) => {
103
+ const claim = await context.service.acquireClaim({ sessionId: options.session,
104
+ generation: options.generation,
105
+ resource: await canonicalClaim(options.resource, context.descriptor),
106
+ mode: options.mode ?? "exclusive", enforcement: options.enforcement ?? "advisory",
107
+ reason: options.reason ?? "unspecified",
108
+ leaseSeconds: options.lease ? positiveNumber(options.lease, "lease") : undefined,
109
+ descriptor: context.descriptor });
110
+ return { data: claim, text: `claimed ${claim.resource}` };
111
+ },
112
+
113
+ release: async ({ options, context }) => {
114
+ const request = { claimId: options.claim, sessionId: options.session,
115
+ generation: options.generation, authority: options.authority, reason: options.reason };
116
+ if (options.authority === undefined) await context.service.releaseClaim(request);
117
+ else await context.service.forceReleaseClaim(request);
118
+ return { data: { claimId: options.claim }, text: `released ${options.claim}` };
119
+ },
120
+
121
+ message: async ({ options, context }) => {
122
+ const message = await context.service.sendMessage({ sessionId: options.session,
123
+ generation: options.generation, toParticipantIds: options.to ?? [],
124
+ type: options.type ?? "note", subject: options.subject, body: options.body,
125
+ priority: options.priority, workstreamId: options.workstream ?? null,
126
+ requiresAck: options.requiresAck === true, descriptor: context.descriptor });
127
+ return { data: message, text: `sent ${message.messageId}` };
128
+ },
129
+
130
+ /**
131
+ * Ask another agent to do something. One call, one write.
132
+ *
133
+ * The recipient hears about it twice by design: the task raises an attention
134
+ * item for the participant it names, and the message reaches their turn as
135
+ * quoted peer text explaining why.
136
+ */
137
+ request: async ({ options, context }) => {
138
+ const { task, message } = await context.service.requestWork({
139
+ sessionId: options.session, generation: options.generation,
140
+ toParticipantId: options.to, title: options.title, detail: options.detail,
141
+ workstreamId: options.workstream, priority: options.priority,
142
+ dependsOn: options.dependsOn ?? [], descriptor: context.descriptor });
143
+ return { data: { task, message },
144
+ text: `requested ${task.taskId} of ${options.to}` };
145
+ },
146
+
147
+ ack: async ({ options, context }) => {
148
+ const receipt = await context.service.markDelivery({ sessionId: options.session,
149
+ generation: options.generation, messageId: options.message,
150
+ state: options.state ?? "acknowledged" });
151
+ return { data: receipt, text: `${receipt.messageId} ${receipt.state}` };
152
+ },
153
+
154
+ /**
155
+ * What was settled, and on whose authority.
156
+ *
157
+ * `--human` is the caller stating that a person actually decided this. The
158
+ * core refuses `--authority human` without it, because a peer proposal
159
+ * becoming a human decision on its own is the one way this record could
160
+ * launder an agent's opinion into a ruling.
161
+ */
162
+ decide: async ({ options, context }) => {
163
+ const decision = await context.service.recordDecision({
164
+ sessionId: options.session, generation: options.generation,
165
+ title: options.title, outcome: options.outcome,
166
+ authority: options.authority ?? "workstream",
167
+ workstreamId: options.workstream ?? null,
168
+ decidedBy: options.decidedBy,
169
+ supersedes: options.supersedes ?? null,
170
+ humanConfirmed: options.human === true,
171
+ descriptor: context.descriptor });
172
+ return { data: decision,
173
+ text: `${decision.decisionId} ${decision.authority}: ${decision.title}` };
174
+ },
175
+
176
+ workstream: async ({ options, context }) => {
177
+ const owner = { sessionId: options.session, generation: options.generation };
178
+ // Taking the coordination of one and creating one are the same noun, so
179
+ // they stay one command rather than two the model has to choose between -
180
+ // the same shape `acc task` already has.
181
+ if (options.take === true || options.release === true) {
182
+ if (options.workstream === undefined) {
183
+ throw usage(`workstream --${options.take === true ? "take" : "release"} `
184
+ + "requires --workstream");
185
+ }
186
+ const acted = options.take === true
187
+ ? await context.service.acquireCoordinator({ ...owner,
188
+ workstreamId: options.workstream })
189
+ : await context.service.releaseCoordinator({ ...owner,
190
+ workstreamId: options.workstream });
191
+ return { data: acted,
192
+ text: `${acted.workstreamId} ${options.take === true ? "coordinated" : "released"}` };
193
+ }
194
+ if (options.title === undefined) throw usage("workstream requires --title");
195
+ if (options.objective === undefined) throw usage("workstream requires --objective");
196
+ const workstream = await context.service.createWorkstream({ ...owner,
197
+ title: options.title, objective: options.objective,
198
+ descriptor: context.descriptor });
199
+ return { data: workstream, text: `${workstream.workstreamId} ${workstream.state}` };
200
+ },
201
+
202
+ task: async ({ options, context }) => {
203
+ const owner = { sessionId: options.session, generation: options.generation };
204
+ // Taking work and moving it along are the same noun as creating it, so they
205
+ // stay one command rather than three the model has to choose between.
206
+ if (options.take === true) {
207
+ if (options.task === undefined) throw usage("task --take requires --task");
208
+ const taken = await context.service.claimTask({ ...owner, taskId: options.task,
209
+ force: options.force === true });
210
+ return { data: taken, text: `${taken.taskId} ${taken.state}` };
211
+ }
212
+ if (options.decline === true) {
213
+ if (options.task === undefined) throw usage("task --decline requires --task");
214
+ const refused = await context.service.declineTask({ ...owner,
215
+ taskId: options.task, reason: options.reason });
216
+ return { data: refused, text: `${refused.taskId} declined` };
217
+ }
218
+ if (options.state !== undefined) {
219
+ if (options.task === undefined) throw usage("task --state requires --task");
220
+ const moved = await context.service.transitionTask({ ...owner,
221
+ taskId: options.task, state: options.state });
222
+ return { data: moved, text: `${moved.taskId} ${moved.state}` };
223
+ }
224
+ if (options.title === undefined) throw usage("task requires --title");
225
+ const task = await context.service.createTask({ ...owner,
226
+ workstreamId: options.workstream, title: options.title, detail: options.detail,
227
+ assigneeParticipantId: options.assignee, taskId: options.task,
228
+ dependsOn: options.dependsOn ?? [], descriptor: context.descriptor });
229
+ return { data: task, text: `${task.taskId} ${task.state}` };
230
+ },
231
+
232
+ finish: async ({ options, context }) => {
233
+ const handoff = await context.service.finishSession({ sessionId: options.session,
234
+ generation: options.generation, goal: options.goal, status: options.status,
235
+ toParticipantId: options.to ?? null, completed: options.completed ?? [],
236
+ remaining: options.remaining ?? [], blockers: options.blocker ?? [] });
237
+ return { data: handoff, text: `handoff ${handoff.handoffId}` };
238
+ },
239
+
240
+ status: async ({ options, context }) => {
241
+ const status = await context.service.collectStatus({
242
+ participantId: options.participant, all: options.all === true });
243
+ const text = `${status.counts.live} live; ${status.counts.claims} claim(s); `
244
+ + `protection ${status.protection}`;
245
+ return { data: status, text };
246
+ },
247
+
248
+ config: async ({ options, runtime }) => {
249
+ const result = await runConfigCommand({ subcommand: options.subcommand,
250
+ cwd: options.cwd ?? runtime.cwd,
251
+ // A pipe is not a person. Without a TTY there is nobody to answer, so the
252
+ // command demands --yes rather than hanging or assuming consent.
253
+ interactive: runtime.stdout?.isTTY === true,
254
+ yes: options.yes === true,
255
+ confirm: runtime.confirm ?? (async () => false),
256
+ force: options.force === true,
257
+ // Opened lazily and only by `init`: `config validate` has to work on a
258
+ // workspace discovery cannot open, which is what a reader runs it to
259
+ // find out.
260
+ probeWorkspace: () => openContext(options, runtime),
261
+ ids: runtime.ids });
262
+ if (result.subcommand === "validate") {
263
+ return { data: result, text: result.present
264
+ ? `${result.file} is valid` : `no ${CONFIG_FILENAME}; defaults apply` };
265
+ }
266
+ return { data: result,
267
+ text: result.written ? `wrote ${result.file}` : `not written: ${result.file}` };
268
+ },
269
+
270
+ install: async ({ options, runtime }) =>
271
+ runInstallCommand({ options, runtime, action: "install" }),
272
+
273
+ uninstall: async ({ options, runtime }) =>
274
+ runInstallCommand({ options, runtime, action: "uninstall" }),
275
+
276
+ doctor: async ({ options, context, runtime }) => runDoctor({ options, context, runtime }),
277
+
278
+ help: async () => ({ data: { commands: describeCommands() }, text: helpText() }),
279
+
280
+ version: async ({ runtime }) => {
281
+ // Read by the composition root from the package manifest: `bin/` sits at
282
+ // the same depth in the published package as it does in this tree, which is
283
+ // not true of anything under `packages/`. A version typed into the source
284
+ // would be one more place the next bump has to reach, and the kind that
285
+ // goes stale without failing.
286
+ if (typeof runtime.version !== "function") {
287
+ throw new AccError(EXIT.DATA, "this build was assembled without a version to report");
288
+ }
289
+ const version = await runtime.version();
290
+ return { data: { version }, text: version };
291
+ },
292
+ });
293
+
294
+ /**
295
+ * @returns {Promise<number>} the process exit code
296
+ */
297
+ const NO_WORKSPACE = Object.freeze(["config", "install", "uninstall", "help", "version"]);
298
+
299
+ export async function main(argv, runtime) {
300
+ const write = (stream, text) => new Promise((resolve, reject) =>
301
+ stream.write(text, error => (error ? reject(error) : resolve())));
302
+ let parsed;
303
+ try {
304
+ parsed = parseArgs(argv);
305
+ // `config` is the one command that must work on a workspace ACC cannot
306
+ // open. Discovery validates the config too, so a broken one would fail
307
+ // there first and `acc config validate` - the command a user runs to find
308
+ // out what is wrong - would never reach its own report.
309
+ // These work on a machine, not a workspace. `config` must run even when
310
+ // discovery cannot open the workspace - that is what a user is trying to
311
+ // find out - install touches client configuration rather than ACC state,
312
+ // and `help` has to answer in a directory that is no workspace at all.
313
+ const context = NO_WORKSPACE.includes(parsed.command)
314
+ ? null
315
+ : await openContext(parsed.options, runtime);
316
+ // Which session is calling is answered once, here, rather than by each
317
+ // handler: every one of them needs the same pair, and a handler that forgot
318
+ // to ask would be an operation an agent cannot reach.
319
+ const options = context === null ? parsed.options
320
+ : await resolveOwner({ command: parsed.command, options: parsed.options,
321
+ context, env: runtime.env });
322
+ const { data, text, error: outcome } = await HANDLERS[parsed.command](
323
+ { options, context, runtime });
324
+ // A handler may have done real work and still failed: `acc install` writes
325
+ // for the clients it could and reports the one it could not. The data is
326
+ // printed either way, and the command still fails.
327
+ if (outcome != null) throw Object.assign(outcome, { details: { ...outcome.details, ...data } });
328
+ // Machine mode writes exactly one JSON object to stdout and nothing else.
329
+ if (parsed.options.json === true) await write(runtime.stdout, `${JSON.stringify(ok(data))}\n`);
330
+ else if (text !== "") await write(runtime.stdout, `${human(text)}\n`);
331
+ return EXIT.OK;
332
+ } catch (error) {
333
+ const code = error instanceof AccError ? error.code : EXIT.DATA;
334
+ // A usage error happens before the parse completes, so machine mode has to
335
+ // be recognised from the raw argv. Otherwise the one caller that cannot
336
+ // read prose - an adapter - gets prose exactly when it made a mistake.
337
+ if (parsed?.options?.json === true || argv.includes("--json")) {
338
+ await write(runtime.stdout, `${JSON.stringify(failure(error))}\n`);
339
+ } else {
340
+ await write(runtime.stderr, `${error.message}\n`);
341
+ }
342
+ return code;
343
+ }
344
+ }
@@ -0,0 +1,109 @@
1
+ import path from "node:path";
2
+
3
+ import { AccError, EXIT } from "@agents-can-communicate/protocol";
4
+
5
+ // Resolution is a pure function of platform and environment, so the Windows and
6
+ // Linux answers are testable from a machine that is neither. Reading
7
+ // process.platform inside would mean two of the three branches are only ever
8
+ // exercised by whoever happens to run them.
9
+ const AREAS = Object.freeze(["data", "config", "cache"]);
10
+
11
+ const OVERRIDE = Object.freeze({
12
+ data: "ACC_DATA_HOME",
13
+ config: "ACC_CONFIG_HOME",
14
+ cache: "ACC_CACHE_HOME",
15
+ });
16
+
17
+ const XDG = Object.freeze({
18
+ data: "XDG_DATA_HOME",
19
+ config: "XDG_CONFIG_HOME",
20
+ cache: "XDG_CACHE_HOME",
21
+ });
22
+
23
+ // An exported-but-empty variable is the normal shape of "unset" in a shell
24
+ // script. Treating it as a path puts runtime state at the filesystem root.
25
+ const set = value => typeof value === "string" && value.length > 0;
26
+
27
+ const usage = (message, details) => {
28
+ throw new AccError(EXIT.USAGE, message, details);
29
+ };
30
+
31
+ function windowsPaths(env) {
32
+ if (!set(env.APPDATA)) {
33
+ usage("cannot resolve the Windows application data directory");
34
+ }
35
+ return {
36
+ data: env.APPDATA,
37
+ config: env.APPDATA,
38
+ // Roaming is the honest fallback: a cache that roams is wasteful, not wrong.
39
+ cache: set(env.LOCALAPPDATA) ? env.LOCALAPPDATA : env.APPDATA,
40
+ };
41
+ }
42
+
43
+ function requireHome(env) {
44
+ if (!set(env.HOME)) usage("cannot resolve the user home directory");
45
+ return env;
46
+ }
47
+
48
+ function macosPaths(env) {
49
+ const support = path.join(env.HOME, "Library", "Application Support");
50
+ // XDG variables are deliberately ignored here. They are common on a machine
51
+ // that also runs Linux tooling, and letting one relocate macOS state would
52
+ // move a user's sessions the day they install something unrelated.
53
+ return { data: support, config: support,
54
+ cache: path.join(env.HOME, "Library", "Caches") };
55
+ }
56
+
57
+ function xdgPaths(env) {
58
+ const fallback = { data: path.join(env.HOME, ".local", "share"),
59
+ config: path.join(env.HOME, ".config"), cache: path.join(env.HOME, ".cache") };
60
+ return Object.fromEntries(AREAS.map(area =>
61
+ [area, set(env[XDG[area]]) ? env[XDG[area]] : fallback[area]]));
62
+ }
63
+
64
+ /**
65
+ * Where ACC keeps state, configuration, and cache on this platform.
66
+ *
67
+ * None of it is ever inside a workspace. A checkout can be deleted, cloned, or
68
+ * synced to another machine without carrying presence, locks, or messages with
69
+ * it - and "just put it in a dotfile next to the project" is the exact
70
+ * regression this exists to prevent, which is why passing `workspaceRoots`
71
+ * makes it an error rather than a convention.
72
+ */
73
+ export function platformPaths({ platform = process.platform, env = process.env,
74
+ workspaceRoots = [] } = {}) {
75
+ // Windows paths are not absolute to a posix `path`, so the check has to use
76
+ // the same flavour the platform does.
77
+ const flavour = platform === "win32" ? path.win32 : path.posix;
78
+
79
+ const overridden = Object.fromEntries(AREAS
80
+ .filter(area => set(env[OVERRIDE[area]]))
81
+ .map(area => [area, env[OVERRIDE[area]]]));
82
+
83
+ // The platform is only consulted for what was not named outright, so a fully
84
+ // overridden environment never has to satisfy that platform's own
85
+ // prerequisites - which is what makes a test fixture for one platform
86
+ // runnable on another.
87
+ const missing = AREAS.filter(area => overridden[area] === undefined);
88
+ const base = missing.length === 0 ? {}
89
+ : platform === "win32" ? windowsPaths(env)
90
+ : platform === "darwin" ? macosPaths(requireHome(env))
91
+ : xdgPaths(requireHome(env));
92
+
93
+ const resolved = Object.fromEntries(AREAS.map(area =>
94
+ [area, overridden[area] ?? base[area]]));
95
+
96
+ for (const [area, location] of Object.entries(resolved)) {
97
+ if (!flavour.isAbsolute(location)) {
98
+ usage(`the ${area} location must be an absolute path`, { area, location });
99
+ }
100
+ for (const root of workspaceRoots) {
101
+ const relative = flavour.relative(root, location);
102
+ if (relative === ""
103
+ || (!flavour.isAbsolute(relative) && !relative.startsWith(".."))) {
104
+ usage(`${area} state must not live inside the workspace`, { area, location, root });
105
+ }
106
+ }
107
+ }
108
+ return Object.freeze(resolved);
109
+ }
@@ -0,0 +1,58 @@
1
+ import path from "node:path";
2
+
3
+ import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protocol";
4
+
5
+ const AREAS = ["protocol", "events", "state", "locks", "ephemeral"];
6
+
7
+ /**
8
+ * @typedef {{ root: string, protocol: string, events: string, state: string,
9
+ * locks: string, ephemeral: string }} RuntimePaths
10
+ */
11
+
12
+ /**
13
+ * Runtime state lives under the platform's user-data directory, never inside
14
+ * the workspace. A checkout can be deleted, cloned, or synced to another
15
+ * machine without carrying presence, locks, or messages with it.
16
+ */
17
+ export function runtimePaths({ dataHome, workspaceId, workspaceRoots = [] }) {
18
+ if (typeof dataHome !== "string" || !path.isAbsolute(dataHome)) {
19
+ throw new AccError(EXIT.USAGE, "the data home must be an absolute path", { dataHome });
20
+ }
21
+ assertPortableId(workspaceId, "workspace id");
22
+ const root = path.join(dataHome, "acc", "workspaces", workspaceId);
23
+ // Enforced here rather than only asserted in a test, because "just put it in
24
+ // .agents next to the project" is the exact regression this design exists to
25
+ // prevent, and it would otherwise look like it works.
26
+ for (const workspaceRoot of workspaceRoots) {
27
+ const relative = path.relative(workspaceRoot, root);
28
+ if (relative === "" || (!path.isAbsolute(relative) && !relative.startsWith(".."))) {
29
+ throw new AccError(EXIT.USAGE, "runtime state must not live inside the workspace",
30
+ { root, workspaceRoot });
31
+ }
32
+ }
33
+ return Object.freeze(Object.fromEntries([["root", root],
34
+ // `ephemeral` holds presence and Intent for workspaces that have not
35
+ // materialised durable state yet, so it is deliberately a sibling of the
36
+ // event log rather than a corner of it.
37
+ ...AREAS.map(area => [area, path.join(root, area)])]));
38
+ }
39
+
40
+ export function platformDataHome({ platform = process.platform, env = process.env } = {}) {
41
+ if (typeof env.ACC_DATA_HOME === "string" && env.ACC_DATA_HOME.length > 0) {
42
+ return env.ACC_DATA_HOME;
43
+ }
44
+ if (platform === "win32") {
45
+ if (typeof env.APPDATA === "string" && env.APPDATA.length > 0) return env.APPDATA;
46
+ throw new AccError(EXIT.USAGE, "cannot resolve the Windows application data directory");
47
+ }
48
+ if (typeof env.XDG_DATA_HOME === "string" && env.XDG_DATA_HOME.length > 0
49
+ && platform !== "darwin") {
50
+ return env.XDG_DATA_HOME;
51
+ }
52
+ if (typeof env.HOME !== "string" || env.HOME.length === 0) {
53
+ throw new AccError(EXIT.USAGE, "cannot resolve the user home directory");
54
+ }
55
+ return platform === "darwin"
56
+ ? path.join(env.HOME, "Library", "Application Support")
57
+ : path.join(env.HOME, ".local", "share");
58
+ }