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.
@@ -0,0 +1,413 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import * as z from 'zod/v4';
5
+ import { delegateSchema } from '../security/input.js';
6
+ import { validateWorkspace } from '../security/workspace.js';
7
+ import { redact } from '../security/redact.js';
8
+ /**
9
+ * A mission is a plan the bridge records and enforces but never runs by itself: an MCP client (the orchestrator)
10
+ * dispatches one step at a time and waits on the returned task with the existing task tools. Keeping the plan here
11
+ * rather than only in the client's context is what makes it survive a reconnect or a restart, and gating dispatch on
12
+ * the recorded dependencies is what stops a worker being sent at a step whose inputs do not exist yet.
13
+ */
14
+ export const MAX_STEPS_PER_MISSION = 12;
15
+ /** Bound the record itself: an MCP request body is capped at 131072 bytes, and the whole plan is re-read by every status call. */
16
+ export const MAX_MISSION_CHARACTERS = 100_000;
17
+ /** Oldest missions with no task still in flight are dropped first, exactly like finished tasks. */
18
+ export const MAX_RETAINED_MISSIONS = 20;
19
+ const stepIdValue = z.string().regex(/^[a-z0-9][a-z0-9._-]{0,63}$/, 'step_id must be a lowercase slug: a-z, 0-9, dot, dash or underscore, at most 64 characters, starting with a letter or digit');
20
+ export const stepIdSchema = stepIdValue;
21
+ export const missionStepSchema = z.object({
22
+ step_id: stepIdValue,
23
+ title: z.string().trim().min(1).max(200),
24
+ depends_on: z.array(stepIdValue).max(MAX_STEPS_PER_MISSION).default([]),
25
+ /** Everything the step will be delegated with, validated by the same schema a direct call has to pass. */
26
+ delegate: delegateSchema
27
+ }).strict();
28
+ const stepsSchema = z.array(missionStepSchema).min(1).max(MAX_STEPS_PER_MISSION);
29
+ export const missionCreateSchema = z.object({
30
+ title: z.string().trim().min(1).max(200),
31
+ goal: z.string().trim().min(1).max(4000),
32
+ workspace: z.string().min(1).max(4096),
33
+ steps: stepsSchema
34
+ }).strict();
35
+ /** The plan is append-only: a new attempt is a new step, never an edit of a step that already ran. */
36
+ export const missionExtendSchema = z.object({ mission_id: z.uuid(), steps: stepsSchema }).strict();
37
+ export const missionIdSchema = z.object({ mission_id: z.uuid() }).strict();
38
+ export const missionDispatchSchema = z.object({ mission_id: z.uuid(), step_id: stepIdValue }).strict();
39
+ export const missionCancelSchema = z.object({ mission_id: z.uuid(), cancel_running_tasks: z.boolean().optional() }).strict();
40
+ const MISSION_FILE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.json$/;
41
+ /** Disk records are validated on the way in: a foreign or half-written file is skipped, never trusted. */
42
+ const recordSchema = z.object({
43
+ mission: z.object({
44
+ mission_id: z.uuid(), title: z.string(), goal: z.string(), workspace: z.string(),
45
+ status: z.enum(['open', 'canceled']), client: z.string().nullable(),
46
+ created_at: z.string(), updated_at: z.string(), canceled_at: z.string().nullable(),
47
+ steps: z.array(z.object({
48
+ step_id: stepIdValue, title: z.string(), depends_on: z.array(z.string()),
49
+ task_id: z.string().nullable(), delegate: delegateSchema
50
+ }).strict())
51
+ }).strict()
52
+ }).strict();
53
+ /**
54
+ * Structural rules of a plan, checked before anything is written. They cannot be expressed as per-field zod checks
55
+ * because they span steps, and a mission failing them could never be dispatched correctly.
56
+ */
57
+ export function assertPlan(additions, existing, workspace, maxTimeoutSeconds) {
58
+ const ids = new Set(existing.map(step => step.step_id));
59
+ for (const step of additions) {
60
+ if (ids.has(step.step_id))
61
+ throw new Error(`step_id "${step.step_id}" is already in the plan; the plan is append-only, so a retry needs a new step_id`);
62
+ ids.add(step.step_id);
63
+ }
64
+ const all = [...existing, ...additions];
65
+ const known = new Set(all.map(step => step.step_id));
66
+ for (const step of additions) {
67
+ if (step.delegate.workspace !== workspace)
68
+ throw new Error(`step "${step.step_id}" asks for workspace ${JSON.stringify(step.delegate.workspace)}, but a mission runs entirely in ${JSON.stringify(workspace)}`);
69
+ if (new Set(step.depends_on).size !== step.depends_on.length)
70
+ throw new Error(`step "${step.step_id}" lists the same dependency twice`);
71
+ if (step.depends_on.includes(step.step_id))
72
+ throw new Error(`step "${step.step_id}" depends on itself`);
73
+ for (const dependency of step.depends_on)
74
+ if (!known.has(dependency))
75
+ throw new Error(`step "${step.step_id}" depends on "${dependency}", which is not in the plan`);
76
+ if (step.delegate.timeout_seconds !== undefined && step.delegate.timeout_seconds > maxTimeoutSeconds)
77
+ throw new Error(`step "${step.step_id}" asks for ${step.delegate.timeout_seconds}s but the bridge limit is ${maxTimeoutSeconds}s`);
78
+ }
79
+ const cycle = findCycle(all);
80
+ if (cycle)
81
+ throw new Error(`dependency cycle: ${cycle.join(' -> ')}`);
82
+ const size = JSON.stringify(all).length;
83
+ if (size > MAX_MISSION_CHARACTERS)
84
+ throw new Error(`the plan would be ${size} characters, over the ${MAX_MISSION_CHARACTERS} limit; split the mission or shorten the steps`);
85
+ }
86
+ /** Iterative DFS over the dependency graph; returns the cycle it found, or null. */
87
+ function findCycle(steps) {
88
+ const edges = new Map(steps.map(step => [step.step_id, step.depends_on]));
89
+ const done = new Set();
90
+ const onStack = new Set();
91
+ const stack = [];
92
+ for (const root of edges.keys()) {
93
+ if (done.has(root))
94
+ continue;
95
+ stack.push({ id: root, index: 0 });
96
+ onStack.add(root);
97
+ while (stack.length) {
98
+ const frame = stack[stack.length - 1];
99
+ const children = edges.get(frame.id) ?? [];
100
+ if (frame.index >= children.length) {
101
+ stack.pop();
102
+ onStack.delete(frame.id);
103
+ done.add(frame.id);
104
+ continue;
105
+ }
106
+ const child = children[frame.index++];
107
+ if (done.has(child) || !edges.has(child))
108
+ continue;
109
+ if (onStack.has(child))
110
+ return [...stack.map(entry => entry.id), child];
111
+ stack.push({ id: child, index: 0 });
112
+ onStack.add(child);
113
+ }
114
+ }
115
+ return null;
116
+ }
117
+ const redactedDelegate = (delegate) => ({
118
+ ...delegate,
119
+ task: redact(delegate.task),
120
+ acceptance_criteria: delegate.acceptance_criteria.map(redact),
121
+ ...(delegate.context === undefined ? {} : { context: redact(delegate.context) })
122
+ });
123
+ const toSteps = (inputs) => inputs.map(step => ({ step_id: step.step_id, title: redact(step.title), depends_on: [...step.depends_on], task_id: null, delegate: redactedDelegate(step.delegate) }));
124
+ export class MissionManager {
125
+ tasks;
126
+ config;
127
+ missions = new Map();
128
+ /** Steps whose dispatch call is in flight. Delegation is asynchronous, so without this two concurrent calls could both pass the readiness check. */
129
+ inFlight = new Set();
130
+ /** A plan only needs the workspace allowlist and the timeout ceiling; the task manager owns everything else. */
131
+ constructor(tasks, config) {
132
+ this.tasks = tasks;
133
+ this.config = config;
134
+ }
135
+ get stateDirectory() { return this.tasks.stateDirectory; }
136
+ get missionsDirectory() { return path.join(this.tasks.stateDirectory, 'missions'); }
137
+ async create(raw, meta = {}) {
138
+ const input = missionCreateSchema.parse(raw);
139
+ assertPlan(input.steps, [], input.workspace, this.config.max_timeout_seconds);
140
+ // Fail at the plan rather than at the first dispatch: a workspace outside the allowlist can never run a step.
141
+ const workspace = await validateWorkspace(input.workspace, this.config.allowed_workspaces);
142
+ // One spelling for the whole record. `assertPlan` proved every step names the mission's workspace, so rewriting
143
+ // it to the canonical directory loses nothing — and keeping the raw string would make `mission_plan` print two
144
+ // spellings of one directory, which `extend` would then refuse the client for using.
145
+ const steps = input.steps.map(step => ({ ...step, delegate: { ...step.delegate, workspace } }));
146
+ const now = new Date().toISOString();
147
+ const mission = {
148
+ mission_id: randomUUID(), title: redact(input.title), goal: redact(input.goal), workspace,
149
+ status: 'open', client: meta.client ? redact(meta.client).slice(0, 100) : null,
150
+ created_at: now, updated_at: now, canceled_at: null, steps: toSteps(steps)
151
+ };
152
+ this.missions.set(mission.mission_id, mission);
153
+ for (const evicted of this.makeRoom())
154
+ await this.forget(evicted);
155
+ await this.persist(mission);
156
+ return mission;
157
+ }
158
+ /** Add steps to a plan that is still open. Existing steps are never rewritten. */
159
+ async extend(id, steps) {
160
+ const mission = this.require(id);
161
+ if (mission.status === 'canceled')
162
+ throw new Error(`mission ${id} is canceled; its plan can no longer change`);
163
+ const additions = await this.alignWorkspaces(stepsSchema.parse(steps), mission.workspace);
164
+ assertPlan(additions, mission.steps, mission.workspace, this.config.max_timeout_seconds);
165
+ mission.steps.push(...toSteps(additions));
166
+ mission.updated_at = new Date().toISOString();
167
+ await this.persist(mission);
168
+ return mission;
169
+ }
170
+ /**
171
+ * Resolve each distinct workspace spelling of an incoming plan against the mission's own directory, so the
172
+ * single-workspace rule gates on the directory rather than on how the client wrote it: a trailing separator,
173
+ * slashes the other way or a Windows short name all name a workspace the plan already runs in, and `dispatch`
174
+ * canonicalizes the same string anyway. Only a spelling that lands on this mission's directory is rewritten;
175
+ * anything else stays exactly as the client wrote it so `assertPlan` refuses it with the plan's own message.
176
+ */
177
+ async alignWorkspaces(steps, workspace) {
178
+ const lands = new Map();
179
+ for (const candidate of new Set(steps.map(step => step.delegate.workspace))) {
180
+ if (candidate === workspace) {
181
+ lands.set(candidate, true);
182
+ continue;
183
+ }
184
+ const resolved = await validateWorkspace(candidate, this.config.allowed_workspaces).catch(() => null);
185
+ lands.set(candidate, resolved === workspace);
186
+ }
187
+ return steps.map(step => lands.get(step.delegate.workspace) ? { ...step, delegate: { ...step.delegate, workspace } } : step);
188
+ }
189
+ /**
190
+ * Starts the one step the caller names, with the arguments recorded in its plan entry. The bridge never picks the
191
+ * next step itself: readiness is checked here, so a dependency that did not complete cannot be skipped past.
192
+ */
193
+ async dispatch(missionId, stepId, meta = {}) {
194
+ const mission = this.require(missionId);
195
+ const step = mission.steps.find(item => item.step_id === stepId);
196
+ if (!step)
197
+ throw new Error(`mission ${missionId} has no step "${stepId}"`);
198
+ if (mission.status === 'canceled')
199
+ throw new Error(`mission ${missionId} is canceled: no step can be dispatched`);
200
+ const states = this.states(mission);
201
+ if (step.task_id)
202
+ throw new Error(`step "${stepId}" is already bound to task ${step.task_id} (${states.get(stepId)}); the plan is append-only, so dispatch a new step to try again`);
203
+ const unmet = step.depends_on.filter(dependency => states.get(dependency) !== 'completed');
204
+ if (unmet.length)
205
+ throw new Error(`step "${stepId}" is not ready: ${unmet.map(dependency => `"${dependency}" is ${states.get(dependency)}`).join(', ')}`);
206
+ const key = `${missionId}/${stepId}`;
207
+ if (this.inFlight.has(key))
208
+ throw new Error(`step "${stepId}" is already being dispatched by another call`);
209
+ this.inFlight.add(key);
210
+ try {
211
+ const task = await this.tasks.delegate({ ...step.delegate }, { client: meta.client ?? `mission ${missionId}/${stepId}` });
212
+ step.task_id = task.task_id;
213
+ mission.updated_at = new Date().toISOString();
214
+ await this.persist(mission);
215
+ return { mission, step, task };
216
+ }
217
+ finally {
218
+ this.inFlight.delete(key);
219
+ }
220
+ }
221
+ /**
222
+ * Stops the plan: nothing further can be dispatched. Running agents are left alone unless asked, because stopping a
223
+ * process mid-edit is the caller's decision and `antigravity_cancel_task` is the tool for it.
224
+ */
225
+ async cancel(id, options = {}) {
226
+ const mission = this.require(id);
227
+ const states = this.states(mission);
228
+ const running = mission.steps
229
+ .filter(step => step.task_id && (states.get(step.step_id) === 'queued' || states.get(step.step_id) === 'running'))
230
+ .map(step => step.task_id);
231
+ const canceled = [];
232
+ if (mission.status !== 'canceled') {
233
+ mission.status = 'canceled';
234
+ mission.canceled_at = new Date().toISOString();
235
+ mission.updated_at = mission.canceled_at;
236
+ await this.persist(mission);
237
+ }
238
+ // Asking for the agents to stop is a separate decision from closing the plan, so it stays honoured after a first cancel.
239
+ if (options.cancelRunningTasks) {
240
+ for (const taskId of running) {
241
+ const stopped = await this.tasks.cancel(taskId).catch(() => undefined);
242
+ if (stopped?.status === 'canceled')
243
+ canceled.push(taskId);
244
+ }
245
+ }
246
+ return { mission, running, canceled };
247
+ }
248
+ /** Cheap progress view: no task text and no logs, one short entry per step. */
249
+ view(id) { return this.viewOf(this.require(id), false); }
250
+ /** The whole plan with each step's delegate arguments, for an orchestrator that has lost its own copy. */
251
+ planOf(id) { return this.viewOf(this.require(id), true); }
252
+ list() {
253
+ return [...this.missions.values()]
254
+ .map(mission => this.viewOf(mission, false))
255
+ .sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at));
256
+ }
257
+ overview() {
258
+ let open = 0, canceled = 0, steps = 0, ready = 0;
259
+ for (const mission of this.missions.values()) {
260
+ mission.status === 'open' ? open++ : canceled++;
261
+ const view = this.viewOf(mission, false);
262
+ steps += view.progress.total;
263
+ ready += view.ready.length;
264
+ }
265
+ return { open, canceled, steps, ready };
266
+ }
267
+ require(id) {
268
+ const mission = this.missions.get(id);
269
+ if (!mission)
270
+ throw new Error('Unknown or expired mission_id');
271
+ return mission;
272
+ }
273
+ /**
274
+ * Task-derived state for a bound step, then readiness for the rest. A step can only reach `completed` through its
275
+ * own task, so an unbound dependency never satisfies readiness and one pass settles every state.
276
+ */
277
+ states(mission) {
278
+ const states = new Map();
279
+ for (const step of mission.steps) {
280
+ if (!step.task_id)
281
+ continue;
282
+ try {
283
+ states.set(step.step_id, this.tasks.summary(step.task_id).status);
284
+ }
285
+ catch {
286
+ states.set(step.step_id, 'lost');
287
+ }
288
+ }
289
+ for (const step of mission.steps) {
290
+ if (states.has(step.step_id))
291
+ continue;
292
+ states.set(step.step_id, step.depends_on.some(dependency => states.get(dependency) !== 'completed') ? 'pending' : 'ready');
293
+ }
294
+ return states;
295
+ }
296
+ viewOf(mission, withArgs) {
297
+ const states = this.states(mission);
298
+ const steps = mission.steps.map(step => {
299
+ const state = states.get(step.step_id);
300
+ const summary = step.task_id ? this.summaryOrNull(step.task_id) : null;
301
+ return {
302
+ step_id: step.step_id, title: step.title, state, depends_on: [...step.depends_on],
303
+ blocked_by: state === 'pending'
304
+ ? step.depends_on.filter(dependency => states.get(dependency) !== 'completed').map(dependency => ({ step_id: dependency, state: states.get(dependency) }))
305
+ : [],
306
+ task_id: step.task_id, agent: step.delegate.agent ?? null, isolation: step.delegate.isolation,
307
+ scope: summary?.scope ? { checked: summary.scope.checked, changed: summary.scope.changed, out_of_scope: summary.scope.out_of_scope.length } : null,
308
+ ...(withArgs ? { delegate: step.delegate } : {})
309
+ };
310
+ });
311
+ const count = (test) => steps.filter(step => test(step.state)).length;
312
+ const terminal = (state) => state !== 'completed' && state !== 'ready' && state !== 'pending' && state !== 'queued' && state !== 'running';
313
+ return {
314
+ mission_id: mission.mission_id, title: mission.title, workspace: mission.workspace, status: mission.status,
315
+ created_at: mission.created_at, updated_at: mission.updated_at,
316
+ ...(withArgs ? { goal: mission.goal, client: mission.client } : {}),
317
+ progress: {
318
+ total: steps.length,
319
+ completed: count(state => state === 'completed'),
320
+ active: count(state => state === 'queued' || state === 'running'),
321
+ ready: count(state => state === 'ready'),
322
+ pending: count(state => state === 'pending'),
323
+ unmet: count(terminal)
324
+ },
325
+ ready: steps.filter(step => step.state === 'ready').map(step => step.step_id),
326
+ steps
327
+ };
328
+ }
329
+ summaryOrNull(id) {
330
+ try {
331
+ return this.tasks.summary(id);
332
+ }
333
+ catch {
334
+ return null;
335
+ }
336
+ }
337
+ /** Retention only ever releases a mission with no task in flight, so a plan cannot vanish under a running agent. */
338
+ makeRoom() {
339
+ const evicted = [];
340
+ while (this.missions.size > MAX_RETAINED_MISSIONS) {
341
+ const candidates = [...this.missions.values()]
342
+ .filter(mission => {
343
+ const states = this.states(mission);
344
+ return ![...states.values()].some(state => state === 'queued' || state === 'running');
345
+ })
346
+ .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at));
347
+ if (!candidates.length)
348
+ return evicted;
349
+ const [oldest] = candidates;
350
+ this.missions.delete(oldest.mission_id);
351
+ evicted.push(oldest.mission_id);
352
+ console.error(`[bridge] Dropped the oldest mission record ${oldest.mission_id} to keep ${MAX_RETAINED_MISSIONS} missions`);
353
+ }
354
+ return evicted;
355
+ }
356
+ /** Best effort, like task history: a lost write costs the resume, not the running plan. */
357
+ async persist(mission) {
358
+ const file = path.join(this.missionsDirectory, `${mission.mission_id}.json`);
359
+ try {
360
+ await mkdir(this.missionsDirectory, { recursive: true, mode: 0o700 });
361
+ await writeFile(`${file}.tmp`, JSON.stringify({ mission }), { mode: 0o600 });
362
+ // The web UI and a polling client open these records for reading; Windows refuses a rename onto a held file.
363
+ for (let attempt = 0;; attempt++) {
364
+ try {
365
+ await rename(`${file}.tmp`, file);
366
+ break;
367
+ }
368
+ catch (error) {
369
+ const code = error.code;
370
+ if (attempt >= 2 || (code !== 'EPERM' && code !== 'EACCES' && code !== 'EBUSY'))
371
+ throw error;
372
+ await new Promise(resolve => setTimeout(resolve, 50 * (attempt + 1)));
373
+ }
374
+ }
375
+ }
376
+ catch {
377
+ await unlink(`${file}.tmp`).catch(() => undefined);
378
+ }
379
+ }
380
+ async forget(id) {
381
+ await unlink(path.join(this.missionsDirectory, `${id}.json`)).catch(() => undefined);
382
+ }
383
+ /**
384
+ * Reloads plans after a restart. Step state is derived from the task records on every read rather than stored, so a
385
+ * task that the restart marked failed shows up as failed here too, and no step is ever re-dispatched on its own.
386
+ */
387
+ async restore() {
388
+ let names;
389
+ try {
390
+ names = (await readdir(this.missionsDirectory)).filter(name => MISSION_FILE.test(name));
391
+ }
392
+ catch {
393
+ return 0;
394
+ }
395
+ const loaded = [];
396
+ for (const name of names) {
397
+ try {
398
+ const { mission } = recordSchema.parse(JSON.parse(await readFile(path.join(this.missionsDirectory, name), 'utf8')));
399
+ if (`${mission.mission_id}.json` !== name)
400
+ continue;
401
+ loaded.push(mission);
402
+ }
403
+ catch { /* a record this project did not write is left where it is, never loaded */ }
404
+ }
405
+ loaded.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
406
+ for (const mission of loaded.slice(MAX_RETAINED_MISSIONS))
407
+ await this.forget(mission.mission_id);
408
+ for (const mission of loaded.slice(0, MAX_RETAINED_MISSIONS))
409
+ this.missions.set(mission.mission_id, mission);
410
+ return this.missions.size;
411
+ }
412
+ }
413
+ //# sourceMappingURL=missions.js.map
@@ -22,8 +22,8 @@ export function skillPromptDisabled(env = process.env) {
22
22
  return /^(1|true|yes)$/i.test(env.BRIDGE_NO_SKILL_PROMPT ?? '') || /^(1|true)$/i.test(env.CI ?? '');
23
23
  }
