agy-mcp-bridge 0.1.33 → 0.1.35

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
@@ -1,4 +1,4 @@
1
- # agy-mcp-bridge
1
+ # agy-mcp-bridge.
2
2
 
3
3
  **An MCP server that hands bounded coding tasks to the CLI agents already logged in on your machine.** One TypeScript process speaks MCP Streamable HTTP on `127.0.0.1` and drives `agy` (Antigravity) and/or `codex`. Every task declares its workspace, acceptance criteria, which agent runs it, how isolated it is, and how much it may self-approve. The orchestrating agent (Codex, Claude Code, any MCP client) keeps the review; the bridge does the reading, writing and waiting.
4
4
 
@@ -5,6 +5,9 @@ import { captureOutput } from './probe.js';
5
5
  const CAPABILITIES = ['code', 'review', 'test', 'research', 'long-run'];
6
6
  const REQUIRED_FLAGS = ['--json', '--sandbox', '--cd', '--output-last-message', '--ephemeral', '--skip-git-repo-check'];
7
7
  const MAX_RESULT_BYTES = 1024 * 1024;
8
+ const MAX_DENIED = 20;
9
+ /** Output a sandboxed Windows or POSIX shell prints when it is not allowed to write: "Access ... is denied", "Permission denied", "Read-only file system". */
10
+ const SANDBOX_REFUSAL = /access\b[^\n]{0,200}\bdenied|permission denied|read-only file system|operation not permitted/i;
8
11
  export function codexAdapter(command, options = {}) {
9
12
  return {
10
13
  id: options.id ?? 'codex',
@@ -23,6 +26,9 @@ export function codexAdapter(command, options = {}) {
23
26
  args.unshift('--ask-for-approval', options.askForApproval);
24
27
  if (options.model)
25
28
  args.push('-m', options.model);
29
+ // One extra writable directory for this run, granted by the owner after a denial: `--add-dir` opens exactly the directory it names (measured on 0.163.0).
30
+ if (task.elevation?.kind === 'codex-add-dir')
31
+ args.push('--add-dir', task.elevation.dir);
26
32
  args.push('-');
27
33
  return { executable: command.executable, args, stdin: `${task.prompt}\n`, resultFile, customEnv: options.env };
28
34
  },
@@ -41,6 +47,7 @@ export function codexAdapter(command, options = {}) {
41
47
  const errors = [];
42
48
  const messages = [];
43
49
  const deniedActions = [];
50
+ let reported = 0;
44
51
  let turns = 0;
45
52
  let cliStatus = null;
46
53
  for (const event of events) {
@@ -56,6 +63,14 @@ export function codexAdapter(command, options = {}) {
56
63
  messages.push(item.text);
57
64
  if (typeof item.status === 'string' && ['declined', 'denied'].includes(item.status)) {
58
65
  deniedActions.push({ action: 'command', display_name: String(item.command ?? item.type ?? 'declined action').slice(0, 200) });
66
+ reported++;
67
+ }
68
+ // In `codex exec` a command the sandbox refuses is not "declined": it is a failed command whose output says so (measured on 0.163.0, no
69
+ // approval event, `on-request` and `never` alike). That is only a guess from text, so it is marked and never fails the task.
70
+ if (item.type === 'command_execution' && item.status === 'failed' && typeof item.aggregated_output === 'string' && SANDBOX_REFUSAL.test(item.aggregated_output) && deniedActions.length < MAX_DENIED) {
71
+ const shown = String(item.command ?? 'command').slice(0, 200);
72
+ if (!deniedActions.some(existing => existing.display_name === shown))
73
+ deniedActions.push({ action: 'command', display_name: shown, suspected: true });
59
74
  }
60
75
  }
61
76
  }
@@ -70,7 +85,7 @@ export function codexAdapter(command, options = {}) {
70
85
  const response = lastMessage.trim() ? lastMessage : messages.at(-1) ?? null;
71
86
  const result = { success: false, response, error: null, cliStatus, numTurns: turns || null, deniedActions };
72
87
  // A denied action is a failure even when the CLI exits 0 with a final message.
73
- if (deniedActions.length)
88
+ if (reported)
74
89
  return { ...result, error: 'Codex reported a denied action; review the approval policy before retrying' };
75
90
  if (raw.exitCode === 0 && response?.trim())
76
91
  return { ...result, success: true, numTurns: turns || 1 };
@@ -4,6 +4,46 @@ export const CAPABILITIES = ['code', 'review', 'test', 'research', 'long-run'];
4
4
  // Verified against opencode v2.2.5: `opencode run` reads the message from stdin and, with `--format json`, writes one JSON event per line.
5
5
  const REQUIRED_FLAGS = ['--format', '--model', '--auto', '--standalone'];
6
6
  const MAX_RESPONSE_CHARS = 1024 * 1024;
7
+ const MAX_DENIED = 20;
8
+ /** The error of a tool call that OpenCode refused because it could not ask (v2.2.5: "This non-interactive run cannot ask the user for permission, ..."). */
9
+ const PERMISSION_REJECTED = /cannot ask the user for permission/i;
10
+ /** What a refused tool call was asked to do, in a few words: the command of a shell call, the path of a file call, else the names of its arguments. */
11
+ function summarizeToolInput(tool, input) {
12
+ if (typeof input === 'string')
13
+ return input.slice(0, 200);
14
+ if (!input || typeof input !== 'object' || Array.isArray(input))
15
+ return '';
16
+ const value = input;
17
+ const pick = typeof value.command === 'string' ? value.command : typeof value.path === 'string' ? value.path : typeof value.filePath === 'string' ? value.filePath : null;
18
+ return (pick ?? Object.keys(value).slice(0, 5).join(', ')).slice(0, 200);
19
+ }
20
+ /**
21
+ * The OPENCODE_CONFIG_CONTENT of one run with `permission` set to allow for a single permission, keeping whatever else the profile already set.
22
+ * A value that is not a JSON object is refused rather than overwritten: this is a permission being widened, so it must be exactly what was meant.
23
+ */
24
+ function withAllowedPermission(env, permission) {
25
+ let config = {};
26
+ const existing = env?.OPENCODE_CONFIG_CONTENT;
27
+ if (existing !== undefined) {
28
+ try {
29
+ const parsed = JSON.parse(existing);
30
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
31
+ throw new Error('not an object');
32
+ config = parsed;
33
+ }
34
+ catch {
35
+ throw new Error('Cannot widen this run: the profile\'s OPENCODE_CONFIG_CONTENT is not a JSON object');
36
+ }
37
+ }
38
+ // A plain "allow" for everything already covers it; any other value is replaced for this one permission only.
39
+ if (config.permission === 'allow')
40
+ return { ...env };
41
+ // A single string such as "ask" applies to every permission; turning it into a per-permission object would silently loosen the others.
42
+ if (typeof config.permission === 'string')
43
+ throw new Error('Cannot widen this run: the profile sets one permission for everything, so a single permission cannot be opened on its own');
44
+ const current = config.permission && typeof config.permission === 'object' && !Array.isArray(config.permission) ? config.permission : {};
45
+ return { ...env, OPENCODE_CONFIG_CONTENT: JSON.stringify({ ...config, permission: { ...current, [permission]: 'allow' } }) };
46
+ }
7
47
  export function opencodeAdapter(command, options = {}) {
8
48
  return {
9
49
  id: options.id ?? 'opencode',
@@ -28,11 +68,17 @@ export function opencodeAdapter(command, options = {}) {
28
68
  // instruction to the agent, never a permission grant, so it must not reach a flag that takes approval away.
29
69
  if (options.autoApprove)
30
70
  args.push('--auto');
71
+ // Permission the owner granted this one run after a denial. It reaches OpenCode only through this run's arguments and environment.
72
+ let env = options.env;
73
+ if (task.elevation?.kind === 'opencode-auto')
74
+ args.push('--auto');
75
+ else if (task.elevation?.kind === 'opencode-allow')
76
+ env = withAllowedPermission(env, task.elevation.permission);
31
77
  return {
32
78
  executable: command.executable,
33
79
  args: [...command.prefixArgs, ...args],
34
80
  stdin: `${task.prompt}\n`,
35
- customEnv: options.env
81
+ customEnv: env
36
82
  };
37
83
  },
38
84
  compactLine(line) {
@@ -52,8 +98,14 @@ export function opencodeAdapter(command, options = {}) {
52
98
  if (type === 'step_start')
53
99
  return null;
54
100
  // A tool event carries the file or command output it produced: keep which tool ran and how it ended, not the payload.
55
- if (type === 'tool_use')
56
- return JSON.stringify({ type, part: { tool: part?.tool, state: { status: part?.state?.status } } });
101
+ // A tool that failed also keeps its error text and a short note of what it was asked to do: that is how a refused permission is recognized.
102
+ if (type === 'tool_use') {
103
+ const state = part?.state;
104
+ const failed = state?.status === 'error';
105
+ return JSON.stringify({ type, part: { tool: part?.tool, state: {
106
+ status: state?.status, ...(failed ? { error: String(state?.error ?? '').slice(0, 300), input: summarizeToolInput(part?.tool, state?.input) } : {})
107
+ } } });
108
+ }
57
109
  return line.length > 2000 ? JSON.stringify({ type }) : line;
58
110
  },
59
111
  async parseResult(raw) {
@@ -64,6 +116,7 @@ export function opencodeAdapter(command, options = {}) {
64
116
  let inputTokens = 0;
65
117
  let outputTokens = 0;
66
118
  let sawTokens = false;
119
+ const denied = [];
67
120
  for (const line of raw.stdout.split(/\r?\n/)) {
68
121
  if (!line.trim())
69
122
  continue;
@@ -80,6 +133,16 @@ export function opencodeAdapter(command, options = {}) {
80
133
  const part = event.part;
81
134
  if (event.type === 'text' && typeof part?.text === 'string')
82
135
  texts.push(part.text);
136
+ else if (event.type === 'tool_use') {
137
+ // `permission` set to ask makes a non-interactive run reject the request on the spot and carry on (measured on v2.2.5).
138
+ const state = part?.state;
139
+ if (state?.status === 'error' && PERMISSION_REJECTED.test(String(state.error ?? '')) && denied.length < MAX_DENIED) {
140
+ const tool = String(part?.tool ?? 'tool').slice(0, 60);
141
+ const note = summarizeToolInput(part?.tool, state.input);
142
+ if (!denied.some(item => item.action === tool && item.display_name === (note ? `${tool}: ${note}` : tool)))
143
+ denied.push({ action: tool, display_name: note ? `${tool}: ${note}` : tool });
144
+ }
145
+ }
83
146
  else if (event.type === 'error') {
84
147
  const error = event.error;
85
148
  errors.push(typeof error?.message === 'string' ? error.message : typeof error?.type === 'string' ? error.type : 'OpenCode reported an error');
@@ -102,7 +165,7 @@ export function opencodeAdapter(command, options = {}) {
102
165
  // The last text event is the final answer; earlier ones are narration between tool calls.
103
166
  const response = texts.at(-1)?.slice(0, MAX_RESPONSE_CHARS).trim() || null;
104
167
  const base = {
105
- success: false, response, error: null, cliStatus: finishReason, numTurns: turns || null, deniedActions: [],
168
+ success: false, response, error: null, cliStatus: finishReason, numTurns: turns || null, deniedActions: denied,
106
169
  stats: sawTokens ? { usage: { input_tokens: inputTokens, output_tokens: outputTokens }, agy_duration_seconds: null, num_turns: turns || null, steps: turns || null } : undefined
107
170
  };
108
171
  if (raw.exitCode === 0 && !errors.length && response)
@@ -0,0 +1,79 @@
1
+ import path from 'node:path';
2
+ import { z } from 'zod';
3
+ /**
4
+ * The owner's approval queue (docs/APPROVAL-QUEUE-PLAN.md): a task that ended with actions its agent was refused can be run again, as a new task,
5
+ * with a little more permission for that one run, or be dismissed. No CLI the bridge drives lets a person approve in the middle of a run, so
6
+ * this is approval afterwards. Nothing here widens a setting: the permission granted is an Elevation stored on the new task and applied to its
7
+ * own launch only, and the only way to create one is the owner's confirmed request in the web UI (there is no MCP tool for it).
8
+ */
9
+ /** Strict on purpose: an unknown kind or field is refused, not ignored. */
10
+ export const elevationSchema = z.discriminatedUnion('kind', [
11
+ z.object({ kind: z.literal('agy-level'), level: z.enum(['accept-edits', 'auto', 'full']) }).strict(),
12
+ z.object({ kind: z.literal('codex-add-dir'), dir: z.string().min(1).max(4096) }).strict(),
13
+ z.object({ kind: z.literal('opencode-allow'), permission: z.enum(['bash', 'edit']) }).strict(),
14
+ z.object({ kind: z.literal('opencode-auto') }).strict()
15
+ ]);
16
+ export function parseElevation(value) {
17
+ const parsed = elevationSchema.safeParse(value);
18
+ if (!parsed.success)
19
+ throw new Error('elevation is not one of the permissions this agent can be given');
20
+ return parsed.data;
21
+ }
22
+ /** OpenCode names the tool (`shell`, `write`, ...), its permission config names the permission (`bash`, `edit`). */
23
+ const OPENCODE_PERMISSION = { shell: 'bash', bash: 'bash', write: 'edit', edit: 'edit', patch: 'edit', multiedit: 'edit' };
24
+ export function opencodePermissionsOf(denied) {
25
+ return [...new Set(denied.flatMap(item => OPENCODE_PERMISSION[item.action.toLowerCase()] ?? []))];
26
+ }
27
+ /** What the owner may pick for a task of this agent family that was refused these actions. Empty when there is no narrow way (the queue then only dismisses). */
28
+ export function elevationOptions(family, denied) {
29
+ if (!denied.length)
30
+ return [];
31
+ if (family === 'antigravity') {
32
+ return [
33
+ { kind: 'agy-level', elevation: { kind: 'agy-level', level: 'accept-edits' }, needs_dir: false, narrowest: true, wide: false },
34
+ { kind: 'agy-level', elevation: { kind: 'agy-level', level: 'auto' }, needs_dir: false, narrowest: false, wide: false },
35
+ { kind: 'agy-level', elevation: { kind: 'agy-level', level: 'full' }, needs_dir: false, narrowest: false, wide: true }
36
+ ];
37
+ }
38
+ if (family === 'codex')
39
+ return [{ kind: 'codex-add-dir', elevation: null, needs_dir: true, narrowest: true, wide: false }];
40
+ const allowed = opencodePermissionsOf(denied);
41
+ return [
42
+ ...allowed.map((permission) => ({ kind: 'opencode-allow', elevation: { kind: 'opencode-allow', permission }, needs_dir: false, narrowest: true, wide: false })),
43
+ { kind: 'opencode-auto', elevation: { kind: 'opencode-auto' }, needs_dir: false, narrowest: false, wide: true }
44
+ ];
45
+ }
46
+ /** Why an elevation may not be given to this task, or null when it may. The directory itself is checked on disk by the caller. */
47
+ export function elevationRefusal(family, denied, elevation) {
48
+ const offered = elevationOptions(family, denied);
49
+ if (!offered.some(option => option.kind === elevation.kind))
50
+ return `This agent cannot be given "${elevation.kind}"`;
51
+ if (elevation.kind === 'agy-level')
52
+ return null;
53
+ if (elevation.kind === 'opencode-auto')
54
+ return null;
55
+ if (elevation.kind === 'opencode-allow')
56
+ return opencodePermissionsOf(denied).includes(elevation.permission) ? null : `The task was not refused "${elevation.permission}"`;
57
+ const dir = elevation.dir;
58
+ if (dir.includes('\0') || !path.isAbsolute(dir))
59
+ return 'The directory must be an absolute path';
60
+ if (path.parse(path.resolve(dir)).root === path.resolve(dir))
61
+ return 'A drive or filesystem root cannot be opened to an agent';
62
+ return null;
63
+ }
64
+ /** The agent family an elevation belongs to: one given for codex must never reach an opencode launch, and the other way round. */
65
+ export const elevationFamily = (elevation) => elevation.kind === 'agy-level' ? 'antigravity' : elevation.kind === 'codex-add-dir' ? 'codex' : 'opencode';
66
+ /** One line for the decision log and the page: what was granted, in the CLI's own terms. */
67
+ export function describeElevation(elevation) {
68
+ switch (elevation.kind) {
69
+ case 'agy-level': return elevation.level === 'full' ? 'antigravity --dangerously-skip-permissions' : `antigravity --mode ${elevation.level}`;
70
+ case 'codex-add-dir': return `codex --add-dir ${elevation.dir}`;
71
+ case 'opencode-allow': return `opencode permission ${elevation.permission}=allow`;
72
+ case 'opencode-auto': return 'opencode --auto';
73
+ }
74
+ }
75
+ /** Waiting for the owner: finished, refused something, and not yet rerun or dismissed. */
76
+ export function isPendingApproval(task) {
77
+ return task.denied_actions.length > 0 && task.status !== 'queued' && task.status !== 'running' && !task.approval;
78
+ }
79
+ //# sourceMappingURL=approvals.js.map
@@ -7,6 +7,7 @@ import path from 'node:path';
7
7
  export const DECISIONS_FILE = 'decisions.jsonl';
8
8
  const MAX_FILE_BYTES = 512 * 1024;
9
9
  const KEEP_RECORDS = 500;
10
+ const ACTIONS = ['apply', 'discard', 'approve', 'rerun', 'dismiss'];
10
11
  export async function appendDecision(stateDirectory, decision) {
11
12
  const file = path.join(stateDirectory, DECISIONS_FILE);
12
13
  try {
@@ -34,7 +35,7 @@ export async function readDecisions(stateDirectory, limit = 50) {
34
35
  continue;
35
36
  try {
36
37
  const value = JSON.parse(line);
37
- if (typeof value.at === 'string' && typeof value.task_id === 'string' && (value.action === 'apply' || value.action === 'discard') && typeof value.ok === 'boolean') {
38
+ if (typeof value.at === 'string' && typeof value.task_id === 'string' && ACTIONS.includes(value.action) && typeof value.ok === 'boolean') {
38
39
  records.push({ at: value.at, task_id: value.task_id, action: value.action, ok: value.ok, detail: String(value.detail ?? '').slice(0, 500) });
39
40
  }
40
41
  }
@@ -1,5 +1,5 @@
1
1
  import { existsSync, statSync } from 'node:fs';
2
- import { mkdir, readdir, readFile, realpath, rename, rmdir, unlink, writeFile } from 'node:fs/promises';
2
+ import { mkdir, readdir, readFile, realpath, rename, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { StringDecoder } from 'node:string_decoder';
5
5
  import path from 'node:path';
@@ -12,6 +12,7 @@ import { redact } from '../security/redact.js';
12
12
  import { delegationState } from '../delegation.js';
13
13
  import { startProcess, terminateOwned } from '../antigravity/process.js';
14
14
  import { legacyAntigravityAgent } from '../agents/antigravity.js';
15
+ import { describeElevation, elevationFamily, elevationOptions, elevationRefusal, isPendingApproval, parseElevation } from '../approvals.js';
15
16
  import { createWorktree, inspectScope, removeWorktree } from '../isolation/worktree.js';
16
17
  import { reapOwnedTree } from '../isolation/orphans.js';
17
18
  import { appendUsage, totalTokens } from '../usage.js';
@@ -125,11 +126,14 @@ export class TaskManager {
125
126
  }
126
127
  /** Calls are admitted strictly in the order they arrive: workspace validation is asynchronous and must not let a later call overtake an earlier one. */
127
128
  delegate(raw, meta = {}) {
128
- const admitted = this.admission.then(() => this.admit(raw, meta));
129
+ return this.admitInOrder(raw, meta);
130
+ }
131
+ admitInOrder(raw, meta, rerun) {
132
+ const admitted = this.admission.then(() => this.admit(raw, meta, rerun));
129
133
  this.admission = admitted.then(() => undefined, () => undefined);
130
134
  return admitted;
131
135
  }
132
- async admit(raw, meta) {
136
+ async admit(raw, meta, rerun) {
133
137
  if (this.closing)
134
138
  throw new Error('Bridge is shutting down');
135
139
  const delegation = delegationState(this.runtime.stateDirectory);
@@ -178,7 +182,8 @@ export class TaskManager {
178
182
  exit_code: null, signal: null,
179
183
  stdout: '', stderr: '', stdout_bytes: 0, stderr_bytes: 0,
180
184
  stdout_truncated: false, stderr_truncated: false,
181
- final_response: null, cli_status: null, error: null, denied_actions: [], review_required: true, acceptance_verified: false
185
+ final_response: null, cli_status: null, error: null, denied_actions: [], review_required: true, acceptance_verified: false,
186
+ ...(rerun ? { retry_of: rerun.retryOf, elevation: rerun.elevation } : {})
182
187
  };
183
188
  const pools = this.config.agents?.pools ?? {};
184
189
  const poolId = (input.agent && pools[input.agent]) ? input.agent : (!input.agent && this.defaultAgent && pools[this.defaultAgent] ? this.defaultAgent : undefined);
@@ -314,7 +319,11 @@ export class TaskManager {
314
319
  }
315
320
  }
316
321
  // The level in force when the task was accepted, or the current one if the owner has lowered it since: lowering applies to queued tasks too, raising never does.
317
- const permission = lowerLevel(entry.request?.permission ?? 'default', permissionState(this.runtime.stateDirectory).level);
322
+ // A run the owner elevated from the approval queue uses the level they chose for it instead: that is the point of the grant, and it ends with this run.
323
+ const elevation = task.elevation ?? undefined;
324
+ if (elevation && agent.adapter.family !== elevationFamily(elevation))
325
+ throw new Error('The permission granted to this run does not belong to its agent');
326
+ const permission = elevation?.kind === 'agy-level' ? elevation.level : lowerLevel(entry.request?.permission ?? 'default', permissionState(this.runtime.stateDirectory).level);
318
327
  if (entry.request)
319
328
  entry.request.permission = permission;
320
329
  // Soft deadline (owner setting, off by default): agy gets the absolute limit as its own print timeout and a log file whose growth shows it is alive.
@@ -340,7 +349,7 @@ export class TaskManager {
340
349
  autonomy, owns: task.owns, taskId: task.task_id, stateDirectory: this.runtime.stateDirectory,
341
350
  // The task's model is a per-family setting: a codex model handed to another family would override that family's own.
342
351
  model: antigravity ? entry.request?.model ?? null : agent.adapter.family === 'codex' ? this.config.agents?.codex_model : undefined,
343
- extraArgs, promptTransport: transport
352
+ extraArgs, promptTransport: transport, elevation
344
353
  });
345
354
  if (invocation.resultFile)
346
355
  await mkdir(path.dirname(invocation.resultFile), { recursive: true });
@@ -529,7 +538,8 @@ export class TaskManager {
529
538
  task.cli_status = result.cliStatus;
530
539
  task.final_response = result.response === null ? null : redact(result.response);
531
540
  task.denied_actions = result.deniedActions.slice(0, 100).map(item => ({
532
- action: redact(item.action).slice(0, 200), display_name: redact(item.display_name).slice(0, 200)
541
+ action: redact(item.action).slice(0, 200), display_name: redact(item.display_name).slice(0, 200),
542
+ ...(item.suspected ? { suspected: true } : {})
533
543
  }));
534
544
  if (result.timeout) {
535
545
  finalStatus = 'timeout';
@@ -833,6 +843,97 @@ export class TaskManager {
833
843
  return this.summary(id);
834
844
  });
835
845
  }
846
+ /**
847
+ * The approval queue (docs/APPROVAL-QUEUE-PLAN.md). A task whose agent was refused something waits here; the owner, and only the owner, at the page,
848
+ * either runs it again as a NEW task with a little more permission for that one run, or sets the refusal aside. Both go through the same one-at-a-time
849
+ * queue as apply and discard, so a double click cannot start two reruns.
850
+ */
851
+ familyOf(agentId) { return this.agents.find(item => item.adapter.id === agentId)?.adapter.family ?? null; }
852
+ approvals() {
853
+ const views = [];
854
+ for (const [id, entry] of this.entries) {
855
+ const task = entry.task;
856
+ if (!task.denied_actions.length || task.status === 'queued' || task.status === 'running')
857
+ continue;
858
+ const family = this.familyOf(task.agent);
859
+ views.push({
860
+ task_id: id, agent: task.agent, family, status: task.status, ended_at: task.ended_at, workspace: task.workspace, isolation: task.isolation,
861
+ title: entry.request?.task.split('\n').find(line => line.trim())?.trim().slice(0, 140) ?? null,
862
+ denied: task.denied_actions.map(item => ({ ...item })), options: family ? elevationOptions(family, task.denied_actions) : [],
863
+ state: task.approval?.state ?? 'pending', rerun_task_id: task.approval?.task_id ?? null, retry_of: task.retry_of ?? null
864
+ });
865
+ }
866
+ return views.sort((a, b) => Date.parse(b.ended_at ?? '') - Date.parse(a.ended_at ?? '')).slice(0, 100);
867
+ }
868
+ approveTask(id, requested) {
869
+ return this.enqueueDecision(async () => {
870
+ const entry = this.entries.get(id);
871
+ if (!entry)
872
+ throw new Error('Unknown or expired task_id');
873
+ const original = entry.task;
874
+ if (original.approval)
875
+ throw new Error(original.approval.state === 'rerun' ? 'This task was already run again' : 'This task was already dismissed');
876
+ if (!isPendingApproval(original))
877
+ throw new Error(original.status === 'queued' || original.status === 'running' ? 'The task is still running' : 'This task has no refused actions waiting for approval');
878
+ const refuse = async (reason) => { await this.recordDecision(id, 'approve', false, reason); throw new Error(reason); };
879
+ const request = entry.request;
880
+ if (!request)
881
+ return refuse('The recorded request of this task is gone, so it cannot be run again');
882
+ const family = this.familyOf(original.agent);
883
+ if (!family)
884
+ return refuse(`Agent "${original.agent}" is no longer configured`);
885
+ let elevation;
886
+ try {
887
+ elevation = parseElevation(requested);
888
+ }
889
+ catch (error) {
890
+ return refuse(error instanceof Error ? error.message : 'Invalid permission');
891
+ }
892
+ const why = elevationRefusal(family, original.denied_actions, elevation);
893
+ if (why)
894
+ return refuse(why);
895
+ if (elevation.kind === 'codex-add-dir') {
896
+ const info = await stat(elevation.dir).catch(() => null);
897
+ if (!info?.isDirectory())
898
+ return refuse('The directory does not exist');
899
+ }
900
+ // The new task is built from the request as it was recorded, never from anything the agent wrote.
901
+ const raw = {
902
+ task: request.task, workspace: original.workspace, acceptance_criteria: request.acceptance_criteria,
903
+ ...(request.context ? { context: request.context } : {}), timeout_seconds: request.timeout_seconds,
904
+ agent: request.agent ?? original.agent, isolation: request.isolation ?? original.isolation,
905
+ owns: request.owns ?? original.owns, autonomy: request.autonomy ?? original.autonomy
906
+ };
907
+ let created;
908
+ try {
909
+ created = await this.admitInOrder(raw, { client: request.client ?? null }, { retryOf: id, elevation });
910
+ }
911
+ catch (error) {
912
+ return refuse(`The new run could not be started: ${error instanceof Error ? redact(error.message) : 'unknown error'}`);
913
+ }
914
+ original.approval = { state: 'rerun', at: new Date().toISOString(), task_id: created.task_id };
915
+ await this.persist(original);
916
+ await this.recordDecision(id, 'approve', true, describeElevation(elevation));
917
+ await this.recordDecision(id, 'rerun', true, `task ${created.task_id}`);
918
+ return { task: created, rerun_of: id };
919
+ });
920
+ }
921
+ dismissApproval(id) {
922
+ return this.enqueueDecision(async () => {
923
+ const entry = this.entries.get(id);
924
+ if (!entry)
925
+ throw new Error('Unknown or expired task_id');
926
+ const task = entry.task;
927
+ if (task.approval)
928
+ throw new Error(task.approval.state === 'rerun' ? 'This task was already run again' : 'This task was already dismissed');
929
+ if (!isPendingApproval(task))
930
+ throw new Error(task.status === 'queued' || task.status === 'running' ? 'The task is still running' : 'This task has no refused actions waiting for approval');
931
+ task.approval = { state: 'dismissed', at: new Date().toISOString() };
932
+ await this.persist(task);
933
+ await this.recordDecision(id, 'dismiss', true, `${task.denied_actions.length} refused action(s) set aside`);
934
+ return this.summary(id);
935
+ });
936
+ }
836
937
  get(id) {
837
938
  const entry = this.entries.get(id);
838
939
  if (!entry)
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The owner's approval queue over HTTP (docs/APPROVAL-QUEUE-PLAN.md). Only the web UI can reach it: there is deliberately no MCP tool to read the
3
+ * queue, approve, or raise a permission, so an agent or a client cannot approve what its own run was refused. Every change needs `confirm: true`.
4
+ */
5
+ const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
6
+ function json(res, code, value) {
7
+ res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' });
8
+ res.end(JSON.stringify(value));
9
+ }
10
+ async function readJson(req) {
11
+ const chunks = [];
12
+ let size = 0;
13
+ for await (const chunk of req) {
14
+ size += chunk.length;
15
+ if (size > 4096)
16
+ throw new Error('Request body too large');
17
+ chunks.push(chunk);
18
+ }
19
+ const value = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
20
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
21
+ throw new Error('Expected a JSON object');
22
+ return value;
23
+ }
24
+ /** Returns true when it answered the request. Errors are thrown to the caller, which turns them into a 400 (404 for an unknown task). */
25
+ export async function handleReviewRoutes(req, res, ctx, pathname, method) {
26
+ if (pathname === '/ui/api/approvals' && method === 'GET') {
27
+ json(res, 200, { approvals: ctx.manager.approvals() });
28
+ return true;
29
+ }
30
+ const approve = pathname.match(new RegExp(`^/ui/api/tasks/(${UUID})/approve$`));
31
+ if (approve && method === 'POST') {
32
+ const body = await readJson(req);
33
+ if (body.confirm !== true) {
34
+ json(res, 400, { error: 'confirm must be true' });
35
+ return true;
36
+ }
37
+ const result = await ctx.manager.approveTask(approve[1], body.elevation);
38
+ json(res, 200, { rerun_of: result.rerun_of, task: ctx.manager.summary(result.task.task_id) });
39
+ return true;
40
+ }
41
+ const dismiss = pathname.match(new RegExp(`^/ui/api/tasks/(${UUID})/dismiss$`));
42
+ if (dismiss && method === 'POST') {
43
+ const body = await readJson(req);
44
+ if (body.confirm !== true) {
45
+ json(res, 400, { error: 'confirm must be true' });
46
+ return true;
47
+ }
48
+ json(res, 200, { task: await ctx.manager.dismissApproval(dismiss[1]) });
49
+ return true;
50
+ }
51
+ return false;
52
+ }
53
+ //# sourceMappingURL=review-routes.js.map
@@ -16,6 +16,7 @@ import { MAX_ABSOLUTE_LIMIT_SECONDS, MIN_ABSOLUTE_LIMIT_SECONDS, isAbsoluteLimit
16
16
  import { MAX_SETTING_WAIT_SECONDS, MIN_WAIT_SECONDS, clientToolTimeoutSeconds, isWaitSeconds, writeWait } from '../wait-setting.js';
17
17
  import { PERMISSION_LEVELS, isPermissionLevel, needsAcknowledgement, permissionState, writePermission } from '../permissions.js';
18
18
  import { inside } from '../security/workspace.js';
19
+ import { handleReviewRoutes } from './review-routes.js';
19
20
  const assets = {
20
21
  '/ui': { file: 'index.html', type: 'text/html; charset=utf-8' },
21
22
  '/ui/': { file: 'index.html', type: 'text/html; charset=utf-8' },
@@ -163,6 +164,8 @@ export async function handleUi(req, res, ctx) {
163
164
  json(res, 200, await budgetState(ctx.manager.stateDirectory, tzOffset));
164
165
  return true;
165
166
  }
167
+ if (await handleReviewRoutes(req, res, ctx, pathname, method))
168
+ return true;
166
169
  const detail = pathname.match(new RegExp(`^/ui/api/tasks/(${UUID})$`));
167
170
  if (detail && method === 'GET') {
168
171
  json(res, 200, ctx.manager.detail(detail[1]));
@@ -1,6 +1,6 @@
1
1
  # Hàng đợi duyệt cho hành động ngoài chính sách (Phase 4) — điều tra và thiết kế
2
2
 
3
- **Trạng thái:** B1, chỉ tài liệu. Chưa có mã hàng đợi. Phần cài đặt (B2) chỉ bắt đầu khi chủ dự án đồng ý thiết kế ở mục 5–6 và trả lời các câu hỏi ở mục 8.
3
+ **Trạng thái:** B2 đã cài đặt theo thiết kế "duyệt sau" sau khi chủ dự án duyệt; những chỗ khác thiết kế và bằng chứng kiểm thật nằm ở mục 9. Các mục 1–8 là điều tra và thiết kế gốc, giữ nguyên.
4
4
 
5
5
  `docs/MULTI-AGENT-PLAN.md` (Phase 4) ghi "hàng đợi duyệt cho hành động ngoài chính sách". Điều chưa ai biết là **agent có dừng lại chờ duyệt giữa chừng được không**. Tài liệu này đo điều đó trên máy thật, rồi thiết kế đúng theo những gì đo được.
6
6
 
@@ -159,3 +159,42 @@ Ngoài MVP và chưa kiểm: duyệt giữa chừng (API server của OpenCode c
159
159
  4. **Codex "nghi ngờ"** (suy ra từ văn bản): có đưa vào hàng đợi (gắn nhãn suy đoán), hay chỉ làm khi CLI báo `declined`/`denied` thật?
160
160
  5. **`agy`**: đăng nhập `agy` trên máy này để đo mục 2.1 trước khi viết mã, hay chấp nhận thiết kế dựa trên bằng chứng cũ và cờ trong `--help`?
161
161
  6. Có làm việc ngoài phạm vi ở mục 7 (đặc biệt mục 1, thiếu nhận diện hết hạn mức OpenCode) thành PR riêng không?
162
+
163
+ ## 9. Đã cài đặt (B2): khác với thiết kế ở trên, và kiểm thật
164
+
165
+ **Chốt cho các câu hỏi ở mục 8** (chủ dự án duyệt thiết kế, các câu hỏi còn lại lấy phương án đề xuất): (1) "duyệt sau" là MVP; (2) task worktree chạy lại dùng
166
+ **worktree mới từ HEAD** (tự nhiên có được vì lần chạy lại là một task mới), worktree cũ giữ nguyên; (3) task có `denied_actions` ở OpenCode/Codex **giữ nguyên
167
+ trạng thái** (`completed` kèm hành động bị từ chối) để không đổi hành vi mission và failover; (4) codex "nghi ngờ" **có** vào hàng đợi, gắn `suspected: true` và chỉ
168
+ là suy đoán; (5) `agy` chưa đo lại (vẫn chưa đăng nhập): thiết kế dựa trên bằng chứng cũ và `--help`; (6) các quan sát ở mục 7 chưa sửa trong PR này.
169
+
170
+ **Khác với mục 5:**
171
+
172
+ - Trạng thái "đã chạy lại / đã bỏ qua" nằm **trên bản ghi task** (`approval: {state, at, task_id}`), không suy từ `decisions.jsonl` như mục 5.5 đã viết: nhật ký bị cắt
173
+ khi dài, bản ghi task thì không, và hàng đợi còn nguyên sau khi khởi động lại bridge (đã thử).
174
+ - `dismiss` là một giá trị mới của `DecisionAction` (cùng `approve` và `rerun`).
175
+ - `codex --sandbox danger-full-access` **không** được cài đặt: chưa đo, và là mức không có sandbox. Với codex chỉ có `--add-dir <thư mục>`; hành động không phải ghi
176
+ ngoài workspace (lệnh, mạng) thì hàng đợi chỉ cho bỏ qua.
177
+ - Quyền nâng không vượt qua họ agent: một `Elevation` của codex không bao giờ tới lần launch của opencode và ngược lại (kiểm lúc duyệt và lúc launch).
178
+ - OpenCode: một quyền cho từng lần chạy là bản vá `permission` trong `OPENCODE_CONFIG_CONTENT` của đúng lần launch đó, gộp vào cấu hình profile đã có; nếu profile đặt **một chuỗi
179
+ cho mọi quyền** (ví dụ `"permission": "ask"`) thì từ chối thay vì làm lỏng các quyền khác.
180
+
181
+ **Kiểm thật trên giao diện** (bridge cô lập: `BRIDGE_HOME` và `ANTIGRAVITY_BRIDGE_CONFIG` tạm, cổng 3999, repo scratch; `opencode` 2.2.5 thật, model free; profile `oc-ask` có
182
+ `permission: {bash: ask, edit: ask}` trong `OPENCODE_CONFIG_CONTENT`):
183
+
184
+ 1. Giao một task qua MCP (`antigravity_delegate`, agent `oc-ask`): OpenCode bị từ chối lệnh shell; task có `denied_actions` =
185
+ `shell: cd …\ws && echo IN_OK > inside.txt` (đọc từ sự kiện `tool_use` thật). Thanh bên hiện huy hiệu **1** ở mục "Duyệt".
186
+ 2. Khởi động lại bridge trên cùng thư mục: hàng đợi **còn nguyên** (yêu cầu vẫn "chờ duyệt").
187
+ 3. Ở trang Duyệt chọn "Cho phép chạy lệnh shell" (mức hẹp nhất, được chọn sẵn; `--auto` hiện nhãn "rộng") và bấm "Cho phép và chạy lại". Hộp thoại xác nhận ghi rõ
188
+ quyền, phạm vi và rằng chỉ cho một lần chạy.
189
+ 4. Task mới có `retry_of` và `elevation: {kind: "opencode-allow", permission: "bash"}`; task cũ thành `approval.state = "rerun"`; nhật ký có `approve` rồi `rerun`.
190
+ **`inside.txt` chứa `IN_OK` được tạo trong workspace** và lần chạy lại **không bị từ chối** (`denied_actions` rỗng) dù profile vẫn đặt `bash: ask`. Trang task của lần chạy
191
+ lại hiện ghi chú "lần chạy lại của task …". Trang Duyệt chuyển yêu cầu sang "Đã xử lý" kèm liên kết tới task mới.
192
+
193
+ Điều kiểm thật tìm ra: một `null` trong mảng trả về của trang Duyệt hiện thành chữ "null" ở cuối trang (`replaceChildren` biến `null` thành văn bản); đã sửa.
194
+
195
+ **Hạn chế đã thấy khi kiểm thật (chưa xử lý):**
196
+
197
+ - Một run kết thúc vì **hết giờ hoặc bị hủy** không đọc kết quả của CLI (`parseResult` bị bỏ qua), nên các lần từ chối trong run đó **không** vào hàng đợi. OpenCode, khi bị từ chối, hay
198
+ thử cách khác cho tới khi hết giờ (đã thấy: model tiếp tục dùng tool `execute` của MCP); chỉ dẫn "bị từ chối thì dừng và báo" trong prompt làm run kết thúc đúng lúc.
199
+ - Hạn mức miễn phí của OpenCode làm run dừng giữa chừng (`provider.free-limit`); lần từ chối trước đó vẫn được ghi lại, nhưng task `failed` vì lý do khác.
200
+ - Chưa đo: bản vá `permission` qua `OPENCODE_CONFIG_CONTENT` thắng cấu hình **toàn cục** của người dùng (chỉ thấy nó thắng cấu hình `ask` của chính profile); `agy` ở mọi khía cạnh; Linux, macOS.
@@ -252,8 +252,12 @@ ngân sách token theo mission — usage vẫn chỉ được ghi và tổng h
252
252
  - Chỉ chủ bridge làm được việc này qua web UI (`POST /ui/api/tasks/<id>/apply|discard` với `confirm: true`). Không có tool MCP tương ứng, nên một
253
253
  agent hay client không tự áp dụng kết quả của chính nó.
254
254
 
255
- Chưa làm của Phase 4: trang Settings cho agent ưu tiên / số task song song / autonomy mặc định (hiện là file cấu hình), hàng đợi duyệt hành động
256
- ngoài chính sách và nút hợp nhất theo nhánh (commit).
255
+ **Đã làm (lát cắt 3: hàng đợi duyệt hành động bị từ chối).** Trang "Duyệt" và panel trên trang Task (`docs/APPROVAL-QUEUE-PLAN.md`). Không CLI nào cho duyệt giữa chừng, nên đây là
256
+ duyệt sau: task kết thúc mà agent bị từ chối hành động thì vào hàng đợi; chủ bridge chọn "Cho phép và chạy lại" (task mới có `retry_of`, kèm quyền hẹp nhất cho đúng lần đó:
257
+ `agy --mode`, `codex --add-dir`, hoặc bản vá `permission` của `opencode`) hoặc "Bỏ qua". Quyền nâng không ghi vào cài đặt nào, không sang agent khác, và chỉ qua web UI
258
+ (`GET /ui/api/approvals`, `POST /ui/api/tasks/<id>/approve|dismiss` với `confirm: true`); không có tool MCP nào để duyệt.
259
+
260
+ Chưa làm của Phase 4: trang Settings cho agent ưu tiên / số task song song / autonomy mặc định (hiện là file cấu hình) và nút hợp nhất theo nhánh (commit).
257
261
 
258
262
  ### Phase 5 — Tích hợp và hardening
259
263
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agy-mcp-bridge",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "description": "Local loopback HTTP MCP bridge that delegates bounded coding tasks to multiple agent CLIs (Antigravity, Codex) with optional parallel git worktrees",
5
5
  "license": "MIT",
6
6
  "keywords": [
package/ui/app.css CHANGED
@@ -245,3 +245,15 @@ pre.log { margin: 0; padding: 12px; border-radius: 10px; background: #0b0e14; co
245
245
  .usage-table th { font-weight: 600; color: var(--muted); }
246
246
 
247
247
  .over-budget { color: var(--bad); font-weight: 600; }
248
+
249
+ /* Approval queue */
250
+ .approvals-page { display: grid; gap: 16px; }
251
+ .section-title { margin: 8px 0 0; font-size: 15px; }
252
+ .approval-body { display: grid; gap: 12px; margin-top: 12px; }
253
+ .option-list { display: grid; gap: 8px; }
254
+ .option { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; cursor: pointer; }
255
+ .option.picked { border-color: var(--accent); background: var(--accent-soft); }
256
+ .option > span { display: grid; gap: 2px; }
257
+ .chip.suspected { border-style: dashed; color: var(--muted); }
258
+ .nav-badge { margin-left: auto; min-width: 20px; padding: 0 6px; border-radius: 999px; background: var(--bad); color: #fff; font-size: 12px; font-weight: 700; text-align: center; }
259
+ .nav-badge[hidden] { display: none; }
package/ui/app.js CHANGED
@@ -9,6 +9,7 @@ const ICONS = {
9
9
  missions: 'M4 5h4v4H4zM4 15h4v4H4zM16 10h4v4h-4zM8 7h3a2 2 0 0 1 2 2v3M8 17h3a2 2 0 0 0 2-2v-3',
10
10
  usage: 'M4 20V10M10 20V4M16 20v-8M22 20H2',
11
11
  settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z',
12
+ approvals: 'M9 12l2 2 4-4M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z',
12
13
  out: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9'
13
14
  };
14
15
 
@@ -20,6 +21,7 @@ const MATCH = {
20
21
  const state = {
21
22
  overview: null, selected: null, detail: null, view: 'tasks', logTab: 'stdout',
22
23
  missions: null, selectedMission: null, plan: null,
24
+ approvals: null, approvalPick: {}, approvalDir: {},
23
25
  open: new Set(), changes: null, connected: true, toast: '', q: '', filter: 'all'
24
26
  };
25
27
  let timer = null;
@@ -176,6 +178,129 @@ function followChanges() {
176
178
  if (state.changes?.task_id !== task.task_id || state.changes.loading) return;
177
179
  void loadChanges(task.task_id, true);
178
180
  }
181
+ /* ---------- Approval queue: a task whose agent was refused something waits here. No CLI lets the owner approve in the middle of a run, so the owner either
182
+ runs the task again, as a new task, with a little more permission for that one run, or sets the refusal aside. Only this page can do it. ---------- */
183
+ const DECISION_LABEL = {apply: 'Áp dụng', discard: 'Bỏ worktree', approve: 'Cho phép', rerun: 'Chạy lại', dismiss: 'Bỏ qua'};
184
+ // What each permission is, in the CLI's own terms, and how far it reaches. The page never invents a grant: the options come from the bridge.
185
+ const OPTION_TEXT = {
186
+ 'agy-level:accept-edits': ['Tự duyệt việc sửa file', '--mode accept-edits. Lệnh và đường dẫn ngoài workspace vẫn bị hỏi, nên vẫn bị từ chối.'],
187
+ 'agy-level:auto': ['Chế độ tự động của Antigravity', '--mode auto: Antigravity tự quyết định hành động của nó.'],
188
+ 'agy-level:full': ['Bỏ qua mọi yêu cầu quyền', '--dangerously-skip-permissions: mọi thao tác được duyệt, kể cả lệnh và đường dẫn ngoài workspace.'],
189
+ 'codex-add-dir': ['Mở thêm một thư mục để ghi', '--add-dir <thư mục>: chỉ đúng thư mục này; mọi thư mục khác ngoài workspace vẫn bị chặn.'],
190
+ 'opencode-allow:bash': ['Cho phép chạy lệnh shell', 'bash=allow cho lần này. Shell của OpenCode ghi được cả ra ngoài workspace.'],
191
+ 'opencode-allow:edit': ['Cho phép sửa file', 'edit=allow cho lần này.'],
192
+ 'opencode-auto': ['Duyệt mọi thứ không bị chặn rõ ràng', '--auto: tự duyệt mọi quyền mà cấu hình OpenCode không từ chối.']
193
+ };
194
+ const optionKey = option => option.elevation?.kind === 'agy-level' ? `agy-level:${option.elevation.level}`
195
+ : option.elevation?.kind === 'opencode-allow' ? `opencode-allow:${option.elevation.permission}` : option.kind;
196
+ const elevationKey = e => e.kind === 'agy-level' ? `agy-level:${e.level}` : e.kind === 'opencode-allow' ? `opencode-allow:${e.permission}` : e.kind;
197
+ function retryNote(task) {
198
+ const e = task.elevation;
199
+ const what = e ? `${(OPTION_TEXT[elevationKey(e)] || [e.kind])[0]}${e.kind === 'codex-add-dir' ? ` (${e.dir})` : ''}` : 'không rõ';
200
+ return `Đây là lần chạy lại của task ${task.retry_of.slice(0, 8)}, với thêm quyền chỉ cho lần chạy này: ${what}. Cài đặt của bạn không đổi.`;
201
+ }
202
+ let approvalsAt = 0;
203
+ async function loadApprovals(force) {
204
+ if (!force && Date.now() - approvalsAt < 4000) return;
205
+ approvalsAt = Date.now();
206
+ try { state.approvals = (await api('approvals')).approvals; } catch { /* keep the list already on screen */ }
207
+ patch();
208
+ }
209
+ /** The option the page preselects: the one the bridge marked narrowest, unless the owner already picked another. */
210
+ function pickedOption(view) {
211
+ const chosen = state.approvalPick[view.task_id];
212
+ if (Number.isInteger(chosen) && chosen >= 0 && chosen < view.options.length) return chosen;
213
+ const narrow = view.options.findIndex(option => option.narrowest);
214
+ return narrow >= 0 ? narrow : 0;
215
+ }
216
+ async function approveRerun(view, option) {
217
+ const typed = (state.approvalDir[view.task_id] || '').trim();
218
+ if (option.needs_dir && !typed) { notify('Nhập đường dẫn tuyệt đối của thư mục trước'); return; }
219
+ const elevation = option.needs_dir ? {kind: option.kind, dir: typed} : option.elevation;
220
+ const [title, note] = OPTION_TEXT[optionKey(option)] || [option.kind, ''];
221
+ if (!confirm(`Chạy lại task này với thêm quyền?\n\n${title}${option.needs_dir ? `: ${typed}` : ''}\n${note}\n\nChỉ áp dụng cho MỘT lần chạy: bridge tạo task mới từ đúng yêu cầu đã ghi của task cũ. Cài đặt của bạn không đổi và lần chạy sau không thừa hưởng quyền này.${option.wide ? '\n\nMức này RỘNG: nó mở nhiều hơn các hành động đã bị từ chối.' : ''}`)) return;
222
+ try {
223
+ await api(`tasks/${view.task_id}/approve`, {method: 'POST', body: {confirm: true, elevation}});
224
+ notify('Đã tạo task chạy lại');
225
+ } catch (error) { notify(error.message); }
226
+ await refresh(true);
227
+ await loadApprovals(true);
228
+ }
229
+ async function dismissRefusal(view) {
230
+ if (!confirm('Bỏ qua các hành động bị từ chối của task này?\n\nKhông chạy gì thêm. Việc này được ghi vào nhật ký quyết định.')) return;
231
+ try { await api(`tasks/${view.task_id}/dismiss`, {method: 'POST', body: {confirm: true}}); notify('Đã bỏ qua'); }
232
+ catch (error) { notify(error.message); }
233
+ await refresh(true);
234
+ await loadApprovals(true);
235
+ }
236
+ /** What a person decides on: the refused actions, then what the task can be given, then the two buttons. */
237
+ function approvalBody(view) {
238
+ const denied = h('div', {}, h('div', {class: 'msg-head', text: 'Hành động bị từ chối'}),
239
+ h('div', {class: 'chips'}, view.denied.map(item => h('span', {
240
+ class: item.suspected ? 'chip suspected' : 'chip',
241
+ title: item.suspected ? 'Bridge chỉ suy ra từ văn bản của một lệnh thất bại; chưa chắc đây là từ chối quyền.' : 'CLI báo hành động này bị từ chối.',
242
+ text: `${item.suspected ? '? ' : ''}${item.display_name || item.action}`}))));
243
+ if (view.state === 'rerun') {
244
+ return [denied, h('div', {class: 'callout good'}, 'Đã chạy lại ở task ', view.rerun_task_id ? h('button', {class: 'link-btn', onclick: () => openTask(view.rerun_task_id), text: view.rerun_task_id.slice(0, 8)}) : '?', '.')];
245
+ }
246
+ if (view.state === 'dismissed') return [denied, h('div', {class: 'callout', text: 'Đã bỏ qua: không chạy gì thêm.'})];
247
+ if (!view.options.length) {
248
+ return [denied, h('div', {class: 'callout warn', text: 'Không có cách nâng quyền hẹp cho loại hành động này. Bạn chỉ có thể bỏ qua, hoặc đổi cấu hình của agent rồi giao lại.'}),
249
+ h('div', {class: 'chips'}, h('button', {class: 'btn small', onclick: () => dismissRefusal(view), text: 'Bỏ qua'}))];
250
+ }
251
+ const picked = pickedOption(view);
252
+ const option = view.options[picked];
253
+ const rows = view.options.map((item, index) => {
254
+ const [title, note] = OPTION_TEXT[optionKey(item)] || [item.kind, ''];
255
+ return h('label', {class: `option${index === picked ? ' picked' : ''}`},
256
+ h('input', {type: 'radio', name: `pick-${view.task_id}`, checked: index === picked, onchange: () => { state.approvalPick[view.task_id] = index; patch(); }}),
257
+ h('span', {}, h('strong', {text: title}), h('span', {class: 'muted', text: note}),
258
+ h('span', {class: 'chips'}, item.narrowest ? h('span', {class: 'pill ok', text: 'hẹp nhất'}) : null, item.wide ? h('span', {class: 'pill bad', text: 'rộng'}) : null)));
259
+ });
260
+ return [denied,
261
+ h('div', {class: 'msg-head', text: 'Quyền thêm, chỉ cho một lần chạy lại'}),
262
+ h('div', {class: 'option-list', role: 'radiogroup'}, rows),
263
+ option.needs_dir ? h('input', {type: 'text', class: 'search', 'aria-label': 'Thư mục được mở thêm', placeholder: 'Đường dẫn tuyệt đối của thư mục, ví dụ C:\\du-an\\build',
264
+ value: state.approvalDir[view.task_id] || '', oninput: event => { state.approvalDir[view.task_id] = event.target.value; }}) : null,
265
+ h('div', {class: 'chips'},
266
+ h('button', {class: 'btn primary small', onclick: () => approveRerun(view, option), text: 'Cho phép và chạy lại'}),
267
+ h('button', {class: 'btn small', onclick: () => dismissRefusal(view), text: 'Bỏ qua'})),
268
+ h('p', {class: 'muted', text: 'Task mới dùng đúng yêu cầu đã ghi của task cũ; quyền thêm chỉ nằm trong lần chạy đó, không đổi cài đặt nào của bạn.'})];
269
+ }
270
+ function approvalCard(view) {
271
+ return h('section', {class: 'card panel approval'},
272
+ h('div', {class: 'mission-head'}, h('div', {},
273
+ h('h2', {}, view.title || view.task_id.slice(0, 8), pill(view.status),
274
+ view.state === 'pending' ? h('span', {class: 'pill warn', text: 'Chờ duyệt'}) : null),
275
+ h('div', {class: 'meta-line muted mono', title: view.workspace}, `${view.agent} · ${folder(view.workspace)}${view.isolation === 'worktree' ? ' · worktree' : ''}`),
276
+ h('div', {class: 'meta-line muted'}, view.ended_at ? agoEl(view.ended_at, 'meta') : null, view.retry_of ? ` · chạy lại từ ${view.retry_of.slice(0, 8)}` : '',
277
+ h('button', {class: 'link-btn', onclick: () => openTask(view.task_id), text: 'Mở task'})))),
278
+ h('div', {class: 'approval-body'}, approvalBody(view)));
279
+ }
280
+ function approvalsView() {
281
+ const head = h('div', {class: 'page-head'}, h('div', {}, h('h1', {text: 'Duyệt'}),
282
+ h('p', {text: 'Task có hành động bị agent từ chối. Không CLI nào cho duyệt giữa chừng, nên bạn duyệt sau: chạy lại task như một task mới với thêm quyền cho đúng lần đó, hoặc bỏ qua.'})));
283
+ const note = h('p', {class: 'callout warn', text: 'Chỉ bạn duyệt được, ở trang này. Không có tool MCP nào để duyệt hay nâng quyền, nên một agent không tự duyệt được yêu cầu của chính nó.'});
284
+ const list = state.approvals;
285
+ if (!list) return [head, note, h('div', {class: 'card empty'}, state.connected ? 'Đang tải…' : 'Không đọc được hàng đợi.')];
286
+ const pending = list.filter(view => view.state === 'pending');
287
+ const settled = list.filter(view => view.state !== 'pending');
288
+ if (!list.length) return [head, note, h('div', {class: 'card empty'}, h('strong', {text: 'Chưa có gì cần duyệt'}), 'Khi một agent bị từ chối quyền ở một hành động, task đó hiện ở đây.')];
289
+ return [head, note,
290
+ pending.length ? h('h2', {class: 'section-title', text: `Chờ duyệt (${pending.length})`}) : h('div', {class: 'card empty'}, h('strong', {text: 'Không còn gì chờ duyệt'})),
291
+ ...pending.map(approvalCard),
292
+ settled.length ? h('h2', {class: 'section-title', text: `Đã xử lý (${settled.length})`}) : null,
293
+ ...settled.map(approvalCard)];
294
+ }
295
+ /** On the task page: the same card, for the one task being read. */
296
+ function approvalPanel(task) {
297
+ const view = (state.approvals || []).find(item => item.task_id === task.task_id);
298
+ const el = details('approval', 'Duyệt hành động bị từ chối', ...(view ? approvalBody(view) : [h('p', {class: 'muted', text: 'Đang tải…'})]));
299
+ el.addEventListener('toggle', () => { if (el.open) void loadApprovals(true); });
300
+ if (!state.approvals) void loadApprovals(true);
301
+ return el;
302
+ }
303
+
179
304
  /* ---------- Mission page: read the recorded plan, stop it, never start a step ---------- */
180
305
  let missionsAt = 0;
181
306
  /** A step changes state only when its task does, so the list is read when the page opens and then at most every 8 seconds. */
@@ -472,7 +597,7 @@ function reviewBody(task) {
472
597
  !task.applied_at && !task.discarded_at ? h('p', {class: 'muted', text: 'Đọc diff từng file ở mục "Thay đổi trong workspace" trước khi áp dụng.'}) : null,
473
598
  mine.length ? h('div', {}, h('div', {class: 'msg-head', text: 'Nhật ký quyết định'}),
474
599
  h('ul', {class: 'change-list'}, mine.map(item => h('li', {class: 'change-row'},
475
- h('span', {class: `change-kind ${item.ok ? 'added' : 'deleted'}`, text: item.action === 'apply' ? 'Áp dụng' : 'Bỏ'}),
600
+ h('span', {class: `change-kind ${item.ok ? 'added' : 'deleted'}`, text: DECISION_LABEL[item.action] || item.action}),
476
601
  h('span', {class: 'change-path', text: `${item.ok ? '' : 'Bị từ chối: '}${item.detail}`}),
477
602
  h('span', {class: 'change-count mono', text: clock(item.at)}))))) : null
478
603
  ];
@@ -569,8 +694,9 @@ function detailView() {
569
694
  } else {
570
695
  answer = [
571
696
  task.final_response ? h('p', {class: 'text', text: task.final_response}) : h('p', {class: 'muted', text: `${agentName} không trả về phản hồi cuối.`}),
697
+ task.retry_of ? h('div', {class: 'callout', text: retryNote(task)}) : null,
572
698
  task.denied_actions.length ? h('div', {}, h('div', {class: 'msg-head', text: 'Thao tác bị từ chối quyền'}),
573
- h('div', {class: 'chips'}, task.denied_actions.map(a => h('span', {class: 'chip', text: a.display_name || a.action})))) : null,
699
+ h('div', {class: 'chips'}, task.denied_actions.map(a => h('span', {class: a.suspected ? 'chip suspected' : 'chip', title: a.suspected ? 'Bridge chỉ suy ra từ văn bản của một lệnh thất bại.' : null, text: `${a.suspected ? '? ' : ''}${a.display_name || a.action}`})))) : null,
574
700
  scopeBody(task),
575
701
  cleanupBody(task),
576
702
  task.error ? h('div', {class: 'callout bad', text: task.error}) : null,
@@ -586,6 +712,7 @@ function detailView() {
586
712
 
587
713
  return h('article', {class: 'card detail'}, head, progress, h('div', {class: 'convo'}, asked, replied),
588
714
  changesPanel(task),
715
+ task.denied_actions.length && task.status !== 'running' && task.status !== 'queued' ? approvalPanel(task) : null,
589
716
  task.isolation === 'worktree' && task.status !== 'running' && task.status !== 'queued' ? reviewPanel(task) : null,
590
717
  details('steps', `Các bước ${agentName} báo cáo`, h('div', {id: 'steps-body'}, stepsBody(state.detail?.activity))),
591
718
  details('logs', `Log của tiến trình ${agentName}`, logs),
@@ -851,14 +978,15 @@ function settingsView(o) {
851
978
 
852
979
  /* ---------- Shell: built once per view change, then patched region by region ---------- */
853
980
  function mount() {
854
- const nav = (view, label) => h('button', {'aria-current': state.view === view ? 'page' : null, onclick: () => { state.view = view; render(); }}, icon(view), label);
981
+ const badge = h('span', {class: 'nav-badge', hidden: true, 'aria-label': 'Số task chờ duyệt'});
982
+ const nav = (view, label, extra) => h('button', {'aria-current': state.view === view ? 'page' : null, onclick: () => { state.view = view; render(); }}, icon(view), label, extra);
855
983
  const live = h('div', {class: 'live', role: 'status'});
856
984
  const side = h('aside', {class: 'side'}, h('div', {class: 'brand'}, h('img', {src: '/ui/favicon.svg', alt: ''}), h('span', {text: 'Antigravity Bridge'})),
857
- h('nav', {class: 'nav', 'aria-label': 'Điều hướng'}, nav('tasks', 'Task'), nav('missions', 'Nhiệm vụ'), nav('usage', 'Sử dụng'), nav('settings', 'Cài đặt')),
985
+ h('nav', {class: 'nav', 'aria-label': 'Điều hướng'}, nav('tasks', 'Task'), nav('missions', 'Nhiệm vụ'), nav('approvals', 'Duyệt', badge), nav('usage', 'Sử dụng'), nav('settings', 'Cài đặt')),
858
986
  h('div', {class: 'side-foot'}, live));
859
987
  const banner = h('div', {'aria-live': 'polite'});
860
988
  const main = h('main', {class: 'main'}, banner);
861
- regions = {live, banner, main, view: state.view};
989
+ regions = {live, banner, main, badge, view: state.view};
862
990
  if (state.view === 'tasks') {
863
991
  regions.stats = h('section', {class: 'stats', 'aria-label': 'Tổng quan'});
864
992
  regions.list = h('div', {class: 'task-list', role: 'list'});
@@ -876,6 +1004,9 @@ function mount() {
876
1004
  } else if (state.view === 'missions') {
877
1005
  regions.missions = h('div', {class: 'mission-page'});
878
1006
  main.append(regions.missions);
1007
+ } else if (state.view === 'approvals') {
1008
+ regions.approvals = h('div', {class: 'approvals-page'});
1009
+ main.append(regions.approvals);
879
1010
  } else {
880
1011
  regions.settings = h('div', {});
881
1012
  main.append(regions.settings);
@@ -896,6 +1027,9 @@ const stable = task => task.status === 'running' || task.status === 'queued' ? {
896
1027
  function patch() {
897
1028
  const o = state.overview;
898
1029
  if (!regions || !o) return;
1030
+ const waiting = o.tasks.filter(task => task.denied_actions?.length && !task.approval && task.status !== 'queued' && task.status !== 'running').length;
1031
+ regions.badge.hidden = waiting === 0;
1032
+ regions.badge.textContent = String(waiting);
899
1033
  setRegion('live', String(state.connected), () => [h('span', {class: `dot${state.connected ? '' : ' off'}`}), h('span', {text: state.connected ? 'Đang cập nhật trực tiếp' : 'Mất kết nối tới bridge'})]);
900
1034
  setRegion('banner', JSON.stringify([state.connected, o.warnings, o.update, o.permission?.level]), () => [
901
1035
  !state.connected ? h('div', {class: 'banner', role: 'alert', text: 'Mất kết nối tới bridge. Dữ liệu bên dưới có thể đã cũ.'}) : null,
@@ -907,7 +1041,7 @@ function patch() {
907
1041
  setRegion('list', JSON.stringify([state.q, state.filter, state.selected, o.budget?.task_tokens, o.tasks.map(t => stable(t))]), () => taskItems(o));
908
1042
  regions.count.textContent = taskCount(o);
909
1043
  const d = state.detail;
910
- const detailKey = JSON.stringify([state.selected, state.logTab, state.changes, state.decisions, state.diff, d && {...d, activity: null, task: {...stable(d.task), stdout: '', stderr: '', stdout_bytes: 0, stderr_bytes: 0, stdout_truncated: false, stderr_truncated: false}}]);
1044
+ const detailKey = JSON.stringify([state.selected, state.logTab, state.changes, state.decisions, state.approvals, state.approvalPick, state.diff, d && {...d, activity: null, task: {...stable(d.task), stdout: '', stderr: '', stdout_bytes: 0, stderr_bytes: 0, stdout_truncated: false, stderr_truncated: false}}]);
911
1045
  const rebuilt = signatures.detail !== detailKey;
912
1046
  setRegion('detail', detailKey, () => detailView());
913
1047
  if (!rebuilt) patchLogs();
@@ -919,6 +1053,9 @@ function patch() {
919
1053
  } else if (regions.view === 'missions') {
920
1054
  loadMissions();
921
1055
  setRegion('missions', JSON.stringify([o.missions, state.missions, state.selectedMission, state.plan]), () => missionsView(o));
1056
+ } else if (regions.view === 'approvals') {
1057
+ loadApprovals();
1058
+ setRegion('approvals', JSON.stringify([state.approvals, state.approvalPick, state.connected]), () => approvalsView().filter(Boolean));
922
1059
  } else {
923
1060
  if (!state.models && !state.modelsLoading) loadModels();
924
1061
  setRegion('settings', JSON.stringify([state.models, o.model, o.permission, o.effort, o.wait, o.budget && [o.budget.task_tokens, o.budget.day_tokens], o.soft_timeout, o.progress, o.cli, o.agents, o.default_agent, o.default_autonomy, o.concurrency, o.delegation, o.limits, o.allowed_workspaces, o.session_workspaces, o.server.mcp_endpoint]), () => settingsView(o));