agents-can-communicate 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +176 -0
- package/bin/acc-hook.mjs +53 -0
- package/bin/acc-mcp.mjs +46 -0
- package/bin/acc.mjs +24 -0
- package/docs/CAPABILITIES.md +153 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.claude-plugin/plugin.json +5 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/hooks/hooks.json +56 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +64 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +106 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +278 -0
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +17 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/hooks.json +55 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +81 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +133 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +235 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +6 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/hooks/hooks.json +69 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +74 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/hooks.mjs +109 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +149 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/.kimi-plugin/plugin.json +9 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +73 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/hooks.mjs +125 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +216 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +101 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/config-merge.mjs +215 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +217 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/events.mjs +55 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +148 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +16 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +95 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-block.mjs +71 -0
- package/node_modules/@agents-can-communicate/cli/package.json +12 -0
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +163 -0
- package/node_modules/@agents-can-communicate/cli/src/claim-spelling.mjs +79 -0
- package/node_modules/@agents-can-communicate/cli/src/config-command.mjs +149 -0
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +85 -0
- package/node_modules/@agents-can-communicate/cli/src/git-probe.mjs +41 -0
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +78 -0
- package/node_modules/@agents-can-communicate/cli/src/index.mjs +10 -0
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +107 -0
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +344 -0
- package/node_modules/@agents-can-communicate/cli/src/platform-paths.mjs +109 -0
- package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +58 -0
- package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +126 -0
- package/node_modules/@agents-can-communicate/cli/src/workspace-discovery.mjs +151 -0
- package/node_modules/@agents-can-communicate/core/package.json +12 -0
- package/node_modules/@agents-can-communicate/core/src/claims.mjs +180 -0
- package/node_modules/@agents-can-communicate/core/src/communication.mjs +334 -0
- package/node_modules/@agents-can-communicate/core/src/index.mjs +7 -0
- package/node_modules/@agents-can-communicate/core/src/intents.mjs +75 -0
- package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +86 -0
- package/node_modules/@agents-can-communicate/core/src/notify.mjs +95 -0
- package/node_modules/@agents-can-communicate/core/src/participants.mjs +48 -0
- package/node_modules/@agents-can-communicate/core/src/ports.mjs +56 -0
- package/node_modules/@agents-can-communicate/core/src/service.mjs +44 -0
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +190 -0
- package/node_modules/@agents-can-communicate/core/src/status.mjs +132 -0
- package/node_modules/@agents-can-communicate/core/src/sync.mjs +273 -0
- package/node_modules/@agents-can-communicate/core/src/tasks.mjs +238 -0
- package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +109 -0
- package/node_modules/@agents-can-communicate/hook-runner/package.json +12 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +379 -0
- package/node_modules/@agents-can-communicate/installer/package.json +10 -0
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +58 -0
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +79 -0
- package/node_modules/@agents-can-communicate/installer/src/index.mjs +6 -0
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +162 -0
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +62 -0
- package/node_modules/@agents-can-communicate/mcp-server/package.json +12 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +58 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +250 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +201 -0
- package/node_modules/@agents-can-communicate/protocol/package.json +12 -0
- package/node_modules/@agents-can-communicate/protocol/src/config.mjs +151 -0
- package/node_modules/@agents-can-communicate/protocol/src/envelopes.mjs +25 -0
- package/node_modules/@agents-can-communicate/protocol/src/errors.mjs +30 -0
- package/node_modules/@agents-can-communicate/protocol/src/fields.mjs +103 -0
- package/node_modules/@agents-can-communicate/protocol/src/ids.mjs +25 -0
- package/node_modules/@agents-can-communicate/protocol/src/index.mjs +9 -0
- package/node_modules/@agents-can-communicate/protocol/src/resources.mjs +74 -0
- package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +175 -0
- package/node_modules/@agents-can-communicate/protocol/src/states.mjs +55 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +12 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +135 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +67 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +4 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +87 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/record-id.mjs +44 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +114 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +80 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +55 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +292 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +87 -0
- package/package.json +81 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { listSessionBindings } from "@agents-can-communicate/adapter-sdk";
|
|
2
|
+
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Work out which session is running this command.
|
|
6
|
+
*
|
|
7
|
+
* Every mutating command needs a session id and the generation that proves the
|
|
8
|
+
* caller is that session. Until this existed, both had to be passed on the
|
|
9
|
+
* command line, and the shipped skills told agents to pass
|
|
10
|
+
* `--session "$ACC_SESSION" --generation "$ACC_GENERATION"` - two variables that
|
|
11
|
+
* nothing in the system has ever set. The generation is not printed by `acc
|
|
12
|
+
* status` either, deliberately: it is the proof of ownership, not public
|
|
13
|
+
* information. So the documented workflow could not be carried out by any agent
|
|
14
|
+
* on any client, and the only way through was to read ACC's own files by hand,
|
|
15
|
+
* which the same skill forbids in as many words.
|
|
16
|
+
*
|
|
17
|
+
* The hook runtime already knows both. It writes them into a binding at
|
|
18
|
+
* SessionStart, keyed by the harness's own session id. What was missing was a
|
|
19
|
+
* way for a shell inside that session to find its own binding. Each step below
|
|
20
|
+
* answers that from something the caller demonstrably has.
|
|
21
|
+
*/
|
|
22
|
+
const NEEDS_OWNER = Object.freeze(new Set(["work", "claim", "release", "message",
|
|
23
|
+
"request", "ack", "workstream", "task", "finish", "decide"]));
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Reads that are answers about *you*.
|
|
27
|
+
*
|
|
28
|
+
* Attention is computed per session: what is addressed to you, what has become
|
|
29
|
+
* unblocked for you. Run by hand with nothing identifying the caller, both of
|
|
30
|
+
* these answered for nobody and returned an empty list - so an agent told at the
|
|
31
|
+
* top of its turn that work was waiting could ask `acc status` and be told there
|
|
32
|
+
* was none. They resolve softly: a shell with no session attached still gets the
|
|
33
|
+
* roster and the events, which is what it came for.
|
|
34
|
+
*/
|
|
35
|
+
const WANTS_OWNER = Object.freeze(new Set(["sync", "status"]));
|
|
36
|
+
|
|
37
|
+
export const needsOwner = command => NEEDS_OWNER.has(command);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Bindings whose session is still open, carrying the session's own checkout.
|
|
41
|
+
*
|
|
42
|
+
* A binding outlives a crash, so one that names a closed or vanished session is
|
|
43
|
+
* a leftover. Acting as a closed session would fail deeper in the service with a
|
|
44
|
+
* conflict; dropping it here means the *next* candidate can be recognised
|
|
45
|
+
* instead.
|
|
46
|
+
*/
|
|
47
|
+
async function liveCandidates({ context }) {
|
|
48
|
+
const bindings = await listSessionBindings({ runtimeDir: context.paths.root });
|
|
49
|
+
if (bindings.length === 0) return [];
|
|
50
|
+
const status = await context.service.collectStatus({});
|
|
51
|
+
const live = new Map(status.participants
|
|
52
|
+
.filter(participant => participant.presence !== "offline")
|
|
53
|
+
.map(participant => [participant.sessionId, participant]));
|
|
54
|
+
return bindings
|
|
55
|
+
.filter(binding => live.has(binding.accSessionId))
|
|
56
|
+
.map(binding => ({ ...binding, session: live.get(binding.accSessionId) }));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const describe = candidates => candidates
|
|
60
|
+
.map(candidate => `${candidate.session.participantId} (${candidate.session.harness})`)
|
|
61
|
+
.join(", ");
|
|
62
|
+
|
|
63
|
+
export async function resolveOwner({ command, options, context, env = {} }) {
|
|
64
|
+
const soft = WANTS_OWNER.has(command);
|
|
65
|
+
if (!soft && !needsOwner(command)) return options;
|
|
66
|
+
if (options.session !== undefined && options.generation !== undefined) return options;
|
|
67
|
+
if (soft && options.participant !== undefined) return options;
|
|
68
|
+
|
|
69
|
+
const candidates = await liveCandidates({ context });
|
|
70
|
+
const take = candidate => ({ ...options, session: candidate.accSessionId,
|
|
71
|
+
generation: candidate.generation,
|
|
72
|
+
// `status` filters by participant rather than by session, so it is told who
|
|
73
|
+
// the caller is in the terms it works in.
|
|
74
|
+
participant: options.participant ?? candidate.session.participantId });
|
|
75
|
+
|
|
76
|
+
// Named a session but not the generation: the id is in `acc status --json`,
|
|
77
|
+
// so this is what an agent reaches for first, and refusing it would teach the
|
|
78
|
+
// wrong lesson about which half is secret.
|
|
79
|
+
if (options.session !== undefined) {
|
|
80
|
+
// The reads never needed a generation, and a session opened by `acc attach`
|
|
81
|
+
// has no binding to find one in. Answering for the session it was given is
|
|
82
|
+
// both what it was asked and what it did before this resolver existed.
|
|
83
|
+
if (soft) return options;
|
|
84
|
+
const named = candidates.find(candidate => candidate.accSessionId === options.session);
|
|
85
|
+
if (named !== undefined) return take(named);
|
|
86
|
+
throw new AccError(EXIT.USAGE,
|
|
87
|
+
`no live session ${options.session} in this workspace`,
|
|
88
|
+
{ command, session: options.session });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The documented pair, honoured for anyone who has wired it up themselves.
|
|
92
|
+
if (typeof env.ACC_SESSION === "string" && typeof env.ACC_GENERATION === "string") {
|
|
93
|
+
return { ...options, session: env.ACC_SESSION, generation: env.ACC_GENERATION };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The client's own session id, under whatever name it exports it. Claude Code
|
|
97
|
+
// sets CLAUDE_CODE_SESSION_ID, measured equal to the `session_id` its hooks
|
|
98
|
+
// receive; matching on the value rather than the variable name means a client
|
|
99
|
+
// that names it something else works without ACC knowing the name.
|
|
100
|
+
const exported = new Set(Object.values(env).filter(value => typeof value === "string"));
|
|
101
|
+
const byEnvironment = candidates
|
|
102
|
+
.filter(candidate => exported.has(candidate.harnessSessionId));
|
|
103
|
+
if (byEnvironment.length === 1) return take(byEnvironment[0]);
|
|
104
|
+
|
|
105
|
+
// Two agents in one workspace are the normal case, and each is usually in its
|
|
106
|
+
// own checkout - that is what the worktree story is. Same repository, so the
|
|
107
|
+
// same workspace, and the checkout tells them apart.
|
|
108
|
+
const here = context.descriptor.git?.worktreeRoot;
|
|
109
|
+
const byCheckout = here === undefined
|
|
110
|
+
? []
|
|
111
|
+
: candidates.filter(candidate => candidate.session.checkoutRoot === here);
|
|
112
|
+
if (byCheckout.length === 1) return take(byCheckout[0]);
|
|
113
|
+
|
|
114
|
+
if (candidates.length === 1) return take(candidates[0]);
|
|
115
|
+
|
|
116
|
+
// Guessing between two live sessions would let one agent act as another, which
|
|
117
|
+
// is exactly what the generation exists to prevent. So this refuses, and says
|
|
118
|
+
// what it found.
|
|
119
|
+
if (soft) return options;
|
|
120
|
+
throw new AccError(EXIT.USAGE, candidates.length === 0
|
|
121
|
+
? `${command} could not tell which session is running it: no live session is `
|
|
122
|
+
+ "attached here. An adapter attaches one at SessionStart; `acc status` shows who is."
|
|
123
|
+
: `${command} could not tell which of ${candidates.length} live sessions is running `
|
|
124
|
+
+ `it (${describe(candidates)}). Pass --session, which \`acc status --json\` lists.`,
|
|
125
|
+
{ command, candidates: candidates.map(candidate => candidate.accSessionId) });
|
|
126
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { open, realpath } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { AccError, CONFIG_FILENAME, EXIT, validateProjectConfig }
|
|
7
|
+
from "@agents-can-communicate/protocol";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {{ id: string, roots: string[], source: "config" | "git" | "directory",
|
|
11
|
+
* displayName: string, git?: object }} WorkspaceDescriptor
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const stableId = value =>
|
|
15
|
+
`workspace_${createHash("sha256").update(value).digest("hex").slice(0, 32)}`;
|
|
16
|
+
|
|
17
|
+
async function readConfigNoFollow(configPath) {
|
|
18
|
+
let handle;
|
|
19
|
+
try {
|
|
20
|
+
handle = await open(configPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (error.code === "ENOENT") return null;
|
|
23
|
+
// ELOOP is what O_NOFOLLOW reports for a symlink. A config reached through
|
|
24
|
+
// a link may point anywhere, so it is refused rather than followed.
|
|
25
|
+
throw new AccError(EXIT.DATA, "cannot safely read the workspace config",
|
|
26
|
+
{ configPath, cause: error.message });
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const stat = await handle.stat();
|
|
30
|
+
if (!stat.isFile()) {
|
|
31
|
+
throw new AccError(EXIT.DATA, "the workspace config is not a regular file", { configPath });
|
|
32
|
+
}
|
|
33
|
+
return JSON.parse(await handle.readFile("utf8"));
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error instanceof AccError) throw error;
|
|
36
|
+
throw new AccError(EXIT.DATA, "the workspace config is not valid JSON",
|
|
37
|
+
{ configPath, cause: error.message });
|
|
38
|
+
} finally {
|
|
39
|
+
await handle.close();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Walk up looking for the one config filename.
|
|
45
|
+
*
|
|
46
|
+
* Sessions start wherever the human happens to be, so a config that only counted
|
|
47
|
+
* at the top of the tree would apply to some sessions in a project and not
|
|
48
|
+
* others. The walk stops at the filesystem root: climbing past it would let a
|
|
49
|
+
* stray file in a home directory claim every project underneath.
|
|
50
|
+
*/
|
|
51
|
+
async function findConfig(start) {
|
|
52
|
+
let directory = start;
|
|
53
|
+
for (;;) {
|
|
54
|
+
const candidate = path.join(directory, CONFIG_FILENAME);
|
|
55
|
+
const config = await readConfigNoFollow(candidate);
|
|
56
|
+
if (config !== null) return { config, configPath: candidate, base: directory };
|
|
57
|
+
const parent = path.dirname(directory);
|
|
58
|
+
if (parent === directory) return null;
|
|
59
|
+
directory = parent;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function canonical(directory, label) {
|
|
64
|
+
try {
|
|
65
|
+
return await realpath(directory);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
throw new AccError(EXIT.USAGE, `cannot resolve the ${label}`,
|
|
68
|
+
{ directory, cause: error.message });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function probeGit(gitProbe, cwd) {
|
|
73
|
+
if (typeof gitProbe !== "function") return null;
|
|
74
|
+
try {
|
|
75
|
+
return await gitProbe({ cwd });
|
|
76
|
+
} catch {
|
|
77
|
+
// Git is enrichment, never a requirement. A missing binary, a bare
|
|
78
|
+
// directory, or a probe failure all degrade to a directory workspace.
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the workspace for a working directory. Pure: no argument parsing, no
|
|
85
|
+
* stdout, no process exit, because the MCP server and every native adapter
|
|
86
|
+
* import this module directly.
|
|
87
|
+
*/
|
|
88
|
+
export async function discoverWorkspace({ cwd, env = {}, gitProbe, explicitConfig }) {
|
|
89
|
+
const override = env.ACC_WORKSPACE_ROOT;
|
|
90
|
+
if (typeof override === "string" && override.length > 0 && !path.isAbsolute(override)) {
|
|
91
|
+
throw new AccError(EXIT.USAGE, "ACC_WORKSPACE_ROOT must be an absolute path",
|
|
92
|
+
{ value: override });
|
|
93
|
+
}
|
|
94
|
+
const start = await canonical(
|
|
95
|
+
typeof override === "string" && override.length > 0 ? override : cwd, "workspace root");
|
|
96
|
+
|
|
97
|
+
const found = explicitConfig === undefined
|
|
98
|
+
? await findConfig(start)
|
|
99
|
+
: { config: await readConfigNoFollow(explicitConfig), configPath: explicitConfig,
|
|
100
|
+
base: path.dirname(explicitConfig) };
|
|
101
|
+
const config = found?.config == null
|
|
102
|
+
? null
|
|
103
|
+
: validateProjectConfig(found.config, { source: found.configPath });
|
|
104
|
+
|
|
105
|
+
const git = await probeGit(gitProbe, start);
|
|
106
|
+
const enrichment = git === null ? {} : {
|
|
107
|
+
git: Object.freeze({
|
|
108
|
+
commonDir: git.commonDir,
|
|
109
|
+
worktreeRoot: git.worktreeRoot,
|
|
110
|
+
branch: git.branch ?? null,
|
|
111
|
+
head: git.head ?? null,
|
|
112
|
+
remote: git.remote ?? null,
|
|
113
|
+
}),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
if (config !== null) {
|
|
117
|
+
// Declared roots are relative to the config, never to the working
|
|
118
|
+
// directory. Runtime containment is checked against these, so resolving
|
|
119
|
+
// them against the wrong base would let state land somewhere it must not.
|
|
120
|
+
const roots = await Promise.all(config.roots
|
|
121
|
+
.map(root => canonical(path.resolve(found.base, root), "declared workspace root")));
|
|
122
|
+
return Object.freeze({
|
|
123
|
+
id: config.workspaceId,
|
|
124
|
+
roots: Object.freeze([...new Set(roots)]),
|
|
125
|
+
source: "config",
|
|
126
|
+
displayName: config.displayName ?? path.basename(found.base),
|
|
127
|
+
policy: config.policy,
|
|
128
|
+
requiredAdapters: config.requiredAdapters,
|
|
129
|
+
...enrichment,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (git !== null) {
|
|
134
|
+
// Every worktree of one repository shares the common directory, so they
|
|
135
|
+
// share one workspace while keeping distinct checkout metadata.
|
|
136
|
+
return Object.freeze({
|
|
137
|
+
id: stableId(await canonical(git.commonDir, "Git common directory")),
|
|
138
|
+
roots: Object.freeze([start]),
|
|
139
|
+
source: "git",
|
|
140
|
+
displayName: path.basename(git.worktreeRoot),
|
|
141
|
+
...enrichment,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return Object.freeze({
|
|
146
|
+
id: stableId(start),
|
|
147
|
+
roots: Object.freeze([start]),
|
|
148
|
+
source: "directory",
|
|
149
|
+
displayName: path.basename(start),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { AccError, EXIT, SCHEMA_VERSION, assertMatchableResource, createId,
|
|
2
|
+
normaliseResource, validateRecord } from "@agents-can-communicate/protocol";
|
|
3
|
+
|
|
4
|
+
import { ensureMaterialised } from "./materialisation.mjs";
|
|
5
|
+
import { classifySessionPresence } from "./sessions.mjs";
|
|
6
|
+
|
|
7
|
+
const GLOB = "/**";
|
|
8
|
+
|
|
9
|
+
const split = resource => {
|
|
10
|
+
const index = resource.indexOf(":");
|
|
11
|
+
return { scheme: resource.slice(0, index), rest: resource.slice(index + 1) };
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Two resources overlap when they name the same thing or when one is a glob
|
|
16
|
+
* prefix of the other. Comparison is per path segment, so `file:ab/c` is not
|
|
17
|
+
* inside `file:a/**` - raw string prefixes are how this check usually goes
|
|
18
|
+
* wrong. Adapters may add richer overlap keys; the core rule stays generic.
|
|
19
|
+
*/
|
|
20
|
+
export function overlaps(left, right) {
|
|
21
|
+
if (left === right) return true;
|
|
22
|
+
const a = split(left);
|
|
23
|
+
const b = split(right);
|
|
24
|
+
if (a.scheme !== b.scheme) return false;
|
|
25
|
+
const covers = (glob, plain) => glob.rest.endsWith(GLOB)
|
|
26
|
+
&& (plain.rest === glob.rest.slice(0, -GLOB.length)
|
|
27
|
+
|| plain.rest.startsWith(`${glob.rest.slice(0, -GLOB.length)}/`));
|
|
28
|
+
return covers(a, b) || covers(b, a);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const isLive = (claim, now) => Date.parse(claim.expiresAt) > Date.parse(now);
|
|
32
|
+
|
|
33
|
+
export function createClaimService(ports, sessions) {
|
|
34
|
+
const { store, clock, ids } = ports;
|
|
35
|
+
|
|
36
|
+
async function requireOwner(input, action) {
|
|
37
|
+
const existing = await sessions.locateSession(input.sessionId, input.workspaceId);
|
|
38
|
+
if (existing === null || existing.record.state !== "open") {
|
|
39
|
+
throw new AccError(EXIT.CONFLICT, `cannot ${action} from a session that is not open`,
|
|
40
|
+
{ sessionId: input.sessionId });
|
|
41
|
+
}
|
|
42
|
+
if (existing.record.generation !== input.generation) {
|
|
43
|
+
throw new AccError(EXIT.CONFLICT, `cannot ${action} with a replaced session generation`,
|
|
44
|
+
{ sessionId: input.sessionId });
|
|
45
|
+
}
|
|
46
|
+
return existing.record;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function conflictWith(existing, owner, now, snapshotSessions) {
|
|
50
|
+
const ownerSession = snapshotSessions.find(item => item.sessionId === existing.ownerSessionId);
|
|
51
|
+
// Staleness is reported so the requester can decide what to do; it never
|
|
52
|
+
// releases the claim on its own.
|
|
53
|
+
const ownerPresence = ownerSession === undefined
|
|
54
|
+
? "offline"
|
|
55
|
+
: classifySessionPresence(ownerSession, now);
|
|
56
|
+
return new AccError(EXIT.CONFLICT, "the resource is already claimed", {
|
|
57
|
+
claimId: existing.claimId,
|
|
58
|
+
resource: existing.resource,
|
|
59
|
+
ownerSessionId: existing.ownerSessionId,
|
|
60
|
+
ownerPresence,
|
|
61
|
+
expiresAt: existing.expiresAt,
|
|
62
|
+
requestedBy: owner.sessionId,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function acquireClaim(input) {
|
|
67
|
+
const session = await requireOwner(input, "claim");
|
|
68
|
+
// One name for one file, decided here rather than at each surface, so a
|
|
69
|
+
// claim taken over MCP and one taken from the CLI mean the same thing - and
|
|
70
|
+
// a shape that could never match is refused rather than stored.
|
|
71
|
+
assertMatchableResource(input.resource, message => {
|
|
72
|
+
throw new AccError(EXIT.USAGE, message, { resource: input.resource });
|
|
73
|
+
});
|
|
74
|
+
const resource = normaliseResource(input.resource);
|
|
75
|
+
const workspaceId = session.workspaceId;
|
|
76
|
+
await ensureMaterialised(ports, { workspaceId, descriptor: input.descriptor,
|
|
77
|
+
reason: "durable_object" });
|
|
78
|
+
const now = clock.now();
|
|
79
|
+
const expiresAt = new Date(Date.parse(now) + (input.leaseSeconds ?? 1800) * 1000)
|
|
80
|
+
.toISOString();
|
|
81
|
+
// Only to name the owner of a conflicting claim in the error.
|
|
82
|
+
const snapshot = await store.snapshot(workspaceId, { kinds: ["session"] });
|
|
83
|
+
|
|
84
|
+
let record = null;
|
|
85
|
+
await store.transaction(async tx => {
|
|
86
|
+
const live = tx.list("claim", claim => isLive(claim, now));
|
|
87
|
+
// This session's own claim on this resource, live or lapsed. Looking only
|
|
88
|
+
// among the live ones left the expired record behind and made a fresh one
|
|
89
|
+
// beside it, so taking a resource back after a lease ran out reported
|
|
90
|
+
// success while the workspace still held a dead claim saying otherwise -
|
|
91
|
+
// and the owner went on being told its claim had run out.
|
|
92
|
+
const mine = tx.list("claim", claim => claim.ownerSessionId === session.sessionId
|
|
93
|
+
&& claim.resource === resource).at(0);
|
|
94
|
+
const blocking = live.find(claim => overlaps(claim.resource, resource)
|
|
95
|
+
&& claim.ownerSessionId !== session.sessionId
|
|
96
|
+
&& (claim.mode === "exclusive" || input.mode === "exclusive"));
|
|
97
|
+
if (blocking !== undefined) throw conflictWith(blocking, session, now, snapshot.sessions);
|
|
98
|
+
|
|
99
|
+
const claimId = mine?.claimId ?? createId("claim");
|
|
100
|
+
record = validateRecord("claim", {
|
|
101
|
+
schemaVersion: SCHEMA_VERSION,
|
|
102
|
+
claimId,
|
|
103
|
+
workspaceId,
|
|
104
|
+
ownerSessionId: session.sessionId,
|
|
105
|
+
resource,
|
|
106
|
+
mode: input.mode ?? "exclusive",
|
|
107
|
+
enforcement: input.enforcement ?? "advisory",
|
|
108
|
+
reason: input.reason,
|
|
109
|
+
// A lapsed claim taken again is a new period of holding it, not a
|
|
110
|
+
// continuation of one that ended.
|
|
111
|
+
acquiredAt: mine !== undefined && isLive(mine, now) ? mine.acquiredAt : now,
|
|
112
|
+
expiresAt,
|
|
113
|
+
generation: ids.next("generation"),
|
|
114
|
+
});
|
|
115
|
+
tx.put("claim", claimId, record, tx.generationOf("claim", claimId));
|
|
116
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
|
|
117
|
+
actorSessionId: session.sessionId, type: mine === undefined ? "claim.acquired"
|
|
118
|
+
: "claim.renewed", occurredAt: now,
|
|
119
|
+
payload: { claimId, resource, mode: record.mode } });
|
|
120
|
+
}, { kinds: ["claim"] });
|
|
121
|
+
return record;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function renewClaim(input) {
|
|
125
|
+
const session = await requireOwner(input, "renew");
|
|
126
|
+
const now = clock.now();
|
|
127
|
+
const expiresAt = new Date(Date.parse(now) + (input.leaseSeconds ?? 1800) * 1000)
|
|
128
|
+
.toISOString();
|
|
129
|
+
let record = null;
|
|
130
|
+
await store.transaction(async tx => {
|
|
131
|
+
const existing = tx.get("claim", input.claimId);
|
|
132
|
+
if (existing === null || existing.ownerSessionId !== session.sessionId) {
|
|
133
|
+
throw new AccError(EXIT.CONFLICT, "only the owning session generation may renew a claim",
|
|
134
|
+
{ claimId: input.claimId });
|
|
135
|
+
}
|
|
136
|
+
record = { ...existing, expiresAt, generation: ids.next("generation") };
|
|
137
|
+
tx.put("claim", input.claimId, record, tx.generationOf("claim", input.claimId));
|
|
138
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
|
|
139
|
+
workspaceId: existing.workspaceId, actorSessionId: session.sessionId,
|
|
140
|
+
type: "claim.renewed", occurredAt: now, payload: { claimId: input.claimId } });
|
|
141
|
+
}, { kinds: ["claim"] });
|
|
142
|
+
return record;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function removeClaim(input, { authority, reason }) {
|
|
146
|
+
const session = await requireOwner(input, "release");
|
|
147
|
+
const now = clock.now();
|
|
148
|
+
await store.transaction(async tx => {
|
|
149
|
+
const existing = tx.get("claim", input.claimId);
|
|
150
|
+
if (existing === null) {
|
|
151
|
+
throw new AccError(EXIT.CONFLICT, "the claim does not exist", { claimId: input.claimId });
|
|
152
|
+
}
|
|
153
|
+
const owned = existing.ownerSessionId === session.sessionId;
|
|
154
|
+
// Force release is an authority decision, not a peer decision. A
|
|
155
|
+
// coordinator lease does not extend outside its own workstream.
|
|
156
|
+
if (!owned && authority !== "human" && authority !== "policy") {
|
|
157
|
+
throw new AccError(EXIT.CONFLICT,
|
|
158
|
+
"force release requires human or policy authority",
|
|
159
|
+
{ claimId: input.claimId, authority: authority ?? null });
|
|
160
|
+
}
|
|
161
|
+
// The claim record is removed rather than tombstoned: a released claim is
|
|
162
|
+
// not a claim, and every consumer of a snapshot would otherwise have to
|
|
163
|
+
// re-derive liveness. The event log keeps the history.
|
|
164
|
+
tx.remove("claim", input.claimId, tx.generationOf("claim", input.claimId));
|
|
165
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
|
|
166
|
+
workspaceId: existing.workspaceId, actorSessionId: session.sessionId,
|
|
167
|
+
type: owned ? "claim.released" : "claim.force_released", occurredAt: now,
|
|
168
|
+
payload: { claimId: input.claimId, authority: authority ?? null,
|
|
169
|
+
reason: reason ?? null, replacedGeneration: existing.generation } });
|
|
170
|
+
}, { kinds: ["claim"] });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
acquireClaim,
|
|
175
|
+
renewClaim,
|
|
176
|
+
releaseClaim: input => removeClaim(input, { authority: null, reason: null }),
|
|
177
|
+
forceReleaseClaim: input => removeClaim(input,
|
|
178
|
+
{ authority: input.authority, reason: input.reason }),
|
|
179
|
+
};
|
|
180
|
+
}
|