agy-mcp-bridge 0.1.21 → 0.1.22

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/README.md CHANGED
@@ -79,6 +79,23 @@ Then call `antigravity_environment` from a fresh session — that is the proof;
79
79
 
80
80
  Two switches control delegation itself: `agy-mcp-bridge delegation off` blocks new tasks immediately without a restart, and `BRIDGE_DELEGATION=off` pins it at startup. Tasks still refuse to be delegated by policy the skill describes (security, auth, secrets, data deletion, deploy, CI and public API changes are not delegated).
81
81
 
82
+ ## Missions
83
+
84
+ A mission is a plan the bridge **records and gates but never runs**: up to 12 steps in one workspace, each carrying its own `delegate` arguments and a list of `depends_on`. An MCP client writes the plan and dispatches it one step at a time; the bridge refuses to start a step whose dependencies did not all complete, so no agent is sent at a step whose inputs do not exist yet. Nothing ever starts by itself — a restart restores every plan and re-dispatches nothing.
85
+
86
+ | Tool | What it does |
87
+ |---|---|
88
+ | `mission_create` | record the plan and return `mission_id`, progress and the `ready` step ids (the receipt does not echo the plan back) |
89
+ | `mission_extend` | append correction or follow-up steps: the plan is append-only, so a retry is always a new step |
90
+ | `mission_dispatch` | start one ready step as a normal supervised task; refused while a dependency is not `completed`, if the step already ran, or once the mission is canceled |
91
+ | `mission_status` | cheap poll: per-step state, `blocked_by`, `ready` — no task text |
92
+ | `mission_plan` | the whole recorded plan with each step's exact `delegate` arguments, for a client that lost its own copy |
93
+ | `mission_cancel` | close the plan so nothing further can be dispatched; running agents are left alone unless `cancel_running_tasks: true` |
94
+
95
+ A dispatched step **is** an ordinary task, so everything in [Delegate](#delegate) still applies to it: worktree isolation, `owns`, timeouts, redaction, process-tree release, `review_required`, and no auto-merge. Step state is derived from the task records on every read rather than cached, so a record that retention dropped shows as `lost` — which never unlocks the step waiting for it. Replies are deliberately small (`dispatch` returns five fields plus a `next_step` hint) because an orchestrator polls them; the **Nhiệm vụ** page is the human view. There is no `mission_list` — keep the `mission_id` you were given, or read it from that page.
96
+
97
+ The bundled `mission-orchestrator` skill teaches a client how to write such a plan (and when not to).
98
+
82
99
  ## Lifecycle and limits
83
100
 
84
101
  Task state belongs to the HTTP server, not to the MCP session, and all clients share one TaskManager. `none`-lane tasks take a cross-process lock (`.bridge-state/execution.lock`); `worktree`-lane tasks rely on their own branch. Results persist to `.bridge-state/tasks/<task_id>.json` (0600, secret-redacted) and reload at startup, so you can recover an answer after a reconnect — this is history, not durable task recovery. Ctrl+C once: stop listening, cancel running tasks and reap their process trees, drain sockets for up to 3 s, print `[bridge] Stopped`. It does not revert files.
@@ -99,7 +116,7 @@ Redaction recognises common secret shapes; it does not catch everything. Keep se
99
116
 
100
117
  ## Web UI
101
118
 
102
- `http://127.0.0.1:3000/ui` on the same machine, no login (there is no auth to log into). It reads the same task store as the tools, so it costs your other agents no tokens. Pages: **Tasks** (bridge state, ready agents, delegation switch, per-task request/criteria/response, `denied_actions`, stdout/stderr, full prompt, worktree/branch and `owns` verdict, workspace changes with per-file line counts) and **Settings** (delegation, MCP endpoint, per-agent probe results, allowed workspaces, permission level, model, effort, wait cadence, soft deadline, step progress, usage, shutdown button). Regions patch in place, so selecting log text survives an update.
119
+ `http://127.0.0.1:3000/ui` on the same machine, no login (there is no auth to log into). It reads the same task store as the tools, so it costs your other agents no tokens. Pages: **Tasks** (bridge state, ready agents, delegation switch, per-task request/criteria/response, `denied_actions`, stdout/stderr, full prompt, worktree/branch and `owns` verdict, workspace changes with per-file line counts), **Nhiệm vụ** (every recorded plan: steps, states, which dependency blocks which step, the exact `delegate` arguments per step, and a **Dừng kế hoạch** button that closes the plan and never touches a running agent — the page reads and stops missions, it cannot dispatch one), and **Settings** (delegation, MCP endpoint, per-agent probe results, allowed workspaces, permission level, model, effort, wait cadence, soft deadline, step progress, usage, shutdown button). Regions patch in place, so selecting log text survives an update.
103
120
 
104
121
  Long explanations, unverified notes and rarely-changed numbers live behind **More** blocks; decisions-worthy state stays visible. The no-auth warning is always visible, and the "allowlist is not a sandbox" warning is always visible at `auto`/`full`.
105
122
 
@@ -114,7 +131,7 @@ npm test # build + unit/integration with fake executables; no AI calls
114
131
  npm run test:live # REAL agent calls, scratch workspace, disconnect/reconnect, cancel, shutdown
115
132
  ```
116
133
 
117
- `npm test` runs serially (`--test-concurrency=1`, ~158 s): most cases start a real loopback server and time its output, so a parallel failure reflects machine load, not code. CI runs Windows and Linux on Node 20/22. Source map: `src/index.ts` entry, `src/http.ts` routing/shutdown, `src/runtime.ts` shared CLI + TaskManager, `src/agents` adapters and registry, `src/isolation` worktrees and `owns`, `src/tasks` lifecycle/lock/output, `src/security` validation and redaction, `src/tools` tool definitions, `src/antigravity` child process, `ui/` plain HTML/CSS/JS with `src/ui/` behind it.
134
+ `npm test` runs serially (`--test-concurrency=1`, ~158 s): most cases start a real loopback server and time its output, so a parallel failure reflects machine load, not code. CI runs Windows and Linux on Node 20/22. Source map: `src/index.ts` entry, `src/http.ts` routing/shutdown, `src/runtime.ts` shared CLI + TaskManager, `src/agents` adapters and registry, `src/missions` the plan store and its dispatch gates, `src/isolation` worktrees and `owns`, `src/tasks` lifecycle/lock/output, `src/security` validation and redaction, `src/tools` tool definitions, `src/antigravity` child process, `ui/` plain HTML/CSS/JS with `src/ui/` behind it.
118
135
 
119
136
  Pushing or merging to `main` runs CI and, when it passes, publishes to npm automatically: the [publish workflow](.github/workflows/publish.yml) bumps the patch version, tags, pushes and publishes through OIDC Trusted Publishing — no npm token in secrets, no manual version bump, nothing published if CI fails. PRs run [CI](.github/workflows/ci.yml) only. Set up once under the package's **Trusted Publisher** settings: user `thanhdo257205`, repository `MCP-bridge`, workflow `publish.yml`, empty environment, allow direct npm publish.
120
137
 
@@ -125,8 +142,8 @@ Pushing or merging to `main` runs CI and, when it passes, publishes to npm autom
125
142
  - [docs/MULTI-AGENT-PLAN.md](docs/MULTI-AGENT-PLAN.md) — the multi-agent roadmap and per-phase status.
126
143
  - [docs/HANDOFF.md](docs/HANDOFF.md) — open work (Job Object, POSIX E2E, worktree TTL) with mutation-test evidence.
127
144
  - [docs/PROMPTS.md](docs/PROMPTS.md) — review and follow-up task prompts.
128
- - [skills/antigravity-bridge/SKILL.md](skills/antigravity-bridge/SKILL.md) and [docs/SKILL-EVAL.md](docs/SKILL-EVAL.md) — the client-side contract and manual evaluation cases.
145
+ - [skills/antigravity-bridge/SKILL.md](skills/antigravity-bridge/SKILL.md), [skills/mission-orchestrator/SKILL.md](skills/mission-orchestrator/SKILL.md) and [docs/SKILL-EVAL.md](docs/SKILL-EVAL.md) — the client-side contract and manual evaluation cases.
129
146
 
130
- The bundled `antigravity-bridge` skill teaches a client the real tool schemas, task-id recovery, timeouts, permission denials and diff review. First interactive run offers to install it (`agy-mcp-bridge skill install --client codex|claude|both` writes `~/.agents/skills/…` or `~/.claude/skills/…`); a SKILL.md you edited yourself is never overwritten. Installing the skill does not connect the server or grant any permission.
147
+ Two skills ship with the bridge: `antigravity-bridge` teaches a client the real tool schemas, task-id recovery, timeouts, permission denials and diff review; `mission-orchestrator` teaches it how to write and drive a plan — when a mission is the right shape, how to keep it cheap, and what must never be put in one. First interactive run offers to install them (`agy-mcp-bridge skill install --client codex|claude|both [--skill <name>] [--update]` writes `~/.agents/skills/…` or `~/.claude/skills/…`); a `SKILL.md` you edited yourself is never overwritten, and one customized skill never blocks the other. Installing a skill does not connect the server or grant any permission.
131
148
 
132
149
  MIT licensed; see `LICENSE`.
@@ -1,24 +1,37 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { readFile, mkdir, writeFile, stat, realpath, rename, rm } from 'node:fs/promises';
2
+ import { readFile, mkdir, writeFile, stat, readdir, realpath, rename, rm } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { pathToFileURL } from 'node:url';
6
6
  import { paths } from '../src/paths.js';
7
- const skillSource = () => path.join(paths.skillsDirectory, 'antigravity-bridge', 'SKILL.md');
8
- const skillFile = (root, client) => path.join(root, client === 'codex' ? '.agents' : '.claude', 'skills', 'antigravity-bridge', 'SKILL.md');
7
+ const skillSource = (name, directory = paths.skillsDirectory) => path.join(directory, name, 'SKILL.md');
8
+ const skillFile = (root, client, name) => path.join(root, client === 'codex' ? '.agents' : '.claude', 'skills', name, 'SKILL.md');
9
+ const readIfPresent = (file) => readFile(file, 'utf8').catch(error => { if (error.code === 'ENOENT')
10
+ return null; throw error; });
11
+ /** One folder per skill under `skills/`, each holding its SKILL.md, so a new skill needs no change here. */
12
+ export async function packagedSkills(directory = paths.skillsDirectory) {
13
+ const names = (await readdir(directory, { withFileTypes: true }).catch(() => []))
14
+ .filter(entry => entry.isDirectory()).map(entry => entry.name);
15
+ const present = await Promise.all(names.map(async (name) => (await readIfPresent(skillSource(name, directory)) === null ? null : name)));
16
+ return present.filter((name) => name !== null).sort();
17
+ }
9
18
  const normalize = (text) => text.replace(/\r\n/g, '\n');
10
19
  /** Identity of a skill file's content, independent of line endings (a Windows checkout may turn LF into CRLF). */
11
20
  export const skillHash = (text) => createHash('sha256').update(normalize(text)).digest('hex');
12
- /** sha256 of every SKILL.md this project has shipped (skills/known-versions.json). A file that matches one is an old packaged copy, not the owner's edit. */
21
+ /** sha256 of every SKILL.md this project has shipped, listed per skill (skills/known-versions.json). A file that matches one is an old packaged copy, not the owner's edit. */
13
22
  export async function knownSkillHashes(file = path.join(paths.skillsDirectory, 'known-versions.json')) {
14
23
  try {
15
24
  const parsed = JSON.parse(await readFile(file, 'utf8'));
16
- return new Set((parsed.versions ?? []).map(entry => entry.sha256).filter((value) => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value)));
25
+ const known = {};
26
+ for (const [name, entries] of Object.entries(parsed.skills ?? {}))
27
+ known[name] = new Set((entries ?? []).map(entry => entry.sha256).filter((value) => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value)));
28
+ return known;
17
29
  }
18
30
  catch {
19
- return new Set();
31
+ return {};
20
32
  }
21
33
  }
34
+ const hashesFor = (known, skill) => known[skill] ?? new Set();
22
35
  function classify(existing, content, known) {
23
36
  if (existing === null)
24
37
  return 'missing';
@@ -27,27 +40,35 @@ function classify(existing, content, known) {
27
40
  return 'installed';
28
41
  return known.has(hash) ? 'outdated' : 'different';
29
42
  }
30
- const readIfPresent = (file) => readFile(file, 'utf8').catch(error => { if (error.code === 'ENOENT')
31
- return null; throw error; });
43
+ async function skillSources(skills) {
44
+ const names = skills ?? await packagedSkills();
45
+ if (!names.length)
46
+ throw new Error('No skill was selected and this bridge ships none (expected skills/<name>/SKILL.md)');
47
+ return Promise.all(names.map(async (name) => ({ name, content: await readFile(skillSource(name), 'utf8') })));
48
+ }
49
+ /** One fingerprint for the packaged set, so "a newer skill ships" means any skill in it changed. */
50
+ const setHash = (sources) => skillHash(sources.map(source => `${source.name}:${skillHash(source.content)}`).join('\n'));
32
51
  /**
33
- * Where the skill is (or would be) installed for the current user and what is there: `installed` (this version), `outdated`
34
- * (an older version this project shipped, safe to replace), `different` (not any shipped version: the owner's edit) or `missing`. Reads only.
52
+ * Where every packaged skill is (or would be) installed for the current user and what is there: `installed` (this version),
53
+ * `outdated` (an older version this project shipped, safe to replace), `different` (not any shipped version: the owner's edit) or `missing`. Reads only.
35
54
  */
36
55
  export async function skillStatus(options = {}) {
37
- const content = await readFile(skillSource(), 'utf8');
56
+ const sources = await skillSources(options.skills);
38
57
  const known = options.known ?? await knownSkillHashes();
39
58
  const root = options.userHome ?? homedir();
40
- const result = { currentHash: skillHash(content) };
59
+ const result = { currentHash: setHash(sources), codex: [], claude: [] };
41
60
  for (const client of ['codex', 'claude']) {
42
- const file = skillFile(root, client);
43
- result[client] = { file, state: classify(await readIfPresent(file), content, known) };
61
+ for (const source of sources) {
62
+ const file = skillFile(root, client, source.name);
63
+ result[client].push({ skill: source.name, file, state: classify(await readIfPresent(file), source.content, hashesFor(known, source.name)) });
64
+ }
44
65
  }
45
66
  return result;
46
67
  }
47
68
  export async function installSkill(options) {
48
69
  if (!['codex', 'claude', 'both'].includes(options.client))
49
70
  throw new Error('--client must be codex, claude or both');
50
- const content = await readFile(skillSource(), 'utf8');
71
+ const sources = await skillSources(options.skills);
51
72
  const known = options.known ?? await knownSkillHashes();
52
73
  let root = options.userHome ?? homedir();
53
74
  if (options.project !== undefined) {
@@ -58,35 +79,39 @@ export async function installSkill(options) {
58
79
  throw new Error('--project must be a directory');
59
80
  }
60
81
  const clients = options.client === 'both' ? ['codex', 'claude'] : [options.client];
61
- const destinations = clients.map(client => skillFile(root, client));
62
82
  const planned = [];
83
+ const writes = [];
63
84
  // Decide for every destination before writing any. A file that is not one of our shipped versions is never overwritten.
64
- for (const filename of destinations) {
65
- const state = classify(await readIfPresent(filename), content, known);
66
- if (state === 'installed')
67
- planned.push({ filename, unchanged: true });
68
- else if (state === 'missing')
69
- planned.push({ filename, unchanged: false });
70
- else if (state === 'outdated') {
71
- if (!options.update)
72
- throw new Error(`Existing skill is an older packaged version; run again with --update to replace it: ${filename}`);
73
- planned.push({ filename, unchanged: false, updated: true });
85
+ for (const client of clients) {
86
+ for (const source of sources) {
87
+ const filename = skillFile(root, client, source.name);
88
+ const state = classify(await readIfPresent(filename), source.content, hashesFor(known, source.name));
89
+ if (state === 'installed')
90
+ planned.push({ skill: source.name, filename, unchanged: true });
91
+ else if (state === 'missing') {
92
+ planned.push({ skill: source.name, filename, unchanged: false });
93
+ writes.push({ filename, content: source.content, replace: false });
94
+ }
95
+ else if (state === 'outdated') {
96
+ if (!options.update)
97
+ throw new Error(`Existing skill is an older packaged version; run again with --update to replace it: ${filename}`);
98
+ planned.push({ skill: source.name, filename, unchanged: false, updated: true });
99
+ writes.push({ filename, content: source.content, replace: true });
100
+ }
101
+ else
102
+ throw new Error(`Existing skill differs from every packaged version (customized); review it or remove it first: ${filename}`);
74
103
  }
75
- else
76
- throw new Error(`Existing skill differs from every packaged version (customized); review it or remove it first: ${filename}`);
77
104
  }
78
- for (const item of planned) {
79
- if (item.unchanged)
80
- continue;
105
+ for (const item of writes) {
81
106
  await mkdir(path.dirname(item.filename), { recursive: true });
82
- if (!item.updated) {
83
- await writeFile(item.filename, content, { flag: 'wx' });
107
+ if (!item.replace) {
108
+ await writeFile(item.filename, item.content, { flag: 'wx' });
84
109
  continue;
85
110
  }
86
111
  // Replace atomically so a crash never leaves a half-written skill.
87
112
  const temporary = `${item.filename}.${process.pid}.tmp`;
88
113
  try {
89
- await writeFile(temporary, content, { flag: 'wx' });
114
+ await writeFile(temporary, item.content, { flag: 'wx' });
90
115
  await rename(temporary, item.filename);
91
116
  }
92
117
  finally {
@@ -96,8 +121,10 @@ export async function installSkill(options) {
96
121
  return planned;
97
122
  }
98
123
  async function main(args) {
124
+ const skills = await packagedSkills();
99
125
  if (args.includes('--help')) {
100
- console.log('npm run skill:install -- --client codex|claude|both [--project ABSOLUTE_PROJECT_PATH] [--update]');
126
+ console.log('npm run skill:install -- --client codex|claude|both [--skill <name>] [--project ABSOLUTE_PROJECT_PATH] [--update]');
127
+ console.log(`Skills packaged with this bridge: ${skills.join(', ') || '(none)'}. Without --skill, every one of them is installed.`);
101
128
  console.log('Without --project, installs for the current user. Does not change MCP configuration or permissions.');
102
129
  console.log('--update replaces an installed copy only if it is an older version shipped by this project; a customized file is never overwritten.');
103
130
  return;
@@ -105,12 +132,15 @@ async function main(args) {
105
132
  let client;
106
133
  let project;
107
134
  let update = false;
135
+ const selected = [];
108
136
  for (let index = 0; index < args.length; index++) {
109
137
  const flag = args[index];
110
138
  if (flag === '--client' && client === undefined && args[index + 1])
111
139
  client = args[++index];
112
140
  else if (flag === '--project' && project === undefined && args[index + 1])
113
141
  project = args[++index];
142
+ else if (flag === '--skill' && !selected.includes(args[index + 1] ?? '') && args[index + 1])
143
+ selected.push(args[++index]);
114
144
  else if (flag === '--update' && !update)
115
145
  update = true;
116
146
  else
@@ -118,7 +148,10 @@ async function main(args) {
118
148
  }
119
149
  if (!client)
120
150
  throw new Error('Choose --client codex, claude or both');
121
- const installed = await installSkill({ client, project, update });
151
+ for (const name of selected)
152
+ if (!skills.includes(name))
153
+ throw new Error(`--skill must be one of the packaged skills: ${skills.join(', ')}`);
154
+ const installed = await installSkill({ client, project, update, skills: selected.length ? selected : undefined });
122
155
  for (const item of installed)
123
156
  console.log(`[skill] ${item.unchanged ? 'Already installed' : item.updated ? 'Updated' : 'Installed'}: ${item.filename}`);
124
157
  console.log('[skill] Reopen the client if the skill does not appear. MCP connection and workspace permissions are configured separately.');
package/dist/src/cli.js CHANGED
@@ -32,9 +32,9 @@ Usage: ${BIN} [command] [options]
32
32
  Manage the directories agents may edit (restart the server afterwards)
33
33
  delegation on|off|status
34
34
  Switch delegation to Antigravity on or off immediately (no restart)
35
- skill install --client codex|claude|both [--project <dir>] [--update]
36
- Install the antigravity-bridge skill (first run also offers it once). --update replaces an older
37
- packaged copy; a file you edited yourself is never overwritten
35
+ skill install --client codex|claude|both [--skill <name>] [--project <dir>] [--update]
36
+ Install the skills this bridge ships (first run also offers them once). Repeat --skill for one of
37
+ them; --update replaces an older packaged copy, and a file you edited yourself is never overwritten
38
38
  setup codex|claude|both [--port N] [--shell powershell|cmd|bash]
39
39
  Print the exact client configuration (changes nothing)
40
40
  setup codex|both --apply [--yes] [--tool-timeout N] [--port N]
package/dist/src/http.js CHANGED
@@ -15,8 +15,8 @@ export function configuredPort(value = process.env.PORT) {
15
15
  export async function startHttpBridge(options) {
16
16
  let stopping = false;
17
17
  let baseUrl = '';
18
- const ui = { manager: options.manager, environment: options.environment, mcpUrl: () => `${baseUrl}/mcp`, startedAt: Date.now(), requestShutdown: options.requestShutdown };
19
- const handler = createMcpHandler(() => createServer(options.manager, options.environment), { maxRequestBodySize: 131072, maxSubscriptions: 16 });
18
+ const ui = { manager: options.manager, missions: options.missions, environment: options.environment, mcpUrl: () => `${baseUrl}/mcp`, startedAt: Date.now(), requestShutdown: options.requestShutdown };
19
+ const handler = createMcpHandler(() => createServer(options.manager, options.environment, options.missions), { maxRequestBodySize: 131072, maxSubscriptions: 16 });
20
20
  const mcp = toNodeHandler(handler, { maxRequestBodySize: 131072 });
21
21
  const server = createHttpServer((req, res) => {
22
22
  const reply = (code, body) => {
@@ -49,7 +49,7 @@ export async function startHttpBridge(options) {
49
49
  return;
50
50
  }
51
51
  if (req.url === '/health' && req.method === 'GET') {
52
- reply(200, { status: 'ok', transport: 'streamable-http', mcp_endpoint: `${baseUrl}/mcp`, ...options.manager.overview() });
52
+ reply(200, { status: 'ok', transport: 'streamable-http', mcp_endpoint: `${baseUrl}/mcp`, ...options.manager.overview(), missions: options.missions.overview() });
53
53
  return;
54
54
  }
55
55
  if (req.url !== '/mcp') {
package/dist/src/index.js CHANGED
@@ -5,7 +5,7 @@ import { createRuntime } from './runtime.js';
5
5
  import { configuredPort, startHttpBridge } from './http.js';
6
6
  async function main() {
7
7
  const port = configuredPort();
8
- const { manager, environment } = await createRuntime();
8
+ const { manager, missions, environment } = await createRuntime();
9
9
  let stopping = false;
10
10
  let closeBridge;
11
11
  const shutdown = async () => {
@@ -26,7 +26,7 @@ async function main() {
26
26
  process.exitCode = 1;
27
27
  }
28
28
  };
29
- const bridge = await startHttpBridge({ manager, environment, port, requestShutdown: shutdown });
29
+ const bridge = await startHttpBridge({ manager, missions, environment, port, requestShutdown: shutdown });
30
30
  closeBridge = bridge.close;
31
31
  console.error(`[bridge] HTTP server: ${bridge.baseUrl}`);
32
32
  console.error(`[bridge] MCP endpoint: ${bridge.mcpUrl}`);