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 +1 -1
- package/dist/src/agents/codex.js +16 -1
- package/dist/src/agents/opencode.js +67 -4
- package/dist/src/approvals.js +79 -0
- package/dist/src/decisions.js +2 -1
- package/dist/src/tasks/manager.js +108 -7
- package/dist/src/ui/review-routes.js +53 -0
- package/dist/src/ui/routes.js +3 -0
- package/docs/APPROVAL-QUEUE-PLAN.md +40 -1
- package/docs/MULTI-AGENT-PLAN.md +6 -2
- package/package.json +1 -1
- package/ui/app.css +12 -0
- package/ui/app.js +143 -6
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
|
|
package/dist/src/agents/codex.js
CHANGED
|
@@ -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 (
|
|
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:
|
|
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
|
-
|
|
56
|
-
|
|
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
|
package/dist/src/decisions.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/src/ui/routes.js
CHANGED
|
@@ -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:**
|
|
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.
|
package/docs/MULTI-AGENT-PLAN.md
CHANGED
|
@@ -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
|
-
|
|
256
|
-
|
|
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.
|
|
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
|
|
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
|
|
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));
|