agy-mcp-bridge 0.1.32 → 0.1.34

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.
@@ -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
  }
@@ -54,13 +54,15 @@ const literal = (file) => `:(literal)${file}`;
54
54
  export async function applyWorktreeChanges(input) {
55
55
  try {
56
56
  const prefix = await prefixOf(input.toplevel, input.workspace);
57
- const scope = prefix ? [prefix] : ['.'];
57
+ const scope = prefix ? [literal(prefix)] : ['.'];
58
58
  const owns = input.owns.map(normalizeOwn);
59
59
  if (!owns.length)
60
60
  throw new Refusal('The task declared no owned paths, so there is nothing it was allowed to change');
61
+ // Renames are never detected: with them on, `--name-only` lists only the new name, so a file moved out of a protected path
62
+ // would be deleted from the workspace without the owned-paths check ever seeing it.
61
63
  // Staging is only a way to include new files in the diff: it touches the worktree's own index, never the workspace.
62
64
  await git(input.worktree, ['add', '-A', '--', ...scope]);
63
- const names = (await git(input.worktree, ['diff', '--cached', '--name-only', '-z', 'HEAD', '--', ...scope])).split('\0').filter(Boolean);
65
+ const names = (await git(input.worktree, ['diff', '--cached', '--no-renames', '--name-only', '-z', 'HEAD', '--', ...scope])).split('\0').filter(Boolean);
64
66
  if (!names.length)
65
67
  throw new Refusal('The worktree has no changes to apply');
66
68
  if (names.length > MAX_FILES)
@@ -70,10 +72,10 @@ export async function applyWorktreeChanges(input) {
70
72
  if (outside.length)
71
73
  throw new Refusal(`Files outside the task's owned paths were changed (${outside.slice(0, 5).join(', ')}); review them in the worktree`);
72
74
  // A symlink or a submodule pointer is more than text: it can point outside the workspace, so it is never applied from here.
73
- const raw = (await git(input.worktree, ['diff', '--cached', '--raw', '-z', 'HEAD', '--', ...scope])).split('\0');
75
+ const raw = (await git(input.worktree, ['diff', '--cached', '--no-renames', '--raw', '-z', 'HEAD', '--', ...scope])).split('\0');
74
76
  if (raw.some(field => /^:(?:12|16)0000 /.test(field) || /^:\d+ (?:12|16)0000 /.test(field)))
75
77
  throw new Refusal('The change includes a symlink or a submodule, which is not applied from here; review it in the worktree');
76
- const patch = await git(input.worktree, ['diff', '--cached', '--binary', '--full-index', '--no-ext-diff', '--no-textconv', 'HEAD', '--', ...scope]);
78
+ const patch = await git(input.worktree, ['diff', '--cached', '--no-renames', '--binary', '--full-index', '--no-ext-diff', '--no-textconv', 'HEAD', '--', ...scope]);
77
79
  if (Buffer.byteLength(patch) > MAX_PATCH_BYTES)
78
80
  throw new Refusal('The change is larger than the apply limit; review it in the worktree');
79
81
  // A file the owner is editing right now is theirs to merge by hand, even if the patch would happen to fit.
@@ -1,5 +1,7 @@
1
1
  import { execFile } from 'node:child_process';
2
+ import { realpath } from 'node:fs/promises';
2
3
  import path from 'node:path';
4
+ import { inside } from '../security/workspace.js';
3
5
  import { redact } from '../security/redact.js';
4
6
  import { gitEnvironment } from './apply.js';
5
7
  /**
@@ -28,6 +30,10 @@ function capture(cwd, args) {
28
30
  export async function worktreeFileDiff(worktree, file) {
29
31
  if (!isRelativeFilePath(file))
30
32
  throw new Error('path must be a relative path inside the worktree');
33
+ // A link in the worktree (a junction on Windows) can lead anywhere; the diff reads files, so the real location must stay inside it.
34
+ const [base, real] = await Promise.all([realpath(worktree), realpath(path.join(worktree, file)).catch(() => null)]);
35
+ if (real !== null && !inside(base, real))
36
+ throw new Error('path leaves the worktree through a link, so it is not read');
31
37
  const literal = `:(literal)${file}`;
32
38
  // Tracked files diff against the base commit; a file the agent created is not in the index, so it is shown as all added lines.
33
39
  const tracked = (await capture(worktree, ['ls-files', '--error-unmatch', '-z', '--', literal])).code === 0;
@@ -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]));
@@ -0,0 +1,200 @@
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
+
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
+
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
+
7
+ ## 1. Kết luận ngắn
8
+
9
+ 1. **Không CLI nào mà bridge dùng cho phép duyệt giữa chừng** trong chế độ không tương tác. Đo được trên `codex` và `opencode`; `agy` chưa đo lại được (mục 2.1).
10
+ - `codex exec`: không có sự kiện "xin quyền" nào, cả với `on-request` lẫn `never`. Hành động bị sandbox chặn chỉ hiện ra là một lệnh **thất bại** (`status: failed`, "Access ... is denied").
11
+ - `opencode run`: khi `permission` là `ask`, lời xin bị **tự từ chối ngay** (`auto-rejecting`) và run đi tiếp.
12
+ 2. Vì vậy MVP trung thực là **duyệt sau, không duyệt giữa chừng**: task kết thúc mà có hành động bị từ chối thì vào hàng đợi; chủ bridge chọn **"Cho phép và chạy lại"** (tạo task mới, `retry_of`, với quyền cao hơn *đúng cho một lần chạy đó*) hoặc **"Bỏ qua"**.
13
+ 3. **Bridge hôm nay chỉ nhìn thấy sự từ chối của `agy`** (`denied_actions`). `codex` chỉ được nhận diện khi item có status `declined`/`denied`, mà **chưa từng thấy** trong các lần đo; `opencode` báo `deniedActions: []` cố định dù có tín hiệu rõ ràng. Hàng đợi chỉ có nghĩa khi hai adapter này được dạy nhận ra sự từ chối (mục 4).
14
+ 4. Có cờ để nâng quyền **hẹp, một lần**: `codex --add-dir <dir>` (đã đo), `agy --mode accept-edits` (từ help, chưa đo), `opencode` qua `OPENCODE_CONFIG_CONTENT` theo từng lần chạy (cơ chế đã đo, tác dụng lên đúng quyền chưa đo).
15
+
16
+ ## 2. Đo đạc
17
+
18
+ Máy: Windows 11, `agy` 1.4.12, `codex-cli` 0.163.0 (đăng nhập ChatGPT, `~/.codex/config.toml` có `[windows] sandbox = "elevated"`), `opencode` v2.2.5. Mọi lần đo chạy trong một repo git **tạm** (`%TEMP%\approval-probe-*\ws`, một commit `a.txt`), thư mục "ngoài workspace" là thư mục tạm riêng; không chạy agent trên repo của người dùng. Hai thư mục tạm trong thư mục home (để nằm ngoài `%TEMP%`) đã được xóa sau mỗi lần.
19
+
20
+ ### 2.1 `agy` — chưa đo được ở 1.4.12
21
+
22
+ ```
23
+ agy --version → 1.4.12
24
+ agy --print "Reply with exactly: PROBE_OK" --output-format json
25
+ → Authentication required. Please visit the URL to log in: https://accounts.google.com/o/oauth2/auth?...
26
+ ```
27
+
28
+ `agy` chưa đăng nhập trên máy này và việc đăng nhập là của chủ máy, nên mọi điều dưới đây về `agy` là **cờ đọc từ `agy --help`** hoặc **bằng chứng đã ghi trước đây**, không phải số đo mới:
29
+
30
+ - Cờ (từ `agy --help`): `--mode accept-edits|auto|plan`, `--dangerously-skip-permissions` ("Auto-approve all tool permission requests without prompting"), `--sandbox` ("terminal restrictions"), `--output-format json`.
31
+ - Bằng chứng đã ghi (`docs/VERIFICATION.md`, mục 4, trên `agy` 1.4.x trước đây): yêu cầu tạo file trong scratch workspace → CLI trả `SUCCESS`/exit 0 nhưng `denied_actions` chứa `write_file`, response rỗng; bridge báo `failed`.
32
+ - Adapter hiện tại (`src/agents/antigravity.ts:39-44, 54-55`) đọc `envelope.denied_actions` (tối đa 100 mục, `action` và `display_name`) và biến task thành lỗi `Required tool permissions were denied; …`.
33
+
34
+ **Chưa biết:** hình dạng `denied_actions` cho hành động khác `write_file` (chạy lệnh, đọc ngoài workspace); mức `--mode` nào mở được loại hành động nào; hành vi trên 1.4.12.
35
+
36
+ ### 2.2 `codex` 0.163.0
37
+
38
+ Lệnh (cờ `--ask-for-approval` là cờ toàn cục, đứng **trước** `exec`):
39
+
40
+ ```
41
+ codex --ask-for-approval <on-request|never> exec --json --sandbox <mode> --skip-git-repo-check --ephemeral --cd <ws> --color never - (prompt qua stdin)
42
+ ```
43
+
44
+ | # | Chính sách | Hành động yêu cầu | Kết quả đo |
45
+ |---|---|---|---|
46
+ | c4 | `never`, `read-only` | ghi `inside.txt` trong workspace | `command_execution` **`failed`**, exit 1, `Access to the path '…\ws\inside.txt' is denied`. Codex thoát 0, `turn.completed`, agent tự kể lại lỗi |
47
+ | c2 | `on-request`, `workspace-write` | ghi `../outside/out.txt` (dưới `%TEMP%`) | **thành công**, exit 0 |
48
+ | c3 | `never`, `workspace-write` | như c2 | **thành công**, exit 0 |
49
+ | d1 | `on-request`, `workspace-write` | ghi file dưới thư mục home, **ngoài `%TEMP%`** | `failed`, exit 1, `Access … is denied`; file không tạo |
50
+ | d2 | `never`, `workspace-write` | như d1 | `failed`, exit 1, như d1 |
51
+ | e1 | `never`, `workspace-write --add-dir <A>` | ghi vào `A` và vào thư mục `B` không nêu tên | `A`: **thành công**; `B`: `failed`, exit 1, `Access … is denied` |
52
+ | c1 | `on-request`, `workspace-write` | ghi `inside.txt` trong workspace | cả hai lần thử: `failed`, exit −1, `Failed to create unified exec process: orchestrator_helper_exit_nonzero: setup helper exited with status Some(143)` — lỗi hạ tầng của sandbox Windows, **không** phải từ chối quyền; chỉ xảy ra ở lần này, c2/c3 chạy ngay sau đó bình thường |
53
+
54
+ Điều rút ra:
55
+
56
+ - **Không có sự kiện xin quyền trong JSONL.** Các loại sự kiện thấy được: `thread.started`, `turn.started`, `item.started|completed` (`agent_message`, `command_execution`, `error`), `turn.completed`. `on-request` và `never` cho cùng kết quả trong `exec`: lệnh bị chặn thất bại ngay, "Execution failures are immediately returned to the model" đúng như `--help`.
57
+ - **Không thấy `status: declined`/`denied` nào.** Điều kiện của adapter (`src/agents/codex.ts:62-63`) không bao giờ đúng với kiểu từ chối này, nên task kết thúc `completed` với câu trả lời của agent ("The write … was refused") và `denied_actions: []`. Bridge **không phân biệt được** "agent làm xong" với "agent bị chặn và kể lại".
58
+ - `workspace-write` **cho ghi vào thư mục tạm** (c2, c3) nhưng **không** cho ghi vào thư mục home ngoài workspace (d1, d2). Hai điều này cho thấy "ngoài workspace" của codex không đồng nghĩa với "ngoài `%TEMP%`" (suy ra từ hai nhóm kết quả, không phải từ tài liệu).
59
+ - `--add-dir <dir>` mở **đúng** thư mục nêu tên (e1) — đây là cách nâng quyền hẹp nhất cho "ghi ngoài workspace".
60
+ - `--sandbox danger-full-access` **không đo**: chạy một agent không có sandbox nằm ngoài phạm vi cuộc điều tra này. Chỉ biết từ `--help` rằng cờ đó tồn tại.
61
+ - Stderr của mọi lần chạy có `ERROR rmcp::transport::worker … http request failed … http://127.0.0.1:3000/mcp` (và `:8080`): codex nạp **MCP server khai báo trong `~/.codex/config.toml` của người dùng**, trong đó có chính bridge. Xem mục 7.
62
+
63
+ ### 2.3 `opencode` v2.2.5
64
+
65
+ Lệnh (cách bridge chạy; cấu hình quyền đưa qua biến môi trường để không đụng file cấu hình của người dùng):
66
+
67
+ ```
68
+ [OPENCODE_CONFIG_CONTENT='{"permission":{…}}'] opencode run --standalone --format json --model <free model> [--auto] (prompt qua stdin)
69
+ ```
70
+
71
+ | # | Cấu hình / cờ | Hành động yêu cầu | Kết quả đo |
72
+ |---|---|---|---|
73
+ | o1 | mặc định, không `--auto` | `printf … > inside.txt` bằng shell | `tool_use` shell **`completed`**, file tạo. **Không hỏi** |
74
+ | o2 | mặc định, không `--auto` | ghi file thư mục home, **ngoài workspace**, bằng shell | `tool_use` shell **`completed`**, file được tạo. **Không hỏi, không chặn** |
75
+ | o3 | `--auto` | như o2 | `completed`, file tạo |
76
+ | o4 | `permission: {edit: ask, bash: ask}` | ghi `inside.txt` | `tool_use` shell **`status: "error"`**, `error`: *"This non-interactive run cannot ask the user for permission, so the request was rejected. Continue without this action."*; agent thử lại bằng tool `write`, cùng lỗi. Stderr: `! permission requested: shell (echo -n "IN_OK" > inside.txt, cat inside.txt); auto-rejecting` và `! permission requested: edit (inside.txt); auto-rejecting`. Run **đi tiếp**, thoát 0, agent kể lại việc bị từ chối |
77
+ | o5 | như o4 + `--auto` | như o4 | `completed`, file tạo → `--auto` **ghi đè** `ask` |
78
+ | o6 | `permission: {edit: deny, bash: deny}` | ghi `inside.txt` | shell/edit bị chặn; agent **chuyển sang tool khác**: gọi tool `execute` với các MCP tool của chủ máy (`tools.rea.*`, `tools.browser`), thử `capture_process_scenario` chạy `powershell.exe`, rồi tạo subagent. Các MCP tool đó trả `capability_unavailable` nên không có tác động; run dừng vì hết hạn mức free |
79
+
80
+ Ghi chú về các số đo: o1 chạy trên `opencode/big-pickle`, o2–o6 (và chạy lại o1, bị 429) trên `opencode/deepseek-v4.1-flash-free`, vì `big-pickle` hết hạn mức miễn phí giữa chừng (`provider.free-limit`, 429). o4 có kết quả trên cả hai model.
81
+
82
+ Điều rút ra:
83
+
84
+ - **`opencode run` không có duyệt giữa chừng.** Lời xin quyền bị tự từ chối tức thì.
85
+ - **Có tín hiệu từ chối rõ ràng và ổn định** để adapter đọc: `tool_use` có `part.state.status === "error"` và thông điệp chứa `cannot ask the user for permission`; stderr có dòng `permission requested: <tool> (<tham số>); auto-rejecting`. Adapter hiện trả `deniedActions: []` cố định (`src/agents/opencode.ts:91`).
86
+ - **Cấu hình mặc định không từ chối gì cả** (o1, o2): hàng đợi duyệt chỉ có việc để làm với OpenCode khi chủ máy đã đặt `permission` thành `ask` (hoặc `deny`) trong cấu hình của OpenCode. Đây là điểm khác lớn với `agy`/`codex`.
87
+ - **Từ chối `bash` không chặn việc chạy lệnh**: o6 cho thấy agent tìm đường khác qua MCP tool có sẵn trong cấu hình OpenCode của chủ máy. Một mức quyền chỉ nói về `bash`/`edit` là chưa đủ.
88
+ - `opencode` có `serve`, `acp`, `api` và cờ `--server <url>`; có thể có API duyệt quyền trong server. **Chưa đánh giá**, ngoài MVP.
89
+
90
+ ## 3. Bridge nhìn thấy gì hôm nay
91
+
92
+ | Agent | Nguồn tín hiệu từ chối | Kết quả trong bridge |
93
+ |---|---|---|
94
+ | `agy` | `denied_actions` trong JSON cuối (`antigravity.ts:39-44`) hoặc stderr khớp `auto-denied\|soft.denied\|permission.*cannot prompt` (dòng 54) | task `failed`, `denied_actions` điền |
95
+ | `codex` | item `declined`/`denied` (`codex.ts:62-63`) — **chưa từng thấy** | sự từ chối kiểu sandbox (mục 2.2) → task `completed`, `denied_actions: []` |
96
+ | `opencode` | không đọc gì (`opencode.ts:91`) | task `completed`, `denied_actions: []` |
97
+
98
+ ## 4. Việc adapter cần làm để hàng đợi có dữ liệu (thuộc B2, nếu thiết kế được duyệt)
99
+
100
+ - **opencode** (tín hiệu chắc chắn): mỗi `tool_use` có `state.status === "error"` và thông điệp khớp `/cannot ask the user for permission/` → một `denied_action` `{action: part.tool, display_name: <tham số rút gọn, đã redact>}`. Một task có `denied_actions` thì chuyển sang `failed` như `agy`, hay giữ `completed` kèm cảnh báo? **Xem câu hỏi 3.**
101
+ - **codex** (tín hiệu yếu): `command_execution` `failed` với output khớp `/access.*denied|permission denied|read-only file system/i` chỉ là **nghi ngờ** (agent có thể tự gây lỗi đó). Đề xuất: ghi vào hàng đợi như `suspected: true`, không đổi trạng thái task, và nói rõ trên giao diện rằng đó là suy đoán từ văn bản. Không dựa vào nó để tự động hóa gì.
102
+ - **agy**: giữ nguyên.
103
+
104
+ ## 5. Thiết kế MVP: "duyệt sau, chạy lại một lần"
105
+
106
+ ### 5.1 Luồng
107
+
108
+ 1. Task kết thúc; có `denied_actions` (hoặc nghi ngờ, mục 4) → một **yêu cầu duyệt** hiện trong hàng đợi và ở panel của task.
109
+ 2. Chủ bridge mở yêu cầu, thấy từng hành động bị từ chối (văn bản do CLI cung cấp, hiển thị bằng `textContent`, cắt độ dài, đã redact) và các lựa chọn:
110
+ - **Cho phép và chạy lại**: hộp thoại chọn *mức nâng* (mặc định là mức hẹp nhất dùng được cho agent đó, xem 5.2), cảnh báo nó có thể lộ ra điều gì, và ô `confirm`. Kết quả: **task mới** `retry_of: <task cũ>`, cùng yêu cầu đã lưu, kèm `elevation` của đúng lần này.
111
+ - **Bỏ qua**: đóng yêu cầu, không chạy gì.
112
+ 3. Mọi quyết định ghi vào nhật ký (`appendDecision`): hành động `approve`/`rerun` (cho phép và chạy lại, kèm mức nâng và id task mới) và `dismiss`.
113
+
114
+ ### 5.2 Quyền nâng cho đúng một lần chạy (mức hẹp nhất mà cờ cho phép)
115
+
116
+ | Agent | Mức hẹp → rộng | Mặc định | Có thể lộ ra |
117
+ |---|---|---|---|
118
+ | `agy` | `--mode accept-edits` → `--mode auto` → `--dangerously-skip-permissions` (từ `agy --help`; **chưa đo** hành động nào cần mức nào) | `accept-edits` nếu mọi hành động bị từ chối là ghi file (`write_file`, đã ghi nhận trước đây); còn lại chủ chọn tay, không đoán | `accept-edits`: sửa file trong workspace không hỏi. `auto`/`full`: lệnh và đường dẫn ngoài workspace, thông tin đăng nhập trên máy |
119
+ | `codex` | `--add-dir <thư mục>` (**đã đo**, mở đúng thư mục nêu tên) → `--sandbox danger-full-access` (**chưa đo**) | `--add-dir` cho thư mục xuất hiện trong hành động bị từ chối, khi suy ra được; ngược lại chủ nhập thư mục | `--add-dir`: ghi vào thư mục đó. `danger-full-access`: toàn bộ ổ đĩa và mạng. `--ask-for-approval` **không** giúp gì (đo: `on-request` = `never` trong `exec`) |
120
+ | `opencode` | patch quyền theo từng lần chạy qua `OPENCODE_CONFIG_CONTENT`, chỉ cho phép đúng quyền bị từ chối (ví dụ `{"permission":{"bash":"allow"}}`) → `--auto` | patch hẹp. **Chưa đo** patch này có thắng cấu hình `ask` toàn cục của người dùng không (cơ chế biến môi trường đã đo ở o4/o5; thứ tự ưu tiên thì chưa) | `bash: allow` ở OpenCode cho phép ghi **ngoài workspace** (o2) và mọi lệnh. `--auto` duyệt cả những gì cấu hình hỏi. MCP tool của chủ máy luôn nằm trong tầm với (o6) |
121
+
122
+ ### 5.3 Bảo đảm quyền chỉ áp dụng cho một lần chạy
123
+
124
+ - Quyền nâng chỉ đi vào **đối số/biến môi trường của lần launch đó** (`extraArgs`/`customEnv` đã có sẵn trong `AgentTask`) và vào trường `elevation` của bản ghi task mới. **Không ghi** `permissions.json`, `workspaces.json`, cấu hình CLI hay biến môi trường của bridge; cài đặt toàn cục của chủ máy không đổi.
125
+ - Task chạy lại **không kế thừa** gì cho task sau: chạy lại một task chạy lại cần một lần duyệt mới. `retry_of` chỉ để tra cứu, không mang quyền.
126
+ - Mức nâng có **trần** là lựa chọn của chủ trong hộp thoại xác nhận; lúc launch, bridge kiểm tra lại `elevation` trên bản ghi chứ không tin giá trị nào khác (cùng tinh thần `lowerLevel` ở `permissions.ts`: không có đường nào làm quyền tăng sau khi chủ đã chọn).
127
+ - Task cũ được giữ nguyên làm bằng chứng; task mới dùng **đúng yêu cầu đã lưu của task cũ** (không lấy văn bản từ đầu ra của agent).
128
+
129
+ ### 5.4 Chỉ chủ bridge duyệt, và agent không tự duyệt được
130
+
131
+ - Chỉ qua **web UI** (`POST /ui/api/…`, `confirm: true`), cùng mô hình tin cậy với "Áp dụng/Bỏ worktree" (#40) và dừng mission. **Không thêm tool MCP nào** để xem hàng đợi, duyệt hay nâng quyền; `tests/http.test.ts` và `tests/bridge.test.ts` đều đếm số tool nên thêm nhầm sẽ đỏ.
132
+ - Văn bản trong yêu cầu (tên hành động, tham số) do CLI/agent tạo ra nên **không tin cậy**: hiển thị bằng `textContent`, giới hạn độ dài, qua `redact`; không bao giờ đưa vào một câu lệnh hay vào prompt của lần chạy lại.
133
+ - Một agent không thể tạo yêu cầu duyệt cho chính nó ngoài việc bị từ chối thật, và không có đường nào từ nội dung nó viết tới nút "Cho phép".
134
+
135
+ ### 5.5 Dữ liệu
136
+
137
+ Không thêm kho trạng thái mới. Yêu cầu duyệt được **suy ra** từ bản ghi task (`denied_actions`, trường mới `retry_of`/`elevation`) và từ `decisions.jsonl` (đã có, chỉ thêm giá trị `approve`/`rerun`/`dismiss` vào `DecisionAction`). Một yêu cầu là "chờ" khi task có `denied_actions` và chưa có quyết định nào cho task đó. Hệ quả: khởi động lại bridge không làm mất hàng đợi, và nhật ký vẫn là nguồn duy nhất.
138
+
139
+ ### 5.6 Task `isolation: worktree`
140
+
141
+ Đề xuất: lần chạy lại tạo **worktree mới từ HEAD** (sạch), worktree cũ giữ nguyên để chủ xem và bỏ. Cái giá: mất phần việc dở của lần đầu. Phương án khác (dùng lại worktree) giữ việc dở nhưng tái hiện đúng vấn đề đã ghi ở `docs/SMART-ROUTER-PLAN.md` (phạm vi là tổng hai lần). **Xem câu hỏi 2.**
142
+
143
+ ## 6. Ranh giới của MVP
144
+
145
+ Ngoài MVP và chưa kiểm: duyệt giữa chừng (API server của OpenCode chưa đánh giá); mọi thứ về `agy` 1.4.12; hình dạng `denied_actions` ngoài `write_file`; `codex --sandbox danger-full-access`; patch cấu hình OpenCode có thắng cấu hình toàn cục không; hành vi trên Linux/macOS (mọi số đo ở trên là Windows).
146
+
147
+ ## 7. Quan sát ngoài phạm vi (không sửa trong PR này)
148
+
149
+ 1. **OpenCode `provider.free-limit` không được nhận là hết quota.** Run bị 429 `FreeUsageLimitError` ("Free usage limit reached. Connect a provider or subscribe to OpenCode Go to keep going, or switch models."): `isQuotaError` trả `false` cho cả thông điệp lẫn `provider.free-limit` (đã chạy), nên một pool có OpenCode **không failover** khi hạn mức miễn phí hết.
150
+ 2. **OpenCode chạy với MCP tool của chủ máy** (o6): từ chối `bash` không chặn việc thử chạy lệnh qua tool khác. Đáng cân nhắc cô lập cấu hình OpenCode của task (cấu hình riêng qua `OPENCODE_CONFIG_CONTENT`/`XDG_*`).
151
+ 3. **Codex nạp MCP server của người dùng, kể cả chính bridge** (stderr mục 2.2). Khi bridge đang chạy ở cổng đó, một task `codex` do bridge giao có thể gọi lại `antigravity_delegate` — một đường đệ quy chưa được chặn. Chưa đo (trong các lần đo bridge không chạy ở cổng 3000).
152
+ 4. Sandbox Windows của codex thỉnh thoảng thất bại ở khâu khởi tạo (c1, `Some(143)`); bridge sẽ thấy lệnh `failed` mà không có dấu hiệu từ chối.
153
+
154
+ ## 8. Câu hỏi cần chủ dự án quyết trước khi viết mã
155
+
156
+ 1. **Chấp nhận "duyệt sau"** (không duyệt giữa chừng) làm MVP? Đây là kết luận của các số đo ở mục 2.
157
+ 2. **Task worktree chạy lại**: worktree mới từ HEAD (đề xuất) hay dùng lại worktree cũ?
158
+ 3. **Task có `denied_actions` ở OpenCode/Codex**: giữ `completed` kèm cảnh báo, hay chuyển `failed` như `agy`? (Đổi trạng thái là đổi hành vi hiện tại, ảnh hưởng mission và pool failover.)
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
+ 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
+ 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.32",
3
+ "version": "0.1.34",
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));