agents-can-communicate 0.1.5 → 0.1.7
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/bin/acc-hook.mjs +0 -0
- package/docs/CAPABILITIES.md +18 -7
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.claude-plugin/plugin.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +8 -3
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +6 -4
- package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +22 -3
- package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +74 -2
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/hooks.mjs +4 -3
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/.kimi-plugin/plugin.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/src/hooks.mjs +5 -4
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +6 -5
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/shell-writes.mjs +319 -0
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +6 -2
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +4 -2
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +8 -2
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +50 -3
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/package.json +1 -1
package/bin/acc-hook.mjs
CHANGED
|
File without changes
|
package/docs/CAPABILITIES.md
CHANGED
|
@@ -60,10 +60,17 @@ toolset contained no `apply_patch`.
|
|
|
60
60
|
`plan` modes the client declares no write tool to the model at all. `write_file` and
|
|
61
61
|
`replace` appear under `auto_edit`; `run_shell_command` under `yolo`.
|
|
62
62
|
|
|
63
|
-
**`guards.beforeShell` is
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
**`guards.beforeShell` is resource-aware where the write is unambiguous.** ACC reads the
|
|
64
|
+
command for its write positions only - a redirection, an operand of a command whose whole
|
|
65
|
+
job is to put bytes somewhere - and declares those paths as targets. Reading positions are
|
|
66
|
+
left alone: `cat file` and `grep file` name a path and write nothing, and treating them as
|
|
67
|
+
writes would have sessions blocking each other for looking.
|
|
68
|
+
|
|
69
|
+
What it does not see is a language runtime opening the file itself (`python3 -c
|
|
70
|
+
"open(...)"`), a command assembled at runtime, or an `eval`. A shell can still evade the
|
|
71
|
+
guard. Until 0.1.7 every shell write did, which is why partial sight is the improvement it
|
|
72
|
+
is: a session told to prefer the shell for file changes walked through every claim in the
|
|
73
|
+
workspace.
|
|
67
74
|
|
|
68
75
|
Where the guard cannot help, the turn context does: it names the claims other sessions
|
|
69
76
|
hold and says which way this session stands with them. Two facts decide the wording -
|
|
@@ -71,7 +78,7 @@ what the claim's owner asked for, and whether ACC can stop this session at all:
|
|
|
71
78
|
|
|
72
79
|
| Claim | This session | Note |
|
|
73
80
|
|---|---|---|
|
|
74
|
-
| guarded | can be guarded | `file edits are blocked;
|
|
81
|
+
| guarded | can be guarded | `file edits and recognised shell writes are blocked; a runtime can still get past` |
|
|
75
82
|
| guarded | cannot be guarded | `not enforced for this session; do not edit it` |
|
|
76
83
|
| advisory | either | `advisory; nothing will stop you, the owner is asking` |
|
|
77
84
|
|
|
@@ -154,5 +161,9 @@ harness name. Both default to the weaker reading, so a generic MCP client or a h
|
|
|
154
161
|
the CLI reads as advisory and manual.
|
|
155
162
|
|
|
156
163
|
A workspace reports `protection: guarded` only when every live session can be stopped. One
|
|
157
|
-
MCP client
|
|
158
|
-
|
|
164
|
+
MCP client and a guarded claim is advice - so the workspace says `advisory`, whatever its
|
|
165
|
+
claims were declared as.
|
|
166
|
+
|
|
167
|
+
"Stoppable" is not "unevadable". Even in a guarded workspace, a session that writes through
|
|
168
|
+
a language runtime rather than a recognised shell form gets past. The claim still says who
|
|
169
|
+
is working where; enforcement is the floor, not the ceiling.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { normalizedEvent } from "@agents-can-communicate/adapter-sdk";
|
|
1
|
+
import { normalizedEvent, shellWriteTargets } from "@agents-can-communicate/adapter-sdk";
|
|
2
2
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
3
3
|
|
|
4
4
|
// Confirmed by capture on 2.1.233, and matching the published documentation
|
|
@@ -66,10 +66,15 @@ export const CLAUDE_EDIT_TOOLS = Object.freeze(["Write", "Edit", "MultiEdit",
|
|
|
66
66
|
* Only the path is taken. `content`, `old_string` and `new_string` are the
|
|
67
67
|
* file's contents, which ACC has no use for and must not carry.
|
|
68
68
|
*
|
|
69
|
-
* `Bash` declares
|
|
70
|
-
* of
|
|
69
|
+
* `Bash` declares no path, but its command names the ones it would write. Those
|
|
70
|
+
* are read out of the write positions only - a redirection, an operand of a
|
|
71
|
+
* command whose job is to put bytes somewhere - because a session told to prefer
|
|
72
|
+
* the shell would otherwise walk through every claim in the workspace. The
|
|
73
|
+
* command string itself is conversation content and stays out; only paths leave
|
|
74
|
+
* this function.
|
|
71
75
|
*/
|
|
72
76
|
function writeTargets(tool, input) {
|
|
77
|
+
if (tool === "Bash") return shellWriteTargets(input?.command);
|
|
73
78
|
if (!CLAUDE_EDIT_TOOLS.includes(tool)) return [];
|
|
74
79
|
const target = input?.file_path ?? input?.notebook_path;
|
|
75
80
|
return typeof target === "string" && target !== "" ? [target] : [];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agents-can-communicate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Coordinate this Codex session with other AI agent sessions working in the same workspace.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"keywords": ["coordination", "multi-agent", "claims", "handoff"],
|
|
@@ -62,10 +62,12 @@ export function createCodexAdapter() {
|
|
|
62
62
|
// property of the model's metadata (apply_patch_tool_type), not a user
|
|
63
63
|
// setting. With a model that does not have it, edits go through
|
|
64
64
|
// exec_command, which reaches hooks as tool_name \"Bash\" carrying a
|
|
65
|
-
// command string
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
// command string. Since 0.1.7 that command is read for its write
|
|
66
|
+
// positions, so those edits are matched too - as far as the reading
|
|
67
|
+
// goes. Verified on 0.147.0.
|
|
68
|
+
"write guards cover apply_patch and the shell writes ACC can read; a model "
|
|
69
|
+
+ "without apply_patch edits through the shell, where a redirection or an "
|
|
70
|
+
+ "mv is matched and a runtime opening the file is not",
|
|
69
71
|
"Codex requires hooks to be trusted before they run; an untrusted plugin is "
|
|
70
72
|
+ "installed but inert",
|
|
71
73
|
],
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { normalizedEvent } from "@agents-can-communicate/adapter-sdk";
|
|
1
|
+
import { normalizedEvent, shellWriteTargets } from "@agents-can-communicate/adapter-sdk";
|
|
2
2
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
3
3
|
|
|
4
4
|
// The event names are settled: they are an enum in the installed 0.147.0 binary,
|
|
@@ -68,7 +68,7 @@ export function normalizeCodexHook(payload) {
|
|
|
68
68
|
model: pick(payload, FIELD_CANDIDATES.model),
|
|
69
69
|
parentSessionId: pick(payload, FIELD_CANDIDATES.parentSessionId),
|
|
70
70
|
tool,
|
|
71
|
-
targets:
|
|
71
|
+
targets: writeTargets(tool, payload?.tool_input),
|
|
72
72
|
});
|
|
73
73
|
}
|
|
74
74
|
|
|
@@ -76,6 +76,7 @@ export function normalizeCodexHook(payload) {
|
|
|
76
76
|
// patch body, one per operation, so a single call can touch several files.
|
|
77
77
|
// Reading `tool_input.path` here - the shape every other harness uses - would
|
|
78
78
|
// find nothing and leave every edit unguarded.
|
|
79
|
+
const PATCH_ENVELOPE = "*** Begin Patch";
|
|
79
80
|
const PATCH_OPERATION = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/;
|
|
80
81
|
const PATCH_MOVE = /^\*\*\* Move to: (.+)$/;
|
|
81
82
|
|
|
@@ -86,9 +87,9 @@ const PATCH_MOVE = /^\*\*\* Move to: (.+)$/;
|
|
|
86
87
|
* are never read: they are the file's contents, which ACC has no use for.
|
|
87
88
|
*/
|
|
88
89
|
export function patchTargets(tool, input) {
|
|
89
|
-
if (tool !== "apply_patch") return [];
|
|
90
90
|
const body = input?.command ?? input?.patch ?? input?.input;
|
|
91
91
|
if (typeof body !== "string") return [];
|
|
92
|
+
if (tool !== "apply_patch" && !body.trimStart().startsWith(PATCH_ENVELOPE)) return [];
|
|
92
93
|
const targets = [];
|
|
93
94
|
for (const line of body.split("\n")) {
|
|
94
95
|
const operation = PATCH_OPERATION.exec(line) ?? PATCH_MOVE.exec(line);
|
|
@@ -97,6 +98,24 @@ export function patchTargets(tool, input) {
|
|
|
97
98
|
return targets;
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
/**
|
|
102
|
+
* The paths a tool call would write.
|
|
103
|
+
*
|
|
104
|
+
* The guard is wired to three tool names here, and which one a shell command
|
|
105
|
+
* arrives under is a property of the model's metadata rather than of this
|
|
106
|
+
* config. So the shape decides instead of the name: a patch envelope is read as
|
|
107
|
+
* a patch, and any other command string is read as a shell command. Reading
|
|
108
|
+
* positions in that command are left alone.
|
|
109
|
+
*/
|
|
110
|
+
export function writeTargets(tool, input) {
|
|
111
|
+
const patched = patchTargets(tool, input);
|
|
112
|
+
if (patched.length > 0) return patched;
|
|
113
|
+
const command = input?.command ?? input?.input;
|
|
114
|
+
if (typeof command !== "string") return [];
|
|
115
|
+
if (command.trimStart().startsWith(PATCH_ENVELOPE)) return [];
|
|
116
|
+
return shellWriteTargets(command);
|
|
117
|
+
}
|
|
118
|
+
|
|
100
119
|
/**
|
|
101
120
|
* Deny a tool call the way this client understands it.
|
|
102
121
|
*
|
|
@@ -118,8 +118,37 @@ const entryFor = () => ({
|
|
|
118
118
|
* an install that writes files alone leaves a plugin the client never sees and
|
|
119
119
|
* a hook that never runs - while reporting success.
|
|
120
120
|
*/
|
|
121
|
+
/**
|
|
122
|
+
* The table that lets an agent here record anything at all.
|
|
123
|
+
*
|
|
124
|
+
* This client sandboxes the shell commands a model runs to the workspace, and
|
|
125
|
+
* ACC keeps its state outside every workspace on purpose - so an agent could
|
|
126
|
+
* read the roster and write nothing: `acc claim`, `acc work`, `acc message` all
|
|
127
|
+
* failed with `EPERM ... locks/writer.lock`. Measured with `codex exec`, which
|
|
128
|
+
* is how an agent actually runs, and confirmed by watching the same commands
|
|
129
|
+
* succeed once this root was declared.
|
|
130
|
+
*
|
|
131
|
+
* Left out when the user declares the table themselves. Declaring it twice is
|
|
132
|
+
* what makes this client refuse the whole config, and their setting is theirs -
|
|
133
|
+
* the diagnostic says what to add rather than adding it for them.
|
|
134
|
+
*/
|
|
135
|
+
const sandboxTable = (stateRoot, theirs) => {
|
|
136
|
+
if (typeof stateRoot !== "string" || stateRoot === "") return [];
|
|
137
|
+
if (theirs) return [];
|
|
138
|
+
return ["", "[sandbox_workspace_write]",
|
|
139
|
+
`writable_roots = [${tomlString(stateRoot)}]`];
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Every spelling TOML allows for the same table: the header, a sub-table
|
|
143
|
+
// header, a dotted key, and an inline table on one line. Missing one means ACC
|
|
144
|
+
// appends a second declaration - which is the duplicate this exists to avoid,
|
|
145
|
+
// and the client refuses the whole config over it.
|
|
146
|
+
const declaresSandbox = config =>
|
|
147
|
+
/^\s*\[sandbox_workspace_write[\].]/m.test(config)
|
|
148
|
+
|| /^\s*sandbox_workspace_write\s*[.=]/m.test(config);
|
|
149
|
+
|
|
121
150
|
export async function installCodexPlugin({ home, agentsHome = home,
|
|
122
|
-
codexHome = path.join(home, ".codex"), runner, node }) {
|
|
151
|
+
codexHome = path.join(home, ".codex"), stateRoot, runner, node }) {
|
|
123
152
|
// Read before writing, so a manifest that will not parse is found before a
|
|
124
153
|
// plugin tree is laid down that nothing will then be able to remove.
|
|
125
154
|
const existing = await readJson(marketplacePath(agentsHome), { name: MARKETPLACE,
|
|
@@ -157,6 +186,7 @@ export async function installCodexPlugin({ home, agentsHome = home,
|
|
|
157
186
|
`marketplace ${MARKETPLACE} is already registered in this config; `
|
|
158
187
|
+ "remove it and install again", { config });
|
|
159
188
|
}
|
|
189
|
+
const theirSandbox = declaresSandbox(stripBlock(before));
|
|
160
190
|
await writeTomlBlock(config, [
|
|
161
191
|
`[marketplaces.${MARKETPLACE}]`,
|
|
162
192
|
`source_type = "local"`,
|
|
@@ -164,6 +194,7 @@ export async function installCodexPlugin({ home, agentsHome = home,
|
|
|
164
194
|
"",
|
|
165
195
|
`[plugins.${tomlString(QUALIFIED)}]`,
|
|
166
196
|
"enabled = true",
|
|
197
|
+
...sandboxTable(stateRoot, theirSandbox),
|
|
167
198
|
]);
|
|
168
199
|
|
|
169
200
|
// The client runs the cached copy, so this has to happen after the shim and
|
|
@@ -184,7 +215,11 @@ export async function installCodexPlugin({ home, agentsHome = home,
|
|
|
184
215
|
// while ACC invented its own marketplace name and so had a root to itself.
|
|
185
216
|
// make the record stale the moment the plugin version changes.
|
|
186
217
|
return { ok: true, changes: [target, file, config, cachePath(codexHome)],
|
|
187
|
-
diagnostics: ["hooks require explicit trust in Codex before they run"
|
|
218
|
+
diagnostics: ["hooks require explicit trust in Codex before they run",
|
|
219
|
+
...(theirSandbox
|
|
220
|
+
? [`this config sets its own sandbox_workspace_write; add ${stateRoot} to `
|
|
221
|
+
+ "writable_roots, or an agent here can read the roster and record nothing"]
|
|
222
|
+
: [])] };
|
|
188
223
|
}
|
|
189
224
|
|
|
190
225
|
/** Remove each directory that is empty, in the order given. */
|
|
@@ -194,6 +229,34 @@ async function removeEmptyDirs(directories) {
|
|
|
194
229
|
}
|
|
195
230
|
}
|
|
196
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Drop the client's trust record for hooks that are about to stop existing.
|
|
234
|
+
*
|
|
235
|
+
* Scoped to tables whose key begins with ACC's own `plugin@marketplace:` - a
|
|
236
|
+
* table belonging to any other plugin is the client's business and stays. The
|
|
237
|
+
* file is rewritten only when something was found, so an install that never ran
|
|
238
|
+
* here leaves the config byte for byte as it was.
|
|
239
|
+
*/
|
|
240
|
+
export async function removeHookTrust(file, prefix) {
|
|
241
|
+
const before = await readFile(file, "utf8").catch(() => null);
|
|
242
|
+
if (before === null || !before.includes(`hooks.state."${prefix}`)) return false;
|
|
243
|
+
|
|
244
|
+
const lines = before.split("\n");
|
|
245
|
+
const kept = [];
|
|
246
|
+
let dropping = false;
|
|
247
|
+
for (const line of lines) {
|
|
248
|
+
const header = /^\s*\[([^\]]*)\]\s*$/.exec(line);
|
|
249
|
+
if (header !== null) dropping = header[1].startsWith(`hooks.state."${prefix}`);
|
|
250
|
+
if (dropping) continue;
|
|
251
|
+
kept.push(line);
|
|
252
|
+
}
|
|
253
|
+
// A table's trailing blank line goes with it rather than piling up.
|
|
254
|
+
const text = kept.join("\n").replace(/\n{3,}/g, "\n\n");
|
|
255
|
+
if (text === before) return false;
|
|
256
|
+
await writeFile(file, text, "utf8");
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
|
|
197
260
|
export async function uninstallCodexPlugin({ home, agentsHome = home,
|
|
198
261
|
codexHome = path.join(home, ".codex"), keep = [] }) {
|
|
199
262
|
const file = marketplacePath(agentsHome);
|
|
@@ -205,6 +268,15 @@ export async function uninstallCodexPlugin({ home, agentsHome = home,
|
|
|
205
268
|
await writeMarketplace(file, { ...existing, plugins: kept });
|
|
206
269
|
}
|
|
207
270
|
if (await removeTomlBlock(configPath(codexHome))) changes.push(configPath(codexHome));
|
|
271
|
+
// This client keeps its own record of which hook files it has trusted, one
|
|
272
|
+
// table per hook, keyed by the plugin that declared them. ACC never writes
|
|
273
|
+
// those - they are the client's bookkeeping about ACC - but once the plugin is
|
|
274
|
+
// gone they name a thing that does not exist, five of them per install, and
|
|
275
|
+
// nothing else will ever clear them. Verified inert first: a hook whose hash
|
|
276
|
+
// no longer matches still runs, so this is tidiness rather than repair.
|
|
277
|
+
if (await removeHookTrust(configPath(codexHome), `${PLUGIN_NAME}@${MARKETPLACE}:`)) {
|
|
278
|
+
if (!changes.includes(configPath(codexHome))) changes.push(configPath(codexHome));
|
|
279
|
+
}
|
|
208
280
|
// The marketplace directory is ACC's too, so it goes rather than being left
|
|
209
281
|
// behind empty.
|
|
210
282
|
// A blank TOML config and an absent one are the same to this client, and a
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { normalizedEvent } from "@agents-can-communicate/adapter-sdk";
|
|
1
|
+
import { normalizedEvent, shellWriteTargets } from "@agents-can-communicate/adapter-sdk";
|
|
2
2
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
3
3
|
|
|
4
4
|
// All eight observed in a live 0.37.0 configuration; the six that ACC uses were
|
|
@@ -59,10 +59,11 @@ export function normalizeGeminiHook(payload) {
|
|
|
59
59
|
* Both editing tools take `file_path`, confirmed from a capture. The file's
|
|
60
60
|
* contents are not read: they are conversation content.
|
|
61
61
|
*
|
|
62
|
-
* `run_shell_command` declares
|
|
63
|
-
*
|
|
62
|
+
* `run_shell_command` declares no path, so its command is read for the positions
|
|
63
|
+
* where a write is unambiguous, and for nothing else.
|
|
64
64
|
*/
|
|
65
65
|
function writeTargets(tool, input) {
|
|
66
|
+
if (tool === "run_shell_command") return shellWriteTargets(input?.command);
|
|
66
67
|
if (!GEMINI_EDIT_TOOLS.includes(tool)) return [];
|
|
67
68
|
const target = input?.file_path;
|
|
68
69
|
return typeof target === "string" && target !== "" ? [target] : [];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agents-can-communicate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Coordinate this Kimi Code session with other AI agent sessions working in the same workspace: shared presence, resource claims, typed messages, and handoffs.",
|
|
5
5
|
"skills": "./skills/",
|
|
6
6
|
"sessionStart": {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { normalizedEvent } from "@agents-can-communicate/adapter-sdk";
|
|
1
|
+
import { normalizedEvent, shellWriteTargets } from "@agents-can-communicate/adapter-sdk";
|
|
2
2
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
3
3
|
|
|
4
4
|
// Not read from documentation and not guessed: this client validates its config
|
|
@@ -73,11 +73,12 @@ export function normalizeKimiHook(payload) {
|
|
|
73
73
|
* `old_string` and `new_string`. Nothing else is read: the contents and the
|
|
74
74
|
* replacement strings are conversation content and stay out.
|
|
75
75
|
*
|
|
76
|
-
* `Bash` declares
|
|
77
|
-
*
|
|
78
|
-
*
|
|
76
|
+
* `Bash` declares no path of its own, so the command is read for the positions
|
|
77
|
+
* where a write is unambiguous. Reading positions are left alone: naming a file
|
|
78
|
+
* is not touching it.
|
|
79
79
|
*/
|
|
80
80
|
function writeTargets(tool, input) {
|
|
81
|
+
if (tool === "Bash") return shellWriteTargets(input?.command);
|
|
81
82
|
if (!KIMI_EDIT_TOOLS.includes(tool)) return [];
|
|
82
83
|
const path = input?.path;
|
|
83
84
|
return typeof path === "string" && path !== "" ? [path] : [];
|
|
@@ -85,11 +85,12 @@ function claimNote(claim) {
|
|
|
85
85
|
if (claim.enforceable === false) {
|
|
86
86
|
return " - not enforced for this session; do not edit it";
|
|
87
87
|
}
|
|
88
|
-
// Guarded, and this session can be stopped -
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
// claimed" would reasonably
|
|
92
|
-
|
|
88
|
+
// Guarded, and this session can be stopped - on a file edit, and on the shell
|
|
89
|
+
// writes the guard can read: a redirection, an operand of a command whose job
|
|
90
|
+
// is to put bytes somewhere. A language runtime opening the file itself still
|
|
91
|
+
// gets past, and a session told merely "this is claimed" would reasonably
|
|
92
|
+
// assume either more or less than is true.
|
|
93
|
+
return " - file edits and recognised shell writes are blocked; a runtime can still get past";
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
function claimLines(claims) {
|
|
@@ -8,6 +8,7 @@ export { assertRunner, bakeSkillCommand, defaultCli, defaultRunner, removeInstal
|
|
|
8
8
|
export { BEGIN, END, removeTomlBlock, renderBlock, stripBlock, tomlString, writeTomlBlock }
|
|
9
9
|
from "./toml-block.mjs";
|
|
10
10
|
export { projectContext } from "./context-projector.mjs";
|
|
11
|
+
export { shellWriteTargets } from "./shell-writes.mjs";
|
|
11
12
|
export { editJson, readJson } from "./json-text.mjs";
|
|
12
13
|
export { formatJsonAs, jsonStyleOf, mergeOwnedConfig, mergeOwnedEntries, ownedEntries, ownedKeys,
|
|
13
14
|
acccreatedFile, removeIfEmpty, removeOwnedConfig, removeOwnedEntries, writeForeignJson,
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a shell command would write.
|
|
3
|
+
*
|
|
4
|
+
* The guard used to declare no targets for a shell call at all, on the reasoning
|
|
5
|
+
* that a command can write anywhere and a guessed path is wrong in both
|
|
6
|
+
* directions. That reasoning held until agents started being told to prefer the
|
|
7
|
+
* shell for file edits: a claim that any `printf >> file` walks through is not a
|
|
8
|
+
* claim. The answer is not to guess. It is to read the positions where a write
|
|
9
|
+
* is unambiguous - a redirection, the operand of a command whose whole job is to
|
|
10
|
+
* put bytes somewhere - and to stay silent everywhere else.
|
|
11
|
+
*
|
|
12
|
+
* Two properties matter more than coverage:
|
|
13
|
+
*
|
|
14
|
+
* - A read is never reported. `cat file` and `grep file` name a path in an
|
|
15
|
+
* argument position that writes nothing; treating those as writes would have
|
|
16
|
+
* sessions blocking each other for looking.
|
|
17
|
+
* - It never throws. This runs on the hook path inside a budget that fails
|
|
18
|
+
* open, so an unparseable command yields no targets rather than an error.
|
|
19
|
+
*
|
|
20
|
+
* What it does not see is stated where a person reads it (`context-projector`):
|
|
21
|
+
* a language runtime opening a file, an `eval`, a command built at runtime. A
|
|
22
|
+
* shell can still evade this. Partial sight is not the same as none - today
|
|
23
|
+
* every one of these walks through - and an agent told where the guard ends
|
|
24
|
+
* behaves better than one who believes it absolute.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const SEPARATORS = new Set([";", "&&", "||", "|", "|&", "&", "\n"]);
|
|
28
|
+
|
|
29
|
+
// Redirections that write. `<` and `<<` read; `>&`/`<&` duplicate a descriptor
|
|
30
|
+
// and name no file.
|
|
31
|
+
const WRITE_REDIRECTION = /^(?:[0-9]*|&)(?:>>|>\|?)$/;
|
|
32
|
+
|
|
33
|
+
// Sinks that discard. Naming one is not touching a file anybody claims.
|
|
34
|
+
const NOT_A_FILE = new Set(["/dev/null", "/dev/stdout", "/dev/stderr", "/dev/tty"]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* How each command spends its operands.
|
|
38
|
+
*
|
|
39
|
+
* `pick` decides which operands are written: every one, only the last (a
|
|
40
|
+
* destination), or every one but the first (a script that is not a file).
|
|
41
|
+
* `valueFlags` are the flags whose following token is a value rather than a
|
|
42
|
+
* path - without them, `truncate -s 0 file` would report a file named `0`.
|
|
43
|
+
*/
|
|
44
|
+
const WRITERS = new Map(Object.entries({
|
|
45
|
+
tee: { pick: "all" },
|
|
46
|
+
touch: { pick: "all", valueFlags: new Set(["-d", "-r", "-t", "--date", "--reference"]) },
|
|
47
|
+
truncate: { pick: "all", valueFlags: new Set(["-s", "--size", "-r", "--reference"]) },
|
|
48
|
+
rm: { pick: "all" },
|
|
49
|
+
unlink: { pick: "all" },
|
|
50
|
+
shred: { pick: "all", valueFlags: new Set(["-n", "--iterations", "-s", "--size"]) },
|
|
51
|
+
mkdir: { pick: "all", valueFlags: new Set(["-m", "--mode"]) },
|
|
52
|
+
rmdir: { pick: "all" },
|
|
53
|
+
cp: { pick: "last", valueFlags: new Set(["-t", "--target-directory", "-S", "--suffix"]) },
|
|
54
|
+
install: { pick: "last", valueFlags: new Set(["-t", "--target-directory", "-m", "-o", "-g"]) },
|
|
55
|
+
rsync: { pick: "last", valueFlags: new Set(["-e", "--rsh", "--exclude"]) },
|
|
56
|
+
ln: { pick: "last", valueFlags: new Set(["-t", "--target-directory", "-S", "--suffix"]) },
|
|
57
|
+
// A move writes the destination and empties the source. Both are the peer's
|
|
58
|
+
// business, so both are reported.
|
|
59
|
+
mv: { pick: "all", valueFlags: new Set(["-t", "--target-directory", "-S", "--suffix"]) },
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
// In-place editors: a write only when the in-place flag is present, and their
|
|
63
|
+
// script operand is never a file.
|
|
64
|
+
const IN_PLACE = new Map(Object.entries({
|
|
65
|
+
sed: { valueFlags: new Set(["-e", "--expression", "-f", "--file", "-l", "-E"]) },
|
|
66
|
+
perl: { valueFlags: new Set(["-e", "-E", "-M", "-m"]) },
|
|
67
|
+
ruby: { valueFlags: new Set(["-e", "-r"]) },
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Split a command line into tokens, keeping quoted text out of the grammar.
|
|
72
|
+
*
|
|
73
|
+
* Returns null on anything it cannot read - an unterminated quote, most often -
|
|
74
|
+
* which the caller turns into "no targets" rather than a guess.
|
|
75
|
+
*/
|
|
76
|
+
function tokenize(command) {
|
|
77
|
+
const tokens = [];
|
|
78
|
+
let text = "";
|
|
79
|
+
let started = false;
|
|
80
|
+
let quoted = false;
|
|
81
|
+
|
|
82
|
+
const flush = () => {
|
|
83
|
+
if (started) tokens.push({ text, quoted });
|
|
84
|
+
text = "";
|
|
85
|
+
started = false;
|
|
86
|
+
quoted = false;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
90
|
+
const character = command[index];
|
|
91
|
+
|
|
92
|
+
if (character === "\\") {
|
|
93
|
+
const next = command[index + 1];
|
|
94
|
+
if (next === undefined) return null;
|
|
95
|
+
text += next;
|
|
96
|
+
started = true;
|
|
97
|
+
quoted = true;
|
|
98
|
+
index += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (character === "'" || character === '"') {
|
|
103
|
+
const close = command.indexOf(character, index + 1);
|
|
104
|
+
if (close === -1) return null;
|
|
105
|
+
text += command.slice(index + 1, close);
|
|
106
|
+
started = true;
|
|
107
|
+
quoted = true;
|
|
108
|
+
index = close;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (character === " " || character === "\t") {
|
|
113
|
+
flush();
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (character === "\n") {
|
|
118
|
+
flush();
|
|
119
|
+
tokens.push({ operator: "\n" });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (character === ";" || character === "&" || character === "|" || character === ">"
|
|
124
|
+
|| character === "<") {
|
|
125
|
+
// A redirection may carry its descriptor on the front (`2>`), which is
|
|
126
|
+
// already sitting in `text` unquoted.
|
|
127
|
+
const prefix = !quoted && /^[0-9]+$/.test(text) ? text : null;
|
|
128
|
+
if (prefix === null) flush();
|
|
129
|
+
else { text = ""; started = false; }
|
|
130
|
+
|
|
131
|
+
let operator = character;
|
|
132
|
+
while (index + 1 < command.length && "&|<>".includes(command[index + 1])) {
|
|
133
|
+
operator += command[index + 1];
|
|
134
|
+
index += 1;
|
|
135
|
+
}
|
|
136
|
+
tokens.push({ operator: prefix === null ? operator : prefix + operator });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
text += character;
|
|
141
|
+
started = true;
|
|
142
|
+
}
|
|
143
|
+
flush();
|
|
144
|
+
return tokens;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Break a token list at the separators, so every simple command is read. */
|
|
148
|
+
function simpleCommands(tokens) {
|
|
149
|
+
const commands = [[]];
|
|
150
|
+
for (const token of tokens) {
|
|
151
|
+
if (token.operator !== undefined && SEPARATORS.has(token.operator)) commands.push([]);
|
|
152
|
+
else commands.at(-1).push(token);
|
|
153
|
+
}
|
|
154
|
+
return commands.filter(command => command.length > 0);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Drop heredoc bodies.
|
|
159
|
+
*
|
|
160
|
+
* The body is content the shell never parses, and it is exactly where a `>` or
|
|
161
|
+
* an `rm` is most likely to appear innocently. Reading it would invent targets
|
|
162
|
+
* out of a file's own text.
|
|
163
|
+
*/
|
|
164
|
+
function stripHeredocs(command) {
|
|
165
|
+
const pattern = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/g;
|
|
166
|
+
let result = command;
|
|
167
|
+
for (;;) {
|
|
168
|
+
pattern.lastIndex = 0;
|
|
169
|
+
const opener = pattern.exec(result);
|
|
170
|
+
if (opener === null) return result;
|
|
171
|
+
const lineEnd = result.indexOf("\n", opener.index);
|
|
172
|
+
if (lineEnd === -1) return result.slice(0, opener.index) + result.slice(opener.index).replace(pattern, "");
|
|
173
|
+
const terminator = new RegExp(`^\\s*${opener[2]}\\s*$`, "m");
|
|
174
|
+
const rest = result.slice(lineEnd + 1);
|
|
175
|
+
const end = terminator.exec(rest);
|
|
176
|
+
const body = end === null ? rest.length : end.index + end[0].length;
|
|
177
|
+
result = result.slice(0, opener.index) + result.slice(opener.index, lineEnd).replace(pattern, "")
|
|
178
|
+
+ "\n" + rest.slice(body);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The word a redirection writes to, or null when it names no file. */
|
|
183
|
+
function redirectionTarget(tokens, index) {
|
|
184
|
+
const next = tokens[index + 1];
|
|
185
|
+
if (next === undefined || next.operator !== undefined) return null;
|
|
186
|
+
if (next.text.startsWith("&")) return null;
|
|
187
|
+
return NOT_A_FILE.has(next.text) ? null : next.text;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Operands of a simple command, with flags and their values removed. */
|
|
191
|
+
function operandsOf(words, valueFlags = new Set()) {
|
|
192
|
+
const operands = [];
|
|
193
|
+
for (let index = 1; index < words.length; index += 1) {
|
|
194
|
+
const word = words[index];
|
|
195
|
+
if (!word.quoted && word.text.startsWith("-") && word.text !== "-") {
|
|
196
|
+
if (valueFlags.has(word.text)) index += 1;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
operands.push(word.text);
|
|
200
|
+
}
|
|
201
|
+
return operands;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Strip the leading environment assignments and wrappers off a command. */
|
|
205
|
+
function commandWords(words) {
|
|
206
|
+
let start = 0;
|
|
207
|
+
while (start < words.length) {
|
|
208
|
+
const word = words[start];
|
|
209
|
+
if (word.operator !== undefined) return null;
|
|
210
|
+
if (!word.quoted && /^[A-Za-z_][A-Za-z0-9_]*=/.test(word.text)) { start += 1; continue; }
|
|
211
|
+
if (["sudo", "command", "nohup", "time", "nice"].includes(word.text)) { start += 1; continue; }
|
|
212
|
+
if (word.text === "env") { start += 1; continue; }
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
return start >= words.length ? null : words.slice(start);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Targets a `dd` call writes, which it names as `of=`. */
|
|
219
|
+
function ddTargets(words) {
|
|
220
|
+
return words.slice(1)
|
|
221
|
+
.filter(word => word.text.startsWith("of="))
|
|
222
|
+
.map(word => word.text.slice(3))
|
|
223
|
+
.filter(target => target !== "" && !NOT_A_FILE.has(target));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Targets a `git` call writes into the working tree.
|
|
228
|
+
*
|
|
229
|
+
* Only the two that overwrite a file a peer may be holding. `git log -- path`
|
|
230
|
+
* and `git diff path` name the same path and touch nothing, which is why the
|
|
231
|
+
* subcommand is read before the operands.
|
|
232
|
+
*/
|
|
233
|
+
function gitTargets(words) {
|
|
234
|
+
const subcommand = words[1]?.text;
|
|
235
|
+
if (subcommand === "restore") {
|
|
236
|
+
return operandsOf(words.slice(1), new Set(["--source", "-s"]));
|
|
237
|
+
}
|
|
238
|
+
if (subcommand === "checkout") {
|
|
239
|
+
const separator = words.findIndex(word => word.text === "--" && !word.quoted);
|
|
240
|
+
if (separator === -1) return [];
|
|
241
|
+
return words.slice(separator + 1).map(word => word.text);
|
|
242
|
+
}
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Targets of an in-place editor, whose script operand is not a file. */
|
|
247
|
+
function inPlaceTargets(words, spec) {
|
|
248
|
+
const flags = words.slice(1).filter(word => !word.quoted && word.text.startsWith("-"));
|
|
249
|
+
const inPlace = flags.some(word => word.text === "-i" || word.text.startsWith("-i")
|
|
250
|
+
|| word.text === "--in-place" || word.text.startsWith("--in-place="));
|
|
251
|
+
if (!inPlace) return [];
|
|
252
|
+
|
|
253
|
+
const valueFlags = new Set(spec.valueFlags);
|
|
254
|
+
const operands = [];
|
|
255
|
+
for (let index = 1; index < words.length; index += 1) {
|
|
256
|
+
const word = words[index];
|
|
257
|
+
if (!word.quoted && word.text.startsWith("-") && word.text !== "-") {
|
|
258
|
+
// `-i ''` on BSD takes its suffix as a separate word; a cluster carrying
|
|
259
|
+
// `e` takes the script that follows it.
|
|
260
|
+
if (valueFlags.has(word.text)) index += 1;
|
|
261
|
+
else if (/^-[A-Za-z]*[eEf]$/.test(word.text)) index += 1;
|
|
262
|
+
else if (word.text === "-i" && words[index + 1]?.quoted
|
|
263
|
+
&& (words[index + 1].text === "" || words[index + 1].text.startsWith("."))) index += 1;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
operands.push(word.text);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const scriptGiven = flags.some(word => /^-[A-Za-z]*[eEf]$/.test(word.text)
|
|
270
|
+
|| word.text.startsWith("--expression") || word.text.startsWith("--file"));
|
|
271
|
+
return scriptGiven ? operands : operands.slice(1);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Paths a shell command would write.
|
|
276
|
+
*
|
|
277
|
+
* Order is the order they appear; a path named twice is reported once. Anything
|
|
278
|
+
* unreadable yields an empty list.
|
|
279
|
+
*/
|
|
280
|
+
export function shellWriteTargets(command) {
|
|
281
|
+
if (typeof command !== "string" || command.trim() === "") return [];
|
|
282
|
+
|
|
283
|
+
let targets = [];
|
|
284
|
+
try {
|
|
285
|
+
const tokens = tokenize(stripHeredocs(command));
|
|
286
|
+
if (tokens === null) return [];
|
|
287
|
+
|
|
288
|
+
for (const simple of simpleCommands(tokens)) {
|
|
289
|
+
for (let index = 0; index < simple.length; index += 1) {
|
|
290
|
+
const token = simple[index];
|
|
291
|
+
if (token.operator === undefined || !WRITE_REDIRECTION.test(token.operator)) continue;
|
|
292
|
+
const target = redirectionTarget(simple, index);
|
|
293
|
+
if (target !== null) targets.push(target);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const words = commandWords(simple.filter(token => token.operator === undefined));
|
|
297
|
+
if (words === null || words.length === 0) continue;
|
|
298
|
+
const name = words[0].text.split("/").at(-1);
|
|
299
|
+
|
|
300
|
+
if (name === "dd") { targets.push(...ddTargets(words)); continue; }
|
|
301
|
+
if (name === "git") { targets.push(...gitTargets(words)); continue; }
|
|
302
|
+
|
|
303
|
+
const inPlace = IN_PLACE.get(name);
|
|
304
|
+
if (inPlace !== undefined) { targets.push(...inPlaceTargets(words, inPlace)); continue; }
|
|
305
|
+
|
|
306
|
+
const writer = WRITERS.get(name);
|
|
307
|
+
if (writer === undefined) continue;
|
|
308
|
+
const operands = operandsOf(words, writer.valueFlags);
|
|
309
|
+
if (operands.length === 0) continue;
|
|
310
|
+
targets.push(...(writer.pick === "last" ? operands.slice(-1) : operands));
|
|
311
|
+
}
|
|
312
|
+
} catch {
|
|
313
|
+
// The hook path fails open. A command this cannot read is a command it
|
|
314
|
+
// reports nothing about.
|
|
315
|
+
return [];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return [...new Set(targets.filter(target => target !== "" && !NOT_A_FILE.has(target)))];
|
|
319
|
+
}
|
|
@@ -17,8 +17,12 @@ export const COMMANDS = Object.freeze({
|
|
|
17
17
|
"state", "workstream"], repeated: ["hint"], flags: ["clear"] },
|
|
18
18
|
claim: { required: ["resource"],
|
|
19
19
|
optional: ["session", "generation", "mode", "enforcement", "reason", "lease"] },
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
// Neither is required on its own, because either one names the claim: an id
|
|
21
|
+
// is precise, and a resource is what the caller typed to take it in the first
|
|
22
|
+
// place. Requiring the id meant a round trip through `acc status --json` to
|
|
23
|
+
// look up something the caller never chose.
|
|
24
|
+
release: { required: [],
|
|
25
|
+
optional: ["claim", "resource", "session", "generation", "authority", "reason"] },
|
|
22
26
|
message: { required: ["subject", "body"],
|
|
23
27
|
optional: ["session", "generation", "type", "priority", "workstream"],
|
|
24
28
|
repeated: ["to"], flags: ["requires-ack"] },
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
3
4
|
|
|
4
5
|
import { detectInstallation, loadOwnership, verifyOwned }
|
|
5
6
|
from "@agents-can-communicate/installer";
|
|
6
7
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
7
8
|
|
|
8
9
|
import { ALL_ADAPTERS, clientContext, probeTimeout } from "./install-command.mjs";
|
|
10
|
+
import { describePresence } from "./main.mjs";
|
|
9
11
|
import { platformPaths } from "./platform-paths.mjs";
|
|
10
12
|
import { noticeUpdate } from "./update-check.mjs";
|
|
11
13
|
import { diagnoseFilesystemStore, repairFilesystemStore }
|
|
@@ -37,9 +39,9 @@ async function diagnoseAdapters({ options, runtime }) {
|
|
|
37
39
|
// The same home `acc install --home` writes to, or the real one. Reading a
|
|
38
40
|
// different home than install wrote to reports every adapter as missing.
|
|
39
41
|
const home = options?.home ?? runtime?.env?.HOME ?? homedir();
|
|
40
|
-
const clients = clientContext(home);
|
|
41
42
|
const { data: dataHome } = platformPaths({ platform: runtime?.platform,
|
|
42
43
|
env: runtime?.env ?? {} });
|
|
44
|
+
const clients = clientContext(home, path.join(dataHome, "acc"));
|
|
43
45
|
const adapters = ALL_ADAPTERS();
|
|
44
46
|
const detected = await detectInstallation({ adapters, context: clients,
|
|
45
47
|
probeTimeoutMs: probeTimeout(runtime?.env) });
|
|
@@ -129,7 +131,7 @@ export async function runDoctor({ options, context, runtime }) {
|
|
|
129
131
|
// command documented as saying "what to run next" said it only to `--json`.
|
|
130
132
|
// A person running `acc doctor` on a client wired to an older plugin was told
|
|
131
133
|
// the store was healthy and nothing else.
|
|
132
|
-
const text = [`store healthy; ${status.counts
|
|
134
|
+
const text = [`store healthy; ${describePresence(status.counts)}; `
|
|
133
135
|
+ `protection ${status.protection}; ${installed} of ${adapters.length} adapter(s) installed`,
|
|
134
136
|
...data.remediation.map(line => ` ${line}`)].join("\n");
|
|
135
137
|
return { data, text };
|
|
@@ -17,12 +17,18 @@ import { platformPaths } from "./platform-paths.mjs";
|
|
|
17
17
|
// Each client keeps its own directory under the user's home, and an adapter
|
|
18
18
|
// pointed at the home itself writes beside them rather than inside them. That
|
|
19
19
|
// install reports success and the client never reads a byte of it.
|
|
20
|
-
export const clientContext = home => ({
|
|
20
|
+
export const clientContext = (home, stateRoot) => ({
|
|
21
21
|
home,
|
|
22
22
|
configDir: path.join(home, ".claude"),
|
|
23
23
|
agentsHome: home,
|
|
24
24
|
codexHome: path.join(home, ".codex"),
|
|
25
25
|
kimiHome: path.join(home, ".kimi-code"),
|
|
26
|
+
// Where ACC keeps its own state, for the client that has to be told. Codex
|
|
27
|
+
// sandboxes the commands a model runs to the workspace, and ACC's state is
|
|
28
|
+
// outside every workspace on purpose - so an agent there could read the
|
|
29
|
+
// roster and record nothing, every write failing with EPERM on the writer
|
|
30
|
+
// lock. Measured with `codex exec`, which is how an agent actually runs.
|
|
31
|
+
stateRoot,
|
|
26
32
|
});
|
|
27
33
|
|
|
28
34
|
export const ALL_ADAPTERS = () => [createClaudeCodeAdapter(), createCodexAdapter(),
|
|
@@ -153,9 +159,9 @@ export function actedOn(result) {
|
|
|
153
159
|
export async function runInstallCommand({ options, runtime, action = "install" }) {
|
|
154
160
|
const adapters = selectAdapters(options.adapter);
|
|
155
161
|
const home = options.home ?? runtime.env?.HOME ?? homedir();
|
|
156
|
-
const context = clientContext(home);
|
|
157
162
|
const { data: dataHome } = platformPaths({ platform: runtime.platform,
|
|
158
163
|
env: runtime.env ?? {} });
|
|
164
|
+
const context = clientContext(home, path.join(dataHome, "acc"));
|
|
159
165
|
|
|
160
166
|
const detected = await detectInstallation({ adapters, context,
|
|
161
167
|
probeTimeoutMs: probeTimeout(runtime.env) });
|
|
@@ -21,6 +21,39 @@ import { discoverWorkspace } from "./workspace-discovery.mjs";
|
|
|
21
21
|
|
|
22
22
|
const DEFAULT_CADENCE_MS = 30_000;
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The claim a caller means when they name a resource instead of an id.
|
|
26
|
+
*
|
|
27
|
+
* `acc claim` takes `--resource`; `acc release` took `--claim`. The asymmetry
|
|
28
|
+
* cost every caller - a person and an agent alike - a round trip through
|
|
29
|
+
* `acc status --json` to find an id they never chose. The id is still accepted,
|
|
30
|
+
* and is still the precise answer when two sessions hold the same resource and
|
|
31
|
+
* an authority is releasing someone else's.
|
|
32
|
+
*/
|
|
33
|
+
async function claimOn(options, context) {
|
|
34
|
+
if (options.resource === undefined) {
|
|
35
|
+
throw new AccError(EXIT.USAGE,
|
|
36
|
+
"release needs the claim to give back: --resource is what you claimed, "
|
|
37
|
+
+ "--claim is its id");
|
|
38
|
+
}
|
|
39
|
+
const resource = await canonicalClaim(options.resource, context.descriptor);
|
|
40
|
+
const { claims } = await context.service.collectStatus({});
|
|
41
|
+
// An authority releasing another session's claim is not looking for its own.
|
|
42
|
+
const mine = claims.filter(claim => claim.resource === resource
|
|
43
|
+
&& (options.authority !== undefined || claim.ownerSessionId === options.session));
|
|
44
|
+
|
|
45
|
+
if (mine.length === 0) {
|
|
46
|
+
throw new AccError(EXIT.DATA, `no claim on ${resource} to release`,
|
|
47
|
+
{ resource, held: claims.map(claim => claim.resource) });
|
|
48
|
+
}
|
|
49
|
+
if (mine.length > 1) {
|
|
50
|
+
throw new AccError(EXIT.DATA,
|
|
51
|
+
`${mine.length} claims on ${resource}; name the one you mean with --claim`,
|
|
52
|
+
{ resource, claims: mine.map(claim => claim.claimId) });
|
|
53
|
+
}
|
|
54
|
+
return mine[0].claimId;
|
|
55
|
+
}
|
|
56
|
+
|
|
24
57
|
async function openContext(options, runtime) {
|
|
25
58
|
const descriptor = await discoverWorkspace({
|
|
26
59
|
cwd: options.cwd ?? runtime.cwd,
|
|
@@ -42,6 +75,14 @@ async function openContext(options, runtime) {
|
|
|
42
75
|
|
|
43
76
|
const human = value => (typeof value === "string" ? value : JSON.stringify(value, null, 2));
|
|
44
77
|
|
|
78
|
+
/** How many are here, and how many only look it. */
|
|
79
|
+
export function describePresence({ live = 0, stale = 0 } = {}) {
|
|
80
|
+
if (live === 0) return "0 live";
|
|
81
|
+
if (stale === 0) return `${live} live`;
|
|
82
|
+
if (stale === live) return `${live} present, none answering`;
|
|
83
|
+
return `${live} live (${stale} not answering)`;
|
|
84
|
+
}
|
|
85
|
+
|
|
45
86
|
const HANDLERS = Object.freeze({
|
|
46
87
|
attach: async ({ options, context }) => {
|
|
47
88
|
const session = await context.service.openSession({
|
|
@@ -112,11 +153,12 @@ const HANDLERS = Object.freeze({
|
|
|
112
153
|
},
|
|
113
154
|
|
|
114
155
|
release: async ({ options, context }) => {
|
|
115
|
-
const
|
|
156
|
+
const claimId = options.claim ?? await claimOn(options, context);
|
|
157
|
+
const request = { claimId, sessionId: options.session,
|
|
116
158
|
generation: options.generation, authority: options.authority, reason: options.reason };
|
|
117
159
|
if (options.authority === undefined) await context.service.releaseClaim(request);
|
|
118
160
|
else await context.service.forceReleaseClaim(request);
|
|
119
|
-
return { data: { claimId
|
|
161
|
+
return { data: { claimId }, text: `released ${claimId}` };
|
|
120
162
|
},
|
|
121
163
|
|
|
122
164
|
message: async ({ options, context }) => {
|
|
@@ -241,7 +283,12 @@ const HANDLERS = Object.freeze({
|
|
|
241
283
|
status: async ({ options, context }) => {
|
|
242
284
|
const status = await context.service.collectStatus({
|
|
243
285
|
participantId: options.participant, all: options.all === true });
|
|
244
|
-
|
|
286
|
+
// `live` counts everyone present, which includes sessions gone stale - a
|
|
287
|
+
// client that exited without its session being closed keeps a record that
|
|
288
|
+
// stops being answered but does not disappear. Printing the number alone
|
|
289
|
+
// said "1 live" about a workspace where the last agent had left minutes
|
|
290
|
+
// before, which is the one thing a person reads this line to find out.
|
|
291
|
+
const text = `${describePresence(status.counts)}; ${status.counts.claims} claim(s); `
|
|
245
292
|
+ `protection ${status.protection}`;
|
|
246
293
|
return { data: status, text };
|
|
247
294
|
},
|