24
24
  /**
25
- * Offers, once, to install the antigravity-bridge skill for Codex / Claude Code. Nothing is written without an explicit answer:
26
- * it only asks in an interactive terminal, only for clients that have no skill file yet (a customized file is never touched),
25
+ * Offers, once, to install the skills this bridge ships for Codex / Claude Code. Nothing is written without an explicit answer:
26
+ * it only asks in an interactive terminal, only for clients that have no file yet for some skill (a customized file is never touched),
27
27
  * and the answer, including "no", is remembered so the question is not repeated. It never throws and never blocks the server.
28
28
  */
29
29
  export async function offerSkillInstall(options) {
@@ -39,19 +39,22 @@ export async function offerSkillInstall(options) {
39
39
  catch {
40
40
  return 'skipped';
41
41
  }
42
- const clients = ['codex', 'claude'].filter(client => status[client].state === 'missing');
42
+ const missing = (client) => status[client].filter(entry => entry.state === 'missing');
43
+ const clients = ['codex', 'claude'].filter(client => missing(client).length > 0);
43
44
  if (!clients.length)
44
45
  return 'skipped';
45
- log('[skill] The antigravity-bridge skill tells Codex / Claude Code how to delegate to Antigravity safely and how to review the result.');
46
+ log('[skill] These skills tell Codex / Claude Code how to delegate to the bridge agents safely and how to review the result.');
46
47
  for (const client of clients)
47
- log(`[skill] ${client}: would be written to ${status[client].file}`);
48
+ for (const entry of missing(client))
49
+ log(`[skill] ${client}: ${entry.skill} would be written to ${entry.file}`);
48
50
  for (const client of ['codex', 'claude'])
49
- if (status[client].state === 'different')
50
- log(`[skill] ${client}: a customized skill already exists and will not be touched (${status[client].file})`);
51
+ for (const entry of status[client])
52
+ if (entry.state === 'different')
53
+ log(`[skill] ${client}: a customized ${entry.skill} skill already exists and will not be touched (${entry.file})`);
51
54
  const choices = [...clients, ...(clients.length > 1 ? ['both'] : [])].join(' / ');
52
55
  let selected = null;
53
56
  for (let attempt = 0; attempt < 2 && selected === null; attempt++) {
54
- const answer = (await options.ask(`Install it now? [${choices} / N] `)).trim().toLowerCase();
57
+ const answer = (await options.ask(`Install them now? [${choices} / N] `)).trim().toLowerCase();
55
58
  if (answer === '' || answer === 'n' || answer === 'no') {
56
59
  await remember(options.stateDirectory, { skill_offer: { answered_at: new Date((options.now ?? Date.now)()).toISOString(), choice: 'declined' } });
57
60
  log('[skill] Not installed, and this question will not be asked again. Install later with: agy-mcp-bridge skill install --client both');
@@ -67,21 +70,24 @@ export async function offerSkillInstall(options) {
67
70
  if (!selected)
68
71
  return 'skipped';
69
72
  try {
70
- for (const client of selected)
71
- for (const item of await (options.install ?? installSkill)({ client, userHome: options.userHome }))
73
+ // Only the skills that are actually absent for that client: a customized file is never part of the request.
74
+ for (const client of selected) {
75
+ const skills = missing(client).map(entry => entry.skill);
76
+ for (const item of await (options.install ?? installSkill)({ client, userHome: options.userHome, skills }))
72
77
  log(`[skill] ${item.unchanged ? 'Already installed' : item.updated ? 'Updated' : 'Installed'}: ${item.filename}`);
78
+ }
73
79
  }
74
80
  catch (error) {
75
81
  log(`[skill] Could not install: ${error instanceof Error ? error.message : 'unknown error'}. The server starts anyway; try again with: agy-mcp-bridge skill install --client both`);
76
82
  return 'failed';
77
83
  }
78
84
  await remember(options.stateDirectory, { skill_offer: { answered_at: new Date((options.now ?? Date.now)()).toISOString(), choice: selected.join('+') } });
79
- log('[skill] Reopen the client if the skill does not appear. MCP connection and workspace permissions are configured separately (agy-mcp-bridge setup).');
85
+ log('[skill] Reopen the client if a skill does not appear. MCP connection and workspace permissions are configured separately (agy-mcp-bridge setup).');
80
86
  return 'installed';
81
87
  }
82
88
  /**
83
89
  * Offers to replace an installed skill that is an older version this project shipped. A file that matches no shipped version is the
84
- * owner's own edit and is neither offered nor touched. The answer, including "no", is remembered for the current packaged skill, so the
90
+ * owner's own edit and is neither offered nor touched. The answer, including "no", is remembered for the packaged set as it is now, so the
85
91
  * question comes back only when a newer skill ships. Same rules as the install offer: interactive terminals only, never throws.
86
92
  */
87
93
  export async function offerSkillUpdate(options) {
@@ -95,16 +101,18 @@ export async function offerSkillUpdate(options) {
95
101
  catch {
96
102
  return 'skipped';
97
103
  }
98
- const outdated = ['codex', 'claude'].filter(client => status[client].state === 'outdated');
104
+ const outdatedFor = (client) => status[client].filter(entry => entry.state === 'outdated');
105
+ const outdated = ['codex', 'claude'].filter(client => outdatedFor(client).length > 0);
99
106
  if (!outdated.length)
100
107
  return 'skipped';
101
108
  const previous = (await readRecord(options.stateDirectory)).skill_update_offer;
102
109
  if (previous?.hash === status.currentHash)
103
110
  return 'skipped';
104
111
  const when = () => new Date((options.now ?? Date.now)()).toISOString();
105
- log('[skill] A newer antigravity-bridge skill ships with this bridge. These installed copies are older packaged versions (not edited by you):');
112
+ log('[skill] Newer skills ship with this bridge. These installed copies are older packaged versions (not edited by you):');
106
113
  for (const client of outdated)
107
- log(`[skill] ${client}: ${status[client].file}`);
114
+ for (const entry of outdatedFor(client))
115
+ log(`[skill] ${client}: ${entry.skill} at ${entry.file}`);
108
116
  let yes = false;
109
117
  for (let attempt = 0; attempt < 2 && !yes; attempt++) {
110
118
  const answer = (await options.ask('Update them now? [y / N] ')).trim().toLowerCase();
@@ -121,9 +129,11 @@ export async function offerSkillUpdate(options) {
121
129
  if (!yes)
122
130
  return 'skipped';
123
131
  try {
124
- for (const client of outdated)
125
- for (const item of await (options.install ?? installSkill)({ client, userHome: options.userHome, update: true }))
132
+ for (const client of outdated) {
133
+ const skills = outdatedFor(client).map(entry => entry.skill);
134
+ for (const item of await (options.install ?? installSkill)({ client, userHome: options.userHome, update: true, skills }))
126
135
  log(`[skill] ${item.unchanged ? 'Already up to date' : item.updated ? 'Updated' : 'Installed'}: ${item.filename}`);
136
+ }
127
137
  }
128
138
  catch (error) {
129
139
  log(`[skill] Could not update: ${error instanceof Error ? error.message : 'unknown error'}. The server starts anyway; try again with: agy-mcp-bridge skill install --client both --update`);
@@ -3,6 +3,7 @@ import { helperDirectory, paths } from './paths.js';
3
3
  import { inspectCli, resolveCommand, startProcess, VERIFIED_AGY_VERSIONS } from './antigravity/process.js';
4
4
  import { createAgentRegistry, publicAgents } from './agents/registry.js';
5
5
  import { TaskManager } from './tasks/manager.js';
6
+ import { MissionManager, MAX_RETAINED_MISSIONS, MAX_STEPS_PER_MISSION } from './missions/missions.js';
6
7
  import { sweepStaleWorktrees, pruneWorktrees } from './isolation/worktree.js';
7
8
  import { startTreeWatch } from './isolation/orphans.js';
8
9
  import { recoverExecutionLock } from './tasks/execution-lock.js';
@@ -56,6 +57,11 @@ export async function createRuntime() {
56
57
  const restored = await manager.restore();
57
58
  if (restored)
58
59
  console.error(`[bridge] Restored ${restored} finished task record(s) from .bridge-state/tasks`);
60
+ // Mission step state is read from the task records above, so restoring plans afterwards costs nothing and cannot disagree with them.
61
+ const missions = new MissionManager(manager, config);
62
+ const restoredMissions = await missions.restore();
63
+ if (restoredMissions)
64
+ console.error(`[bridge] Restored ${restoredMissions} mission plan(s) from .bridge-state/missions (no step is dispatched by itself)`);
59
65
  const lockWarning = await manager.lockDiagnostic();
60
66
  if (lockWarning)
61
67
  console.error(`[bridge] WARNING: ${lockWarning}`);
@@ -69,17 +75,19 @@ export async function createRuntime() {
69
75
  default_agent: registry.defaultAgent,
70
76
  max_parallel_worktrees: maxParallelWorktrees,
71
77
  default_autonomy: config.default_autonomy ?? 'auto',
72
- supported: { delegate: anyAgent, status: true, wait: true, output: true, cancel_owned_process: true, agents_list: true, worktree_isolation: true, detached_resume: false },
78
+ supported: { delegate: anyAgent, status: true, wait: true, output: true, cancel_owned_process: true, agents_list: true, worktree_isolation: true, missions: true, detached_resume: false },
73
79
  allowed_workspaces: config.allowed_workspaces, session_workspaces: sessionOnlyWorkspaces(),
74
80
  authentication: 'Each agent CLI uses its own cached login; only a live task verifies it.',
75
- limits: { max_timeout_seconds: config.max_timeout_seconds, max_output_bytes_per_stream: config.max_output_bytes, max_retained_tasks: config.max_retained_tasks, concurrent_tasks: maxParallelWorktrees + 1, concurrent_normal_tasks: 1, max_parallel_worktrees: maxParallelWorktrees, max_queued_tasks: config.max_queued_tasks },
81
+ limits: { max_timeout_seconds: config.max_timeout_seconds, max_output_bytes_per_stream: config.max_output_bytes, max_retained_tasks: config.max_retained_tasks, max_mission_steps: MAX_STEPS_PER_MISSION, max_retained_missions: MAX_RETAINED_MISSIONS, concurrent_tasks: maxParallelWorktrees + 1, concurrent_normal_tasks: 1, max_parallel_worktrees: maxParallelWorktrees, max_queued_tasks: config.max_queued_tasks },
76
82
  limitations: [
77
83
  `Antigravity requires agy 1.4.x with all required flags; real execution was verified on ${VERIFIED_AGY_VERSIONS.join(' and ')}.`,
78
84
  'Codex runs non-interactively with sandbox workspace-write; approval-requiring actions still fail instead of bypassing the sandbox.',
79
85
  'Ordinary tasks are serialized globally. Only tasks with isolation "worktree" run concurrently, each in its own git worktree and branch.',
86
+ 'Mission plans are recorded and gated by the bridge, but the bridge never dispatches a step by itself: every step starts on a client call, and a canceled or completed plan grants nothing.',
80
87
  'Worktree tasks declare owns; files outside that list are reported for review and are never merged automatically.',
81
88
  ...antigravity.warnings,
82
89
  'Client disconnect does not cancel accepted tasks. Finished-task records persist in .bridge-state/tasks; a task running during a restart is marked failed and its process is not recovered.',
90
+ 'Mission plans persist in .bridge-state/missions across restarts, with the step states read back from those task records. The bridge does not re-dispatch anything after a restart.',
83
91
  'CLI SUCCESS/exit 0 does not establish acceptance; tool permissions may have been denied.',
84
92
  'Workspace preflight is not an OS sandbox and cannot prevent concurrent link swaps.',
85
93
  'Existing agent permissions, hooks, MCP and credential access remain governed by each CLI configuration.',
@@ -90,6 +98,6 @@ export async function createRuntime() {
90
98
  'Redaction recognizes known patterns only. Never include credentials in task data.'
91
99
  ]
92
100
  };
93
- return { manager, environment };
101
+ return { manager, missions, environment };
94
102
  }
95
103
  //# sourceMappingURL=runtime.js.map
package/dist/src/stdio.js CHANGED
@@ -2,8 +2,8 @@ import { serveStdio } from '@modelcontextprotocol/server/stdio';
2
2
  import { createRuntime } from './runtime.js';
3
3
  import { createServer } from './tools/register.js';
4
4
  async function main() {
5
- const { manager, environment } = await createRuntime();
6
- const handle = serveStdio(() => createServer(manager, { ...environment, transport: 'stdio' }), { onerror: () => console.error('[bridge] MCP transport error') });
5
+ const { manager, missions, environment } = await createRuntime();
6
+ const handle = serveStdio(() => createServer(manager, { ...environment, transport: 'stdio' }, missions), { onerror: () => console.error('[bridge] MCP transport error') });
7
7
  let shuttingDown = false;
8
8
  const shutdown = async () => {
9
9
  if (shuttingDown)