agents-can-communicate 0.1.6 → 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.
Files changed (25) hide show
  1. package/docs/CAPABILITIES.md +18 -7
  2. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  3. package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +8 -3
  4. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  5. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +6 -4
  6. package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +22 -3
  7. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +37 -0
  8. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  9. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/hooks.mjs +4 -3
  10. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  11. package/node_modules/@agents-can-communicate/adapter-kimi/src/hooks.mjs +5 -4
  12. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  13. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +6 -5
  14. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -0
  15. package/node_modules/@agents-can-communicate/adapter-sdk/src/shell-writes.mjs +319 -0
  16. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  17. package/node_modules/@agents-can-communicate/cli/src/args.mjs +6 -2
  18. package/node_modules/@agents-can-communicate/cli/src/main.mjs +36 -2
  19. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  20. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  21. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  22. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  23. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  24. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  25. package/package.json +1 -1
@@ -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 never resource-aware.** A shell command can write anywhere, and
64
- ACC does not parse commands. The guard fires and is allowed through, because guessing a
65
- path out of a command would block work at random and still miss real writes. Shell calls
66
- therefore declare no targets on every adapter.
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; edits made through a shell are not` |
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, one Kimi session, or one Codex session on a shell-editing model, and a guarded
158
- claim is advice - so the workspace says `advisory`, whatever its claims were declared as.
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-claude-code",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 nothing: a command can write anywhere, and a path guessed out
70
- * of one gives a guard that is wrong in both directions.
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/adapter-codex",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 - and a command names no resource, so a write guard
66
- // has nothing to match. Verified on 0.147.0.
67
- "write guards apply only to models that offer apply_patch; with the rest, "
68
- + "edits run through the shell and cannot be matched to a claim",
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: patchTargets(tool, payload?.tool_input),
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
  *
@@ -229,6 +229,34 @@ async function removeEmptyDirs(directories) {
229
229
  }
230
230
  }
231
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
+
232
260
  export async function uninstallCodexPlugin({ home, agentsHome = home,
233
261
  codexHome = path.join(home, ".codex"), keep = [] }) {
234
262
  const file = marketplacePath(agentsHome);
@@ -240,6 +268,15 @@ export async function uninstallCodexPlugin({ home, agentsHome = home,
240
268
  await writeMarketplace(file, { ...existing, plugins: kept });
241
269
  }
242
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
+ }
243
280
  // The marketplace directory is ACC's too, so it goes rather than being left
244
281
  // behind empty.
245
282
  // A blank TOML config and an absent one are the same to this client, and a
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-gemini-cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 nothing. A command can write anywhere, and a path
63
- * guessed out of one gives a guard that is wrong in both directions.
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/adapter-kimi",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 nothing. A command can write anywhere, and a path guessed out
77
- * of one would give a guard that blocks work it holds no claim over while
78
- * missing writes it does.
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] : [];
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-sdk",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 - but only on a file edit. No
89
- // harness intercepts a shell command, so an edit made through one goes
90
- // through whatever the claim says, and a session told merely "this is
91
- // claimed" would reasonably assume otherwise.
92
- return " - file edits are blocked; edits made through a shell are not";
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
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
- release: { required: ["claim"],
21
- optional: ["session", "generation", "authority", "reason"] },
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"] },
@@ -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,
@@ -120,11 +153,12 @@ const HANDLERS = Object.freeze({
120
153
  },
121
154
 
122
155
  release: async ({ options, context }) => {
123
- const request = { claimId: options.claim, sessionId: options.session,
156
+ const claimId = options.claim ?? await claimOn(options, context);
157
+ const request = { claimId, sessionId: options.session,
124
158
  generation: options.generation, authority: options.authority, reason: options.reason };
125
159
  if (options.authority === undefined) await context.service.releaseClaim(request);
126
160
  else await context.service.forceReleaseClaim(request);
127
- return { data: { claimId: options.claim }, text: `released ${options.claim}` };
161
+ return { data: { claimId }, text: `released ${claimId}` };
128
162
  },
129
163
 
130
164
  message: async ({ options, context }) => {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/installer",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": { ".": "./src/index.mjs" },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/mcp-server",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/protocol",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/storage-filesystem",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "description": "Local-first coordination for independently opened AI agent sessions.",
6
6
  "keywords": [