agy-mcp-bridge 0.1.30 → 0.1.32
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/dist/src/agents/codex.js +3 -17
- package/dist/src/agents/opencode.js +3 -17
- package/dist/src/agents/probe.js +20 -0
- package/dist/src/agents/registry.js +17 -10
- package/dist/src/cli.js +8 -1
- package/dist/src/freebuff-config.js +5 -0
- package/dist/src/isolation/apply.js +1 -1
- package/dist/src/isolation/diff.js +44 -0
- package/dist/src/tasks/manager.js +8 -2
- package/dist/src/ui/routes.js +11 -0
- package/docs/MULTI-AGENT-PLAN.md +4 -1
- package/docs/OPENCODE-INTEGRATION-PLAN.md +18 -0
- package/package.json +1 -1
- package/ui/app.css +9 -0
- package/ui/app.js +37 -4
package/dist/src/agents/codex.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { isQuotaError, isStrongQuotaError } from './quota.js';
|
|
4
|
-
import {
|
|
4
|
+
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;
|
|
@@ -86,20 +86,6 @@ export function codexAdapter(command, options = {}) {
|
|
|
86
86
|
export function codexAgent(command, status, options = {}) {
|
|
87
87
|
return { adapter: codexAdapter(command, options), status, enabled: true, prefixArgs: command.prefixArgs };
|
|
88
88
|
}
|
|
89
|
-
async function capture(command, args, cwd, extraEnvPassthrough) {
|
|
90
|
-
return new Promise((resolve, reject) => {
|
|
91
|
-
const child = startProcess(command.executable, [...command.prefixArgs, ...args], cwd, { extraEnvPassthrough });
|
|
92
|
-
let text = '';
|
|
93
|
-
let exceeded = false;
|
|
94
|
-
const timer = setTimeout(() => { exceeded = true; void terminateOwned(child).catch(reject); }, 10000);
|
|
95
|
-
const append = (chunk) => { if (text.length < 32768)
|
|
96
|
-
text += chunk.toString().slice(0, 32768 - text.length); };
|
|
97
|
-
child.stdout.on('data', append);
|
|
98
|
-
child.stderr.on('data', append);
|
|
99
|
-
child.once('error', error => { clearTimeout(timer); reject(error); });
|
|
100
|
-
child.once('close', code => { clearTimeout(timer); exceeded ? reject(new Error('Codex inspection timed out')) : resolve({ code, text }); });
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
89
|
/** Non-interactive `codex exec` support is the only contract this adapter relies on. */
|
|
104
90
|
export async function detectCodex(command, options = {}) {
|
|
105
91
|
const cwd = options.cwd ?? process.cwd();
|
|
@@ -108,8 +94,8 @@ export async function detectCodex(command, options = {}) {
|
|
|
108
94
|
id: 'codex', label: 'Codex', available, version, executable: command.source, capabilities: CAPABILITIES, warnings
|
|
109
95
|
});
|
|
110
96
|
try {
|
|
111
|
-
const version = await
|
|
112
|
-
const help = await
|
|
97
|
+
const version = await captureOutput('Codex', command, ['--version'], cwd, extra);
|
|
98
|
+
const help = await captureOutput('Codex', command, ['exec', '--help'], cwd, extra);
|
|
113
99
|
const versionText = version.text.trim().split(/\r?\n/)[0] ?? '';
|
|
114
100
|
const parsed = /codex-cli\s+([^\s]+)/i.exec(versionText)?.[1] ?? (versionText || null);
|
|
115
101
|
const missing = REQUIRED_FLAGS.filter(flag => !new RegExp(`(?:^|\\s)${flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=\\s|,|$)`).test(help.text));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { captureOutput } from './probe.js';
|
|
2
2
|
import { isQuotaError } from './quota.js';
|
|
3
3
|
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.
|
|
@@ -120,20 +120,6 @@ export function opencodeAdapter(command, options = {}) {
|
|
|
120
120
|
export function opencodeAgent(command, status, options = {}) {
|
|
121
121
|
return { adapter: opencodeAdapter(command, options), status, enabled: true, prefixArgs: command.prefixArgs };
|
|
122
122
|
}
|
|
123
|
-
function capture(command, args, cwd, extraEnvPassthrough) {
|
|
124
|
-
return new Promise((resolve, reject) => {
|
|
125
|
-
const child = startProcess(command.executable, [...command.prefixArgs, ...args], cwd, { extraEnvPassthrough });
|
|
126
|
-
let text = '';
|
|
127
|
-
let exceeded = false;
|
|
128
|
-
const timer = setTimeout(() => { exceeded = true; void terminateOwned(child).catch(reject); }, 10000);
|
|
129
|
-
const append = (chunk) => { if (text.length < 32768)
|
|
130
|
-
text += chunk.toString().slice(0, 32768 - text.length); };
|
|
131
|
-
child.stdout.on('data', append);
|
|
132
|
-
child.stderr.on('data', append);
|
|
133
|
-
child.once('error', error => { clearTimeout(timer); reject(error); });
|
|
134
|
-
child.once('close', code => { clearTimeout(timer); exceeded ? reject(new Error('OpenCode inspection timed out')) : resolve({ code, text }); });
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
123
|
/** Non-interactive `opencode run --format json` is the only contract this adapter relies on. */
|
|
138
124
|
export async function detectOpenCode(command, opts = {}) {
|
|
139
125
|
const cwd = opts.cwd ?? process.cwd();
|
|
@@ -142,8 +128,8 @@ export async function detectOpenCode(command, opts = {}) {
|
|
|
142
128
|
id: 'opencode', label: 'OpenCode', available, version, executable: command.executable, capabilities: CAPABILITIES, warnings
|
|
143
129
|
});
|
|
144
130
|
try {
|
|
145
|
-
const version = await
|
|
146
|
-
const help = await
|
|
131
|
+
const version = await captureOutput('OpenCode', command, ['--version'], cwd, extra);
|
|
132
|
+
const help = await captureOutput('OpenCode', command, ['run', '--help'], cwd, extra);
|
|
147
133
|
const parsed = version.text.trim().split(/\r?\n/)[0]?.replace(/^opencode\s+/i, '').replace(/^v/i, '') || null;
|
|
148
134
|
if (version.code !== 0)
|
|
149
135
|
return status(false, null, ['opencode --version failed']);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { startProcess, terminateOwned } from '../antigravity/process.js';
|
|
2
|
+
/**
|
|
3
|
+
* Runs an agent CLI once to read its output (`--version`, `<subcommand> --help`) while it is being inspected. Bounded in time (10 s) and in
|
|
4
|
+
* size (32 KiB of combined stdout and stderr), and the process is killed with its tree when the time runs out. `label` names the CLI in the error.
|
|
5
|
+
*/
|
|
6
|
+
export function captureOutput(label, command, args, cwd, extraEnvPassthrough) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const child = startProcess(command.executable, [...command.prefixArgs, ...args], cwd, { extraEnvPassthrough });
|
|
9
|
+
let text = '';
|
|
10
|
+
let exceeded = false;
|
|
11
|
+
const timer = setTimeout(() => { exceeded = true; void terminateOwned(child).catch(reject); }, 10000);
|
|
12
|
+
const append = (chunk) => { if (text.length < 32768)
|
|
13
|
+
text += chunk.toString().slice(0, 32768 - text.length); };
|
|
14
|
+
child.stdout.on('data', append);
|
|
15
|
+
child.stderr.on('data', append);
|
|
16
|
+
child.once('error', error => { clearTimeout(timer); reject(error); });
|
|
17
|
+
child.once('close', code => { clearTimeout(timer); exceeded ? reject(new Error(`${label} inspection timed out`)) : resolve({ code, text }); });
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=probe.js.map
|
|
@@ -54,23 +54,30 @@ export async function createAgentRegistry(config, antigravity) {
|
|
|
54
54
|
askForApproval: settings.codex_ask_for_approval
|
|
55
55
|
}));
|
|
56
56
|
const opencodeEnabled = enabled.includes('opencode');
|
|
57
|
-
|
|
57
|
+
const autoApproveWarning = (id) => `Agent "${id}" is configured to auto-approve permissions (opencode_auto_approve): tasks run with opencode --auto`;
|
|
58
|
+
// The OpenCode CLI is probed when the `opencode` agent is enabled, and also when an enabled opencode profile relies on it:
|
|
59
|
+
// OpenCode is opt-in, so a profile declared without `enabled` must not end up unavailable because the base agent is off.
|
|
60
|
+
const profileUsesOpencode = Object.entries(settings.profiles ?? {}).some(([id, profile]) => profile.adapter === 'opencode' && !profile.executable && (enabled.includes(id) || !settings.enabled));
|
|
61
|
+
let opencodeProbe = {
|
|
58
62
|
id: 'opencode', label: 'OpenCode', available: false, version: null, executable: null,
|
|
59
63
|
capabilities: CAPABILITIES, warnings: ['Disabled in the bridge config']
|
|
60
64
|
};
|
|
61
65
|
let opencodeCommand = { executable: 'opencode', prefixArgs: [], source: 'opencode' };
|
|
62
|
-
if (opencodeEnabled) {
|
|
66
|
+
if (opencodeEnabled || profileUsesOpencode) {
|
|
63
67
|
try {
|
|
64
68
|
opencodeCommand = await resolveCommand(settings.opencode_executable ?? 'opencode');
|
|
65
|
-
|
|
69
|
+
opencodeProbe = await detectOpenCode(opencodeCommand, { cwd: process.cwd(), extraEnvPassthrough: config.extra_env_passthrough });
|
|
66
70
|
}
|
|
67
71
|
catch (error) {
|
|
68
|
-
|
|
72
|
+
opencodeProbe = { ...opencodeProbe, warnings: [`OpenCode CLI not found: ${error instanceof Error ? error.message : 'unknown error'}`] };
|
|
69
73
|
}
|
|
70
74
|
}
|
|
71
|
-
|
|
75
|
+
// The base agent is only available when it was asked for; the probe above may exist just for a profile.
|
|
76
|
+
let opencodeStatus = opencodeEnabled
|
|
77
|
+
? opencodeProbe
|
|
78
|
+
: { ...opencodeProbe, available: false, warnings: ['Disabled in the bridge config'] };
|
|
72
79
|
if (opencodeEnabled && settings.opencode_auto_approve)
|
|
73
|
-
opencodeStatus = { ...opencodeStatus, warnings: [...opencodeStatus.warnings,
|
|
80
|
+
opencodeStatus = { ...opencodeStatus, warnings: [...opencodeStatus.warnings, autoApproveWarning('opencode')] };
|
|
74
81
|
agents.push(opencodeAgent(opencodeCommand, opencodeStatus, {
|
|
75
82
|
model: settings.opencode_model,
|
|
76
83
|
autoApprove: settings.opencode_auto_approve
|
|
@@ -79,7 +86,7 @@ export async function createAgentRegistry(config, antigravity) {
|
|
|
79
86
|
for (const [profileId, profile] of Object.entries(settings.profiles)) {
|
|
80
87
|
const isProfileEnabled = enabled.includes(profileId) || (!settings.enabled && true);
|
|
81
88
|
const adapterType = profile.adapter;
|
|
82
|
-
const label = profile.label ??
|
|
89
|
+
const label = profile.label ?? `${{ codex: 'Codex', antigravity: 'Antigravity', opencode: 'OpenCode' }[adapterType]} (${profileId})`;
|
|
83
90
|
const profileWarnings = [];
|
|
84
91
|
if (!isProfileEnabled)
|
|
85
92
|
profileWarnings.push('Disabled in the bridge config');
|
|
@@ -148,8 +155,8 @@ export async function createAgentRegistry(config, antigravity) {
|
|
|
148
155
|
else if (adapterType === 'opencode') {
|
|
149
156
|
let cmd = opencodeCommand.executable;
|
|
150
157
|
let prefixArgs = opencodeCommand.prefixArgs;
|
|
151
|
-
let available =
|
|
152
|
-
let version =
|
|
158
|
+
let available = opencodeProbe.available;
|
|
159
|
+
let version = opencodeProbe.version;
|
|
153
160
|
if (profile.executable) {
|
|
154
161
|
try {
|
|
155
162
|
const resolved = await resolveCommand(profile.executable);
|
|
@@ -170,7 +177,7 @@ export async function createAgentRegistry(config, antigravity) {
|
|
|
170
177
|
version: version || null, executable: cmd || null,
|
|
171
178
|
capabilities: CAPABILITIES,
|
|
172
179
|
warnings: isProfileEnabled
|
|
173
|
-
? [...
|
|
180
|
+
? [...(profile.executable ? [] : opencodeProbe.warnings.filter(w => !w.includes('Disabled'))), ...profileWarnings, ...(settings.opencode_auto_approve ? [autoApproveWarning(profileId)] : [])]
|
|
174
181
|
: profileWarnings
|
|
175
182
|
};
|
|
176
183
|
agents.push({
|
package/dist/src/cli.js
CHANGED
|
@@ -10,7 +10,7 @@ import { addSessionWorkspaces } from './config.js';
|
|
|
10
10
|
import { recoverExecutionLock } from './tasks/execution-lock.js';
|
|
11
11
|
import { renderSetup } from './setup.js';
|
|
12
12
|
import { DEFAULT_TOOL_TIMEOUT_SECONDS, codexConfigPath, planCodexConfig, readCodexConfig, writeCodexConfig } from './codex-config.js';
|
|
13
|
-
import { freebuffConfigPath, planFreebuffConfig, readFreebuffConfig, writeFreebuffConfig } from './freebuff-config.js';
|
|
13
|
+
import { freebuffConfigPath, isTransientInstall, planFreebuffConfig, readFreebuffConfig, writeFreebuffConfig } from './freebuff-config.js';
|
|
14
14
|
import { readWait } from './wait-setting.js';
|
|
15
15
|
import { configuredPort } from './http.js';
|
|
16
16
|
import { offerSkillInstall, offerSkillUpdate } from './onboarding.js';
|
|
@@ -217,6 +217,8 @@ async function setup(args) {
|
|
|
217
217
|
return;
|
|
218
218
|
}
|
|
219
219
|
const clients = target === 'both' ? ['codex', 'claude'] : [target];
|
|
220
|
+
if (target === 'freebuff' && isTransientInstall(paths.packageRoot))
|
|
221
|
+
console.error(`Note: this copy of ${BIN} runs from a temporary npx cache, so the stdio command printed below stops working once npm clears it. Install it globally (npm install -g ${pkg.name}) and print it again before using it.`);
|
|
220
222
|
console.log(renderSetup({ clients, mcpUrl, shells: shell, bin: BIN, waitSeconds: readWait(paths.stateDirectory) ?? undefined, stdio: stdioCommand() }));
|
|
221
223
|
}
|
|
222
224
|
/** The command a stdio client must run for THIS installation: the interpreter and the packaged stdio entry, both absolute. */
|
|
@@ -225,6 +227,11 @@ export function stdioCommand() {
|
|
|
225
227
|
}
|
|
226
228
|
/** Shows exactly what would change in Freebuff's .agents/mcp.json and writes it only after a yes (or --yes). */
|
|
227
229
|
async function applyFreebuffSetup(project, yes) {
|
|
230
|
+
if (isTransientInstall(paths.packageRoot)) {
|
|
231
|
+
console.error(`Not changed: this copy of ${BIN} runs from a temporary npx cache (${paths.packageRoot}), which npm can delete, so Freebuff's config would point at a path that stops working. Install it first: npm install -g ${pkg.name}, then run ${BIN} setup freebuff --apply again.`);
|
|
232
|
+
process.exitCode = 1;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
228
235
|
if (project !== undefined) {
|
|
229
236
|
const info = await stat(project).catch(() => null);
|
|
230
237
|
if (!info?.isDirectory())
|
|
@@ -10,6 +10,11 @@ import path from 'node:path';
|
|
|
10
10
|
* diff stays readable. The caller shows the plan and asks before anything is written, and a backup is made first.
|
|
11
11
|
*/
|
|
12
12
|
export const SERVER_KEY = 'antigravity';
|
|
13
|
+
/**
|
|
14
|
+
* `npx` unpacks a package into a cache directory (`.../_npx/<hash>/node_modules/...`) that npm may delete at any time. An
|
|
15
|
+
* absolute path into it must not be written into a client's persistent config: it works today and breaks silently later.
|
|
16
|
+
*/
|
|
17
|
+
export const isTransientInstall = (packageRoot) => /[\\/]_npx[\\/]/.test(packageRoot);
|
|
13
18
|
/** `~/.agents/mcp.json`, or `<project>/.agents/mcp.json`: Freebuff reads the file nearest to where it runs. */
|
|
14
19
|
export const freebuffConfigPath = (options = {}) => path.join(options.project ?? options.home ?? os.homedir(), '.agents', 'mcp.json');
|
|
15
20
|
/** The indent the file already uses, so re-serializing it does not reformat the servers someone else wrote. */
|
|
@@ -12,7 +12,7 @@ import { isOwned, normalizeOwn } from './worktree.js';
|
|
|
12
12
|
*/
|
|
13
13
|
const MAX_PATCH_BYTES = 8 * 1024 * 1024;
|
|
14
14
|
const MAX_FILES = 500;
|
|
15
|
-
const gitEnvironment = () => ({
|
|
15
|
+
export const gitEnvironment = () => ({
|
|
16
16
|
PATH: process.env.PATH, PATHEXT: process.env.PATHEXT, SystemRoot: process.env.SystemRoot, COMSPEC: process.env.COMSPEC,
|
|
17
17
|
TEMP: process.env.TEMP, TMP: process.env.TMP, TMPDIR: process.env.TMPDIR,
|
|
18
18
|
GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null',
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { redact } from '../security/redact.js';
|
|
4
|
+
import { gitEnvironment } from './apply.js';
|
|
5
|
+
/**
|
|
6
|
+
* The content diff of one file in a task's worktree, for the owner to read before applying it. Read only: nothing is staged or written
|
|
7
|
+
* (`git add` would race with an agent that is still running), and the worktree is bridge-owned, so its files are not the owner's.
|
|
8
|
+
* Secrets are filtered the way task logs are.
|
|
9
|
+
*/
|
|
10
|
+
const MAX_DIFF_CHARS = 200_000;
|
|
11
|
+
const NULL_DEVICE = process.platform === 'win32' ? 'NUL' : '/dev/null';
|
|
12
|
+
/** A path inside the worktree, as the changes list reports it: relative, no drive, no `..`, no empty segment. */
|
|
13
|
+
export function isRelativeFilePath(file) {
|
|
14
|
+
if (typeof file !== 'string' || !file || file.length > 512 || file.includes('\0'))
|
|
15
|
+
return false;
|
|
16
|
+
if (path.isAbsolute(file) || /^[A-Za-z]:/.test(file) || file.startsWith('/') || file.startsWith('\\'))
|
|
17
|
+
return false;
|
|
18
|
+
return !file.replace(/\\/g, '/').split('/').some(part => part === '' || part === '..');
|
|
19
|
+
}
|
|
20
|
+
function capture(cwd, args) {
|
|
21
|
+
return new Promise(resolve => {
|
|
22
|
+
execFile('git', ['-c', 'core.fsmonitor=false', ...args], { cwd, env: gitEnvironment(), timeout: 20000, maxBuffer: 1024 * 1024, encoding: 'utf8', windowsHide: true }, (error, stdout) => {
|
|
23
|
+
const code = error ? (typeof error.code === 'number' ? error.code : null) : 0;
|
|
24
|
+
resolve({ stdout: stdout ?? '', code, overflow: Boolean(error) && /maxBuffer/i.test(error.message) });
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
export async function worktreeFileDiff(worktree, file) {
|
|
29
|
+
if (!isRelativeFilePath(file))
|
|
30
|
+
throw new Error('path must be a relative path inside the worktree');
|
|
31
|
+
const literal = `:(literal)${file}`;
|
|
32
|
+
// 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
|
+
const tracked = (await capture(worktree, ['ls-files', '--error-unmatch', '-z', '--', literal])).code === 0;
|
|
34
|
+
const result = tracked
|
|
35
|
+
? await capture(worktree, ['diff', 'HEAD', '--no-ext-diff', '--no-textconv', '--no-color', '--', literal])
|
|
36
|
+
: await capture(worktree, ['diff', '--no-index', '--no-ext-diff', '--no-textconv', '--no-color', '--', NULL_DEVICE, file]);
|
|
37
|
+
// `git diff --no-index` exits 1 when the files differ; 128 means the file is not there at all.
|
|
38
|
+
if (!result.stdout && !result.overflow)
|
|
39
|
+
return { path: file, diff: '', truncated: false, binary: false, changed: false };
|
|
40
|
+
const truncated = result.overflow || result.stdout.length > MAX_DIFF_CHARS;
|
|
41
|
+
const text = redact(result.stdout.slice(0, MAX_DIFF_CHARS));
|
|
42
|
+
return { path: file, diff: text, truncated, binary: /^Binary files .* differ$/m.test(text), changed: true };
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=diff.js.map
|
|
@@ -7,6 +7,7 @@ import { DEFAULT_MAX_WAIT_SECONDS, delegateSchema, promptFor } from '../security
|
|
|
7
7
|
import { inside, validateWorkspace } from '../security/workspace.js';
|
|
8
8
|
import { appendDecision } from '../decisions.js';
|
|
9
9
|
import { applyWorktreeChanges, repositoryRoot } from '../isolation/apply.js';
|
|
10
|
+
import { worktreeFileDiff } from '../isolation/diff.js';
|
|
10
11
|
import { redact } from '../security/redact.js';
|
|
11
12
|
import { delegationState } from '../delegation.js';
|
|
12
13
|
import { startProcess, terminateOwned } from '../antigravity/process.js';
|
|
@@ -744,14 +745,14 @@ export class TaskManager {
|
|
|
744
745
|
this.decisionQueue = run.catch(() => undefined);
|
|
745
746
|
return run;
|
|
746
747
|
}
|
|
747
|
-
async reviewableTask(id) {
|
|
748
|
+
async reviewableTask(id, allowRunning = false) {
|
|
748
749
|
const entry = this.entries.get(id);
|
|
749
750
|
if (!entry)
|
|
750
751
|
throw new Error('Unknown or expired task_id');
|
|
751
752
|
const task = entry.task;
|
|
752
753
|
if (task.isolation !== 'worktree' || !task.worktree)
|
|
753
754
|
throw new Error('This task did not run in a worktree');
|
|
754
|
-
if (task.status === 'queued' || task.status === 'running')
|
|
755
|
+
if (!allowRunning && (task.status === 'queued' || task.status === 'running'))
|
|
755
756
|
throw new Error('The task is still running');
|
|
756
757
|
if (task.discarded_at)
|
|
757
758
|
throw new Error('This task worktree was already discarded');
|
|
@@ -768,6 +769,11 @@ export class TaskManager {
|
|
|
768
769
|
recordDecision(id, action, ok, detail) {
|
|
769
770
|
return appendDecision(this.runtime.stateDirectory, { at: new Date().toISOString(), task_id: id, action, ok, detail });
|
|
770
771
|
}
|
|
772
|
+
/** One file's content diff from a worktree task. Read only, so it is allowed while the task still runs. */
|
|
773
|
+
async fileDiff(id, file) {
|
|
774
|
+
const task = await this.reviewableTask(id, true);
|
|
775
|
+
return worktreeFileDiff(task.worktree, file);
|
|
776
|
+
}
|
|
771
777
|
applyTask(id) {
|
|
772
778
|
return this.enqueueDecision(async () => {
|
|
773
779
|
const task = await this.reviewableTask(id);
|
package/dist/src/ui/routes.js
CHANGED
|
@@ -6,6 +6,7 @@ import { paths } from '../paths.js';
|
|
|
6
6
|
import { knownUpdate, readPackageInfo } from '../update.js';
|
|
7
7
|
import { workspaceChanges } from '../changes.js';
|
|
8
8
|
import { readDecisions } from '../decisions.js';
|
|
9
|
+
import { isRelativeFilePath } from '../isolation/diff.js';
|
|
9
10
|
import { validModelId, writeModel } from '../models.js';
|
|
10
11
|
import { readProgress, writeProgress } from '../progress.js';
|
|
11
12
|
import { readUsage, summarizeUsage } from '../usage.js';
|
|
@@ -193,6 +194,16 @@ export async function handleUi(req, res, ctx) {
|
|
|
193
194
|
json(res, 200, { task: await ctx.manager.discardTask(discard[1]) });
|
|
194
195
|
return true;
|
|
195
196
|
}
|
|
197
|
+
const fileDiff = pathname.match(new RegExp(`^/ui/api/tasks/(${UUID})/diff$`));
|
|
198
|
+
if (fileDiff && method === 'GET') {
|
|
199
|
+
const file = new URL(req.url ?? '', 'http://localhost').searchParams.get('path');
|
|
200
|
+
if (!isRelativeFilePath(file)) {
|
|
201
|
+
json(res, 400, { error: 'path must be a relative path inside the worktree' });
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
json(res, 200, await ctx.manager.fileDiff(fileDiff[1], file));
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
196
207
|
if (pathname === '/ui/api/decisions' && method === 'GET') {
|
|
197
208
|
json(res, 200, { decisions: await readDecisions(ctx.manager.stateDirectory) });
|
|
198
209
|
return true;
|
package/docs/MULTI-AGENT-PLAN.md
CHANGED
|
@@ -245,12 +245,15 @@ ngân sách token theo mission — usage vẫn chỉ được ghi và tổng h
|
|
|
245
245
|
không đổi nhánh). Bị từ chối, kèm lý do, khi: task chưa `completed`, có file ngoài `owns`, workspace không còn trong allowlist,
|
|
246
246
|
workspace đang có sửa đổi chưa commit trên cùng file, patch không còn khớp (workspace đã đi tiếp), hoặc thay đổi có symlink/submodule.
|
|
247
247
|
- `Bỏ worktree` xóa thư mục và nhánh `bridge/<id>` của task (giữ nguyên workspace).
|
|
248
|
+
- Diff nội dung từng file (lát cắt 2): bấm một file ở "Thay đổi trong workspace" của task worktree để đọc diff so với commit gốc
|
|
249
|
+
(`GET /ui/api/tasks/<id>/diff?path=<file>`). Chỉ đọc, không stage gì (an toàn khi task còn chạy), secret được lọc như log, chỉ nhận đường dẫn
|
|
250
|
+
tương đối trong worktree. Task không dùng worktree chưa có diff nội dung.
|
|
248
251
|
- Nhật ký quyết định ở `<state>/decisions.jsonl` (mỗi dòng: thời điểm, task, apply/discard, ok, chi tiết), hiện trong panel và ở `GET /ui/api/decisions`.
|
|
249
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
|
|
250
253
|
agent hay client không tự áp dụng kết quả của chính nó.
|
|
251
254
|
|
|
252
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
|
|
253
|
-
ngoài chính sách
|
|
256
|
+
ngoài chính sách và nút hợp nhất theo nhánh (commit).
|
|
254
257
|
|
|
255
258
|
### Phase 5 — Tích hợp và hardening
|
|
256
259
|
|
|
@@ -5,6 +5,24 @@
|
|
|
5
5
|
|
|
6
6
|
Không thay đổi hành vi hiện tại của antigravity và codex. OpenCode nằm trong **Phase 3** theo `docs/MULTI-AGENT-PLAN.md`.
|
|
7
7
|
|
|
8
|
+
## Đã làm khác thiết kế ban đầu (đọc mục này trước các mục 1–3)
|
|
9
|
+
|
|
10
|
+
Các mục 1–3 bên dưới là bản thiết kế viết trước khi có code, giữ lại để biết lý do. Nơi nào mâu thuẫn với bảng này thì **bảng này đúng** (đã đối chiếu với `opencode` v2.2.5 và với code):
|
|
11
|
+
|
|
12
|
+
| Thiết kế ban đầu | Thực tế |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `opencode` vào `DEFAULT_ENABLED` | **Mặc định tắt**; chỉ chạy khi `agents.enabled` liệt kê. Profile OpenCode khai báo mà bỏ `agents.enabled` vẫn được kiểm tra CLI và báo lý do nếu không dùng được |
|
|
15
|
+
| `opencode run <prompt>` qua argv | `opencode run --standalone --format json`, prompt qua **stdin** (argv bị giới hạn độ dài, và prompt bắt đầu bằng `-` bị đọc thành cờ) |
|
|
16
|
+
| không nhắc service nền | **`--standalone` bắt buộc**: mặc định `opencode run` giao việc cho service nền dùng chung của OpenCode Desktop, hủy task không dừng agent và shell của agent nằm ngoài cây process mà bridge dọn (đo ở `docs/HANDOFF.md` mục 7) |
|
|
17
|
+
| `OPENCODE_HOME` tách tài khoản | Biến này **không có tác dụng**; dùng `XDG_DATA_HOME` / `XDG_CONFIG_HOME` trong `profile.env` |
|
|
18
|
+
| model `gpt-5` | Dạng `provider/model` (xem `opencode models`), ví dụ `opencode/big-pickle` |
|
|
19
|
+
| `parseResult` đọc JSON đơn hoặc text thường | Đọc luồng JSONL: sự kiện `text` cuối là câu trả lời, `step_finish` cho lượt và token, `error` là thất bại |
|
|
20
|
+
| `autonomy: "full"` có thể thêm `--auto` | **Không bao giờ**: `autonomy` do client chọn và chỉ là chỉ dẫn. `--auto` chỉ bật bằng `agents.opencode_auto_approve` trong config của chủ máy, và hiện trong cảnh báo của `agents_list` |
|
|
21
|
+
| không có giới hạn log | `compactLine` bỏ `step_start` và rút `tool_use` về tên tool và trạng thái, để luồng JSON không vượt giới hạn output |
|
|
22
|
+
| quyền mặc định không bàn tới | OpenCode **chạy lệnh shell và sửa file mà không hỏi** kể cả không có `--auto` (đo trên v2.2.5): mô hình quyền của nó lỏng hơn mặc định của `agy` và Codex `workspace-write`. Bật nó trong `agents.enabled` là chấp nhận điều đó |
|
|
23
|
+
|
|
24
|
+
Mục 3 ("Câu hỏi cần trả lời") đã được trả lời bằng bảng trên; không còn câu hỏi mở.
|
|
25
|
+
|
|
8
26
|
---
|
|
9
27
|
|
|
10
28
|
## 1. Khai báo kỹ thuật
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agy-mcp-bridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.32",
|
|
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
|
@@ -150,6 +150,15 @@ details.more .body { padding: 10px 12px 12px; }
|
|
|
150
150
|
.change-list { margin: 0; padding: 0; list-style: none; display: grid; max-height: 360px; overflow: auto; border: 1px solid var(--border); border-radius: 10px; }
|
|
151
151
|
.change-row { display: grid; grid-template-columns: 64px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 7px 12px; border-top: 1px solid var(--border); font-size: 13px; }
|
|
152
152
|
.change-row:first-child { border-top: 0; }
|
|
153
|
+
.change-row.clickable { cursor: pointer; }
|
|
154
|
+
.change-row.clickable:hover, .change-row.clickable:focus-visible { background: var(--surface-2); outline: none; }
|
|
155
|
+
.diff-box { margin-top: 12px; display: grid; gap: 8px; }
|
|
156
|
+
.diff { white-space: pre; overflow: auto; max-height: 460px; }
|
|
157
|
+
.diff span { display: block; min-height: 1.3em; }
|
|
158
|
+
.diff .add { color: var(--ok); background: var(--ok-soft); }
|
|
159
|
+
.diff .del { color: var(--bad); background: var(--bad-soft); }
|
|
160
|
+
.diff .hunk { color: var(--warn); }
|
|
161
|
+
.diff .meta { color: var(--muted); }
|
|
153
162
|
.change-path { overflow-wrap: anywhere; }
|
|
154
163
|
.change-count { color: var(--muted); white-space: nowrap; font-size: 12.5px; }
|
|
155
164
|
.change-kind { text-align: center; padding: 1px 6px; border-radius: 6px; font-size: 11.5px; font-weight: 650; background: var(--idle-soft); color: var(--idle); }
|
package/ui/app.js
CHANGED
|
@@ -375,6 +375,36 @@ function collapsible(key, title, ...body) {
|
|
|
375
375
|
function more(key, ...body) { return collapsible(key, 'Xem thêm', ...body); }
|
|
376
376
|
|
|
377
377
|
const CHANGE_LABEL = {modified: 'Sửa', deleted: 'Xóa', added: 'Thêm', 'type-changed': 'Đổi kiểu'};
|
|
378
|
+
/* ---------- Content diff of one changed file (worktree tasks only; read only, secrets filtered on the server) ---------- */
|
|
379
|
+
async function loadDiff(task, file) {
|
|
380
|
+
state.diff = {task_id: task.task_id, path: file, loading: true}; patch();
|
|
381
|
+
try { state.diff = {task_id: task.task_id, path: file, data: await api(`tasks/${task.task_id}/diff?path=${encodeURIComponent(file)}`)}; }
|
|
382
|
+
catch (error) { state.diff = {task_id: task.task_id, path: file, error: error.message}; }
|
|
383
|
+
patch();
|
|
384
|
+
}
|
|
385
|
+
/** A row is a button only where a diff can be read: a task that ran in a worktree, which the bridge owns. */
|
|
386
|
+
function rowProps(task, file) {
|
|
387
|
+
if (task.isolation !== 'worktree' || task.discarded_at) return {class: 'change-row'};
|
|
388
|
+
const open = () => loadDiff(task, file);
|
|
389
|
+
return {class: 'change-row clickable', tabindex: '0', role: 'button', 'aria-label': `Xem diff của ${file}`, onclick: open,
|
|
390
|
+
onkeydown: event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); open(); } }};
|
|
391
|
+
}
|
|
392
|
+
const diffLineClass = line => line.startsWith('+++') || line.startsWith('---') || line.startsWith('diff ') || line.startsWith('index ') ? 'meta'
|
|
393
|
+
: line.startsWith('@@') ? 'hunk' : line.startsWith('+') ? 'add' : line.startsWith('-') ? 'del' : '';
|
|
394
|
+
function diffViewer(task) {
|
|
395
|
+
const current = state.diff && state.diff.task_id === task.task_id ? state.diff : null;
|
|
396
|
+
if (!current) return null;
|
|
397
|
+
const close = h('button', {class: 'btn small', onclick: () => { state.diff = null; patch(); }, text: 'Đóng'});
|
|
398
|
+
const head = h('div', {class: 'change-head'}, h('strong', {class: 'mono', text: current.path}), close);
|
|
399
|
+
if (current.loading) return h('div', {class: 'diff-box'}, head, h('div', {class: 'typing'}, h('span', {class: 'spinner'}), 'Đang đọc diff…'));
|
|
400
|
+
if (current.error) return h('div', {class: 'diff-box'}, head, h('div', {class: 'callout bad', text: current.error}));
|
|
401
|
+
const data = current.data;
|
|
402
|
+
if (!data.changed) return h('div', {class: 'diff-box'}, head, h('p', {class: 'muted', text: 'File này không có thay đổi so với commit gốc của worktree.'}));
|
|
403
|
+
if (data.binary) return h('div', {class: 'diff-box'}, head, h('p', {class: 'muted', text: 'File nhị phân: chỉ biết là có thay đổi, không hiện được nội dung.'}));
|
|
404
|
+
return h('div', {class: 'diff-box'}, head,
|
|
405
|
+
h('pre', {class: 'log diff'}, data.diff.split('\n').map(line => h('span', {class: diffLineClass(line), text: line}))),
|
|
406
|
+
data.truncated ? h('div', {class: 'callout warn', text: 'Diff dài nên chỉ hiện phần đầu.'}) : null);
|
|
407
|
+
}
|
|
378
408
|
function changesBody(task) {
|
|
379
409
|
const c = state.changes && state.changes.task_id === task.task_id ? state.changes : null;
|
|
380
410
|
const refresh = h('button', {class: 'btn small', onclick: () => loadChanges(task.task_id), disabled: Boolean(c && c.loading), text: c && c.data ? 'Làm mới' : 'Xem thay đổi'});
|
|
@@ -384,16 +414,18 @@ function changesBody(task) {
|
|
|
384
414
|
const d = c.data;
|
|
385
415
|
if (!d.available) return [h('div', {class: 'callout warn', text: d.reason || 'Không đọc được thay đổi.'}), refresh];
|
|
386
416
|
const rows = [
|
|
387
|
-
...d.changes.map(item => h('li',
|
|
417
|
+
...d.changes.map(item => h('li', rowProps(task, item.path), h('span', {class: `change-kind ${item.status}`, text: CHANGE_LABEL[item.status] || item.status}),
|
|
388
418
|
h('span', {class: 'change-path mono', text: item.path}),
|
|
389
419
|
h('span', {class: 'change-count mono', text: item.added === null ? 'nhị phân' : `+${item.added} −${item.deleted}`}))),
|
|
390
|
-
...d.untracked.map(item => h('li',
|
|
420
|
+
...d.untracked.map(item => h('li', rowProps(task, item.path), h('span', {class: 'change-kind untracked', text: 'Mới'}),
|
|
391
421
|
h('span', {class: 'change-path mono', text: item.path}), h('span', {class: 'change-count mono', text: item.bytes === null ? '' : bytes(item.bytes)})))
|
|
392
422
|
];
|
|
393
423
|
return [
|
|
394
424
|
h('div', {class: 'change-head'}, h('strong', {text: rows.length ? `${d.changes.length} file đã sửa hoặc xóa · ${d.untracked.length} file mới chưa theo dõi` : 'Không có thay đổi nào so với HEAD.'}), refresh),
|
|
395
|
-
h('p', {class: 'muted', text: `So với commit ${d.head ? d.head.slice(0, 8) : '(repo chưa có commit)'}; gồm cả thay đổi đã có từ trước khi task chạy. Chỉ hiện tên file và số dòng, không có nội dung
|
|
425
|
+
h('p', {class: 'muted', text: `So với commit ${d.head ? d.head.slice(0, 8) : '(repo chưa có commit)'}; gồm cả thay đổi đã có từ trước khi task chạy. ${task.isolation === 'worktree' && !task.discarded_at ? 'Danh sách chỉ có tên file và số dòng; nội dung đọc ở diff từng file.' : 'Chỉ hiện tên file và số dòng, không có nội dung.'}`}),
|
|
426
|
+
rows.length && task.isolation === 'worktree' && !task.discarded_at ? h('p', {class: 'muted', text: 'Bấm một file để đọc nội dung thay đổi (đã lọc secret).'}) : null,
|
|
396
427
|
rows.length ? h('ul', {class: 'change-list'}, rows) : null,
|
|
428
|
+
diffViewer(task),
|
|
397
429
|
d.truncated ? h('div', {class: 'callout warn', text: 'Chỉ hiện 500 mục đầu tiên của mỗi nhóm.'}) : null
|
|
398
430
|
];
|
|
399
431
|
}
|
|
@@ -437,6 +469,7 @@ function reviewBody(task) {
|
|
|
437
469
|
h('button', {class: 'btn primary small', disabled: Boolean(block), title: block || 'Ghi thay đổi vào workspace', onclick: () => applyTask(task), text: 'Áp dụng vào workspace'}),
|
|
438
470
|
h('button', {class: 'btn danger small', disabled: Boolean(task.discarded_at), onclick: () => discardTask(task), text: 'Bỏ worktree'})),
|
|
439
471
|
block && !task.applied_at && !task.discarded_at ? h('p', {class: 'muted', text: block}) : null,
|
|
472
|
+
!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,
|
|
440
473
|
mine.length ? h('div', {}, h('div', {class: 'msg-head', text: 'Nhật ký quyết định'}),
|
|
441
474
|
h('ul', {class: 'change-list'}, mine.map(item => h('li', {class: 'change-row'},
|
|
442
475
|
h('span', {class: `change-kind ${item.ok ? 'added' : 'deleted'}`, text: item.action === 'apply' ? 'Áp dụng' : 'Bỏ'}),
|
|
@@ -874,7 +907,7 @@ function patch() {
|
|
|
874
907
|
setRegion('list', JSON.stringify([state.q, state.filter, state.selected, o.budget?.task_tokens, o.tasks.map(t => stable(t))]), () => taskItems(o));
|
|
875
908
|
regions.count.textContent = taskCount(o);
|
|
876
909
|
const d = state.detail;
|
|
877
|
-
const detailKey = JSON.stringify([state.selected, state.logTab, state.changes, state.decisions, d && {...d, activity: null, task: {...stable(d.task), stdout: '', stderr: '', stdout_bytes: 0, stderr_bytes: 0, stdout_truncated: false, stderr_truncated: false}}]);
|
|
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}}]);
|
|
878
911
|
const rebuilt = signatures.detail !== detailKey;
|
|
879
912
|
setRegion('detail', detailKey, () => detailView());
|
|
880
913
|
if (!rebuilt) patchLogs();
|