agy-mcp-bridge 0.1.29 → 0.1.31
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/decisions.js +45 -0
- package/dist/src/isolation/apply.js +96 -0
- package/dist/src/isolation/diff.js +44 -0
- package/dist/src/isolation/worktree.js +2 -2
- package/dist/src/tasks/manager.js +103 -2
- package/dist/src/ui/routes.js +37 -0
- package/docs/MULTI-AGENT-PLAN.md +16 -0
- package/package.json +1 -1
- package/ui/app.css +10 -0
- package/ui/app.js +90 -4
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* The owner's decisions about a finished task (apply its changes, discard its worktree), one line each. The task history keeps only the
|
|
5
|
+
* latest tasks, so this is the record of who changed the workspace and when. Trimmed when the file grows large.
|
|
6
|
+
*/
|
|
7
|
+
export const DECISIONS_FILE = 'decisions.jsonl';
|
|
8
|
+
const MAX_FILE_BYTES = 512 * 1024;
|
|
9
|
+
const KEEP_RECORDS = 500;
|
|
10
|
+
export async function appendDecision(stateDirectory, decision) {
|
|
11
|
+
const file = path.join(stateDirectory, DECISIONS_FILE);
|
|
12
|
+
try {
|
|
13
|
+
await mkdir(stateDirectory, { recursive: true });
|
|
14
|
+
await appendFile(file, JSON.stringify(decision) + '\n', { mode: 0o600 });
|
|
15
|
+
if ((await stat(file)).size > MAX_FILE_BYTES) {
|
|
16
|
+
const lines = (await readFile(file, 'utf8')).split('\n').filter(Boolean);
|
|
17
|
+
await writeFile(file, lines.slice(-KEEP_RECORDS).join('\n') + '\n', { mode: 0o600 });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
catch { /* the log is a convenience, never a reason to fail the action it describes */ }
|
|
21
|
+
}
|
|
22
|
+
/** Newest first. A damaged line is skipped. */
|
|
23
|
+
export async function readDecisions(stateDirectory, limit = 50) {
|
|
24
|
+
let text;
|
|
25
|
+
try {
|
|
26
|
+
text = await readFile(path.join(stateDirectory, DECISIONS_FILE), 'utf8');
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
const records = [];
|
|
32
|
+
for (const line of text.split('\n')) {
|
|
33
|
+
if (!line.trim())
|
|
34
|
+
continue;
|
|
35
|
+
try {
|
|
36
|
+
const value = JSON.parse(line);
|
|
37
|
+
if (typeof value.at === 'string' && typeof value.task_id === 'string' && (value.action === 'apply' || value.action === 'discard') && typeof value.ok === 'boolean') {
|
|
38
|
+
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
|
+
}
|
|
41
|
+
catch { /* skip a damaged line */ }
|
|
42
|
+
}
|
|
43
|
+
return records.slice(-limit).reverse();
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=decisions.js.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { realpath } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { inside } from '../security/workspace.js';
|
|
5
|
+
import { isOwned, normalizeOwn } from './worktree.js';
|
|
6
|
+
/**
|
|
7
|
+
* Brings a finished worktree task's changes into the original workspace as plain, uncommitted edits.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately not a branch merge: nothing is committed on the owner's behalf, no branch is switched, and the owner reviews and
|
|
10
|
+
* commits (or `git restore`s) what arrives. The patch is built from the worktree, checked against the workspace first, and only
|
|
11
|
+
* then applied, so a workspace that moved on since the task started is refused instead of half-written.
|
|
12
|
+
*/
|
|
13
|
+
const MAX_PATCH_BYTES = 8 * 1024 * 1024;
|
|
14
|
+
const MAX_FILES = 500;
|
|
15
|
+
export const gitEnvironment = () => ({
|
|
16
|
+
PATH: process.env.PATH, PATHEXT: process.env.PATHEXT, SystemRoot: process.env.SystemRoot, COMSPEC: process.env.COMSPEC,
|
|
17
|
+
TEMP: process.env.TEMP, TMP: process.env.TMP, TMPDIR: process.env.TMPDIR,
|
|
18
|
+
GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null',
|
|
19
|
+
GIT_ATTR_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', GIT_PAGER: 'cat', LC_ALL: 'C'
|
|
20
|
+
});
|
|
21
|
+
class Refusal extends Error {
|
|
22
|
+
}
|
|
23
|
+
function git(cwd, args, input) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const child = execFile('git', ['-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=' + (process.platform === 'win32' ? 'NUL' : '/dev/null'), ...args], { cwd, env: gitEnvironment(), timeout: 30000, maxBuffer: MAX_PATCH_BYTES + 1024, encoding: 'utf8', windowsHide: true }, (error, stdout, stderr) => {
|
|
26
|
+
if (error)
|
|
27
|
+
reject(new Error((stderr || error.message).trim().split('\n')[0].slice(0, 300)));
|
|
28
|
+
else
|
|
29
|
+
resolve(stdout);
|
|
30
|
+
});
|
|
31
|
+
// A git that exits before reading its input (a bad patch) closes the pipe; that is reported through the exit status, not as a crash.
|
|
32
|
+
child.stdin?.on('error', () => undefined);
|
|
33
|
+
if (input !== undefined)
|
|
34
|
+
child.stdin?.end(input);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/** The directory's path inside the repository, forward slashes, no trailing slash ('' at the top). */
|
|
38
|
+
async function prefixOf(toplevel, workspace) {
|
|
39
|
+
const [top, work] = await Promise.all([realpath(toplevel), realpath(workspace)]);
|
|
40
|
+
if (!inside(top, work))
|
|
41
|
+
throw new Refusal('The workspace is not inside the repository the worktree was made from');
|
|
42
|
+
return path.relative(top, work).split(path.sep).join('/');
|
|
43
|
+
}
|
|
44
|
+
/** The repository root of a directory, as an absolute canonical path. */
|
|
45
|
+
export async function repositoryRoot(directory) {
|
|
46
|
+
try {
|
|
47
|
+
return await realpath((await git(directory, ['rev-parse', '--show-toplevel'])).trim());
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
throw new Error('The workspace is not a git repository: ' + (error instanceof Error ? error.message : 'git failed'));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const literal = (file) => `:(literal)${file}`;
|
|
54
|
+
export async function applyWorktreeChanges(input) {
|
|
55
|
+
try {
|
|
56
|
+
const prefix = await prefixOf(input.toplevel, input.workspace);
|
|
57
|
+
const scope = prefix ? [prefix] : ['.'];
|
|
58
|
+
const owns = input.owns.map(normalizeOwn);
|
|
59
|
+
if (!owns.length)
|
|
60
|
+
throw new Refusal('The task declared no owned paths, so there is nothing it was allowed to change');
|
|
61
|
+
// Staging is only a way to include new files in the diff: it touches the worktree's own index, never the workspace.
|
|
62
|
+
await git(input.worktree, ['add', '-A', '--', ...scope]);
|
|
63
|
+
const names = (await git(input.worktree, ['diff', '--cached', '--name-only', '-z', 'HEAD', '--', ...scope])).split('\0').filter(Boolean);
|
|
64
|
+
if (!names.length)
|
|
65
|
+
throw new Refusal('The worktree has no changes to apply');
|
|
66
|
+
if (names.length > MAX_FILES)
|
|
67
|
+
throw new Refusal(`The worktree changes ${names.length} files; apply is limited to ${MAX_FILES}`);
|
|
68
|
+
const relative = names.map(name => prefix && name.startsWith(`${prefix}/`) ? name.slice(prefix.length + 1) : name);
|
|
69
|
+
const outside = relative.filter(name => !isOwned(name, owns));
|
|
70
|
+
if (outside.length)
|
|
71
|
+
throw new Refusal(`Files outside the task's owned paths were changed (${outside.slice(0, 5).join(', ')}); review them in the worktree`);
|
|
72
|
+
// A symlink or a submodule pointer is more than text: it can point outside the workspace, so it is never applied from here.
|
|
73
|
+
const raw = (await git(input.worktree, ['diff', '--cached', '--raw', '-z', 'HEAD', '--', ...scope])).split('\0');
|
|
74
|
+
if (raw.some(field => /^:(?:12|16)0000 /.test(field) || /^:\d+ (?:12|16)0000 /.test(field)))
|
|
75
|
+
throw new Refusal('The change includes a symlink or a submodule, which is not applied from here; review it in the worktree');
|
|
76
|
+
const patch = await git(input.worktree, ['diff', '--cached', '--binary', '--full-index', '--no-ext-diff', '--no-textconv', 'HEAD', '--', ...scope]);
|
|
77
|
+
if (Buffer.byteLength(patch) > MAX_PATCH_BYTES)
|
|
78
|
+
throw new Refusal('The change is larger than the apply limit; review it in the worktree');
|
|
79
|
+
// A file the owner is editing right now is theirs to merge by hand, even if the patch would happen to fit.
|
|
80
|
+
const dirty = (await git(input.toplevel, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', ...names.map(literal)])).split('\0').filter(Boolean);
|
|
81
|
+
if (dirty.length)
|
|
82
|
+
throw new Refusal(`The workspace already has uncommitted changes in: ${dirty.slice(0, 5).map(line => line.slice(3)).join(', ')}`);
|
|
83
|
+
try {
|
|
84
|
+
await git(input.toplevel, ['apply', '--check', '--whitespace=nowarn', '-'], patch);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
throw new Refusal(`The changes no longer fit the workspace: ${error instanceof Error ? error.message : 'git apply --check failed'}`);
|
|
88
|
+
}
|
|
89
|
+
await git(input.toplevel, ['apply', '--whitespace=nowarn', '-'], patch);
|
|
90
|
+
return { applied: true, files: relative.sort(), patch_bytes: Buffer.byteLength(patch) };
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
return { applied: false, reason: error instanceof Error ? error.message : 'Apply failed' };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=apply.js.map
|
|
@@ -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
|
|
@@ -299,10 +299,10 @@ export async function pruneWorktrees(repo, worktreesRoot) {
|
|
|
299
299
|
return { pruned: 0, reason: error instanceof Error ? error.message : 'could not prune worktree records' };
|
|
300
300
|
}
|
|
301
301
|
}
|
|
302
|
-
function normalizeOwn(own) {
|
|
302
|
+
export function normalizeOwn(own) {
|
|
303
303
|
return path.posix.normalize(own.replace(/\\/g, '/')).replace(/^\.\//, '').replace(/\/+$/, '');
|
|
304
304
|
}
|
|
305
|
-
function isOwned(relative, owns) {
|
|
305
|
+
export function isOwned(relative, owns) {
|
|
306
306
|
return owns.some(own => relative === own || relative.startsWith(`${own}/`));
|
|
307
307
|
}
|
|
308
308
|
/** Compare the real diff with the declared ownership list. Any file outside it must be reviewed before merging. */
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { existsSync, statSync } from 'node:fs';
|
|
2
|
-
import { mkdir, readdir, readFile, rename, rmdir, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { mkdir, readdir, readFile, realpath, rename, rmdir, 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';
|
|
6
6
|
import { DEFAULT_MAX_WAIT_SECONDS, delegateSchema, promptFor } from '../security/input.js';
|
|
7
|
-
import { validateWorkspace } from '../security/workspace.js';
|
|
7
|
+
import { inside, validateWorkspace } from '../security/workspace.js';
|
|
8
|
+
import { appendDecision } from '../decisions.js';
|
|
9
|
+
import { applyWorktreeChanges, repositoryRoot } from '../isolation/apply.js';
|
|
10
|
+
import { worktreeFileDiff } from '../isolation/diff.js';
|
|
8
11
|
import { redact } from '../security/redact.js';
|
|
9
12
|
import { delegationState } from '../delegation.js';
|
|
10
13
|
import { startProcess, terminateOwned } from '../antigravity/process.js';
|
|
@@ -732,6 +735,104 @@ export class TaskManager {
|
|
|
732
735
|
await this.persist(entry.task);
|
|
733
736
|
entry.finish?.();
|
|
734
737
|
}
|
|
738
|
+
/**
|
|
739
|
+
* The owner's decisions about a finished worktree task (never an MCP client's): bring its changes into the workspace, or throw the
|
|
740
|
+
* worktree away. One at a time, so two clicks cannot interleave two git operations on the same repository.
|
|
741
|
+
*/
|
|
742
|
+
decisionQueue = Promise.resolve();
|
|
743
|
+
enqueueDecision(work) {
|
|
744
|
+
const run = this.decisionQueue.then(work);
|
|
745
|
+
this.decisionQueue = run.catch(() => undefined);
|
|
746
|
+
return run;
|
|
747
|
+
}
|
|
748
|
+
async reviewableTask(id, allowRunning = false) {
|
|
749
|
+
const entry = this.entries.get(id);
|
|
750
|
+
if (!entry)
|
|
751
|
+
throw new Error('Unknown or expired task_id');
|
|
752
|
+
const task = entry.task;
|
|
753
|
+
if (task.isolation !== 'worktree' || !task.worktree)
|
|
754
|
+
throw new Error('This task did not run in a worktree');
|
|
755
|
+
if (!allowRunning && (task.status === 'queued' || task.status === 'running'))
|
|
756
|
+
throw new Error('The task is still running');
|
|
757
|
+
if (task.discarded_at)
|
|
758
|
+
throw new Error('This task worktree was already discarded');
|
|
759
|
+
const [root, canonical] = await Promise.all([
|
|
760
|
+
realpath(path.join(this.runtime.stateDirectory, 'worktrees')).catch(() => null),
|
|
761
|
+
realpath(task.worktree).catch(() => null)
|
|
762
|
+
]);
|
|
763
|
+
if (!canonical)
|
|
764
|
+
throw new Error('The task worktree no longer exists');
|
|
765
|
+
if (!root || !inside(root, canonical))
|
|
766
|
+
throw new Error('The task worktree is not under the bridge state directory');
|
|
767
|
+
return task;
|
|
768
|
+
}
|
|
769
|
+
recordDecision(id, action, ok, detail) {
|
|
770
|
+
return appendDecision(this.runtime.stateDirectory, { at: new Date().toISOString(), task_id: id, action, ok, detail });
|
|
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
|
+
}
|
|
777
|
+
applyTask(id) {
|
|
778
|
+
return this.enqueueDecision(async () => {
|
|
779
|
+
const task = await this.reviewableTask(id);
|
|
780
|
+
const refuse = async (reason) => { await this.recordDecision(id, 'apply', false, reason); throw new Error(reason); };
|
|
781
|
+
if (task.status !== 'completed')
|
|
782
|
+
return refuse(`Only a completed task can be applied (this one is ${task.status})`);
|
|
783
|
+
if (task.applied_at)
|
|
784
|
+
throw new Error('This task was already applied');
|
|
785
|
+
if (task.scope?.out_of_scope.length)
|
|
786
|
+
return refuse(`The task changed files outside its owned paths (${task.scope.out_of_scope.slice(0, 5).join(', ')}); review them in the worktree`);
|
|
787
|
+
let workspace;
|
|
788
|
+
try {
|
|
789
|
+
workspace = await validateWorkspace(task.workspace, this.config.allowed_workspaces);
|
|
790
|
+
}
|
|
791
|
+
catch (error) {
|
|
792
|
+
return refuse(`The workspace is no longer allowed: ${error instanceof Error ? error.message : 'invalid'}`);
|
|
793
|
+
}
|
|
794
|
+
let toplevel;
|
|
795
|
+
try {
|
|
796
|
+
toplevel = await repositoryRoot(workspace);
|
|
797
|
+
}
|
|
798
|
+
catch (error) {
|
|
799
|
+
return refuse(error instanceof Error ? error.message : 'The workspace is not a git repository');
|
|
800
|
+
}
|
|
801
|
+
const result = await applyWorktreeChanges({ worktree: task.worktree, toplevel, workspace, owns: task.owns });
|
|
802
|
+
if (!result.applied)
|
|
803
|
+
return refuse(result.reason);
|
|
804
|
+
task.applied_at = new Date().toISOString();
|
|
805
|
+
task.applied_files = result.files.slice(0, 100);
|
|
806
|
+
await this.persist(task);
|
|
807
|
+
await this.recordDecision(id, 'apply', true, `${result.files.length} file(s): ${result.files.slice(0, 5).join(', ')}`);
|
|
808
|
+
return { task: this.summary(id), files: result.files };
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
discardTask(id) {
|
|
812
|
+
return this.enqueueDecision(async () => {
|
|
813
|
+
const task = await this.reviewableTask(id);
|
|
814
|
+
let toplevel;
|
|
815
|
+
try {
|
|
816
|
+
toplevel = await repositoryRoot(await validateWorkspace(task.workspace, this.config.allowed_workspaces));
|
|
817
|
+
}
|
|
818
|
+
catch (error) {
|
|
819
|
+
const reason = error instanceof Error ? error.message : 'The workspace is not usable';
|
|
820
|
+
await this.recordDecision(id, 'discard', false, reason);
|
|
821
|
+
throw new Error(reason);
|
|
822
|
+
}
|
|
823
|
+
const worktree = { root: task.worktree, workspace: task.worktree, toplevel, branch: task.branch ?? '', prefix: '', base: '' };
|
|
824
|
+
const removal = await removeWorktree(worktree);
|
|
825
|
+
if (!removal.removed) {
|
|
826
|
+
const reason = removal.reason ?? 'The worktree could not be removed';
|
|
827
|
+
await this.recordDecision(id, 'discard', false, reason);
|
|
828
|
+
throw new Error(reason);
|
|
829
|
+
}
|
|
830
|
+
task.discarded_at = new Date().toISOString();
|
|
831
|
+
await this.persist(task);
|
|
832
|
+
await this.recordDecision(id, 'discard', true, task.applied_at ? 'worktree removed after its changes were applied' : 'worktree removed without applying its changes');
|
|
833
|
+
return this.summary(id);
|
|
834
|
+
});
|
|
835
|
+
}
|
|
735
836
|
get(id) {
|
|
736
837
|
const entry = this.entries.get(id);
|
|
737
838
|
if (!entry)
|
package/dist/src/ui/routes.js
CHANGED
|
@@ -5,6 +5,8 @@ import { redact } from '../security/redact.js';
|
|
|
5
5
|
import { paths } from '../paths.js';
|
|
6
6
|
import { knownUpdate, readPackageInfo } from '../update.js';
|
|
7
7
|
import { workspaceChanges } from '../changes.js';
|
|
8
|
+
import { readDecisions } from '../decisions.js';
|
|
9
|
+
import { isRelativeFilePath } from '../isolation/diff.js';
|
|
8
10
|
import { validModelId, writeModel } from '../models.js';
|
|
9
11
|
import { readProgress, writeProgress } from '../progress.js';
|
|
10
12
|
import { readUsage, summarizeUsage } from '../usage.js';
|
|
@@ -171,6 +173,41 @@ export async function handleUi(req, res, ctx) {
|
|
|
171
173
|
json(res, 200, await changesFor(ctx, changes[1]));
|
|
172
174
|
return true;
|
|
173
175
|
}
|
|
176
|
+
// Applying a finished task's changes edits the owner's workspace, so like stopping a plan it is a decision for a person at the page.
|
|
177
|
+
const apply = pathname.match(new RegExp(`^/ui/api/tasks/(${UUID})/apply$`));
|
|
178
|
+
if (apply && method === 'POST') {
|
|
179
|
+
const body = await readJson(req);
|
|
180
|
+
if (body.confirm !== true) {
|
|
181
|
+
json(res, 400, { error: 'confirm must be true' });
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
json(res, 200, await ctx.manager.applyTask(apply[1]));
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
const discard = pathname.match(new RegExp(`^/ui/api/tasks/(${UUID})/discard$`));
|
|
188
|
+
if (discard && method === 'POST') {
|
|
189
|
+
const body = await readJson(req);
|
|
190
|
+
if (body.confirm !== true) {
|
|
191
|
+
json(res, 400, { error: 'confirm must be true' });
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
json(res, 200, { task: await ctx.manager.discardTask(discard[1]) });
|
|
195
|
+
return true;
|
|
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
|
+
}
|
|
207
|
+
if (pathname === '/ui/api/decisions' && method === 'GET') {
|
|
208
|
+
json(res, 200, { decisions: await readDecisions(ctx.manager.stateDirectory) });
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
174
211
|
const cancel = pathname.match(new RegExp(`^/ui/api/tasks/(${UUID})/cancel$`));
|
|
175
212
|
if (cancel && method === 'POST') {
|
|
176
213
|
await ctx.manager.cancel(cancel[1]);
|
package/docs/MULTI-AGENT-PLAN.md
CHANGED
|
@@ -239,6 +239,22 @@ ngân sách token theo mission — usage vẫn chỉ được ghi và tổng h
|
|
|
239
239
|
- Hàng đợi duyệt cho hành động ngoài chính sách.
|
|
240
240
|
- Nhật ký quyết định, diff theo task, nút hợp nhất nhánh.
|
|
241
241
|
|
|
242
|
+
**Đã làm (lát cắt 1: duyệt kết quả worktree).** Trang Task có panel "Duyệt kết quả worktree" cho task chạy trong worktree:
|
|
243
|
+
|
|
244
|
+
- `Áp dụng vào workspace` ghi thay đổi của worktree vào workspace gốc dưới dạng **chưa commit** (`git apply`; bridge không tạo commit,
|
|
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
|
+
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
|
+
- `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.
|
|
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`.
|
|
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
|
+
agent hay client không tự áp dụng kết quả của chính nó.
|
|
254
|
+
|
|
255
|
+
Chưa làm của Phase 4: trang Settings cho agent ưu tiên / số task song song / autonomy mặc định (hiện là file cấu hình), hàng đợi duyệt hành động
|
|
256
|
+
ngoài chính sách và nút hợp nhất theo nhánh (commit).
|
|
257
|
+
|
|
242
258
|
### Phase 5 — Tích hợp và hardening
|
|
243
259
|
|
|
244
260
|
- Merge nhánh trung gian, kiểm tra xung đột, chạy test trên nhánh tích hợp.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agy-mcp-bridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.31",
|
|
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
|
@@ -126,6 +126,7 @@ button { cursor: pointer; }
|
|
|
126
126
|
.callout { padding: 10px 12px; border-radius: 10px; font-weight: 550; overflow-wrap: anywhere; }
|
|
127
127
|
.callout.bad { background: var(--bad-soft); color: var(--bad); }
|
|
128
128
|
.callout.warn { background: var(--warn-soft); color: var(--warn); }
|
|
129
|
+
.callout.good { background: var(--ok-soft); color: var(--ok); }
|
|
129
130
|
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
130
131
|
.chip { padding: 2px 9px; border-radius: 8px; background: var(--surface); border: 1px solid var(--border); font-size: 12.5px; font-family: var(--mono); }
|
|
131
132
|
.typing { display: flex; align-items: center; gap: 10px; color: var(--muted); }
|
|
@@ -149,6 +150,15 @@ details.more .body { padding: 10px 12px 12px; }
|
|
|
149
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; }
|
|
150
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; }
|
|
151
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); }
|
|
152
162
|
.change-path { overflow-wrap: anywhere; }
|
|
153
163
|
.change-count { color: var(--muted); white-space: nowrap; font-size: 12.5px; }
|
|
154
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,19 +414,74 @@ 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
|
}
|
|
432
|
+
/* ---------- Review a finished worktree task: apply its changes, or throw the worktree away. Both are the owner's decision, never an agent's. ---------- */
|
|
433
|
+
async function loadDecisions() {
|
|
434
|
+
try { state.decisions = (await api('decisions')).decisions; } catch { /* keep the list already on screen */ }
|
|
435
|
+
patch();
|
|
436
|
+
}
|
|
437
|
+
async function applyTask(task) {
|
|
438
|
+
if (!confirm(`Áp dụng thay đổi của task này vào workspace?\n\n${task.workspace}\n\nCác file được ghi thẳng vào thư mục làm việc ở dạng CHƯA COMMIT; bridge không tạo commit nào. Muốn bỏ thì dùng git restore.\nBị từ chối nếu workspace đang có sửa đổi chưa commit trên cùng file, hoặc thay đổi không còn khớp.`)) return;
|
|
439
|
+
try {
|
|
440
|
+
const result = await api(`tasks/${task.task_id}/apply`, {method: 'POST', body: {confirm: true}});
|
|
441
|
+
notify(`Đã áp dụng ${result.files.length} file vào workspace (chưa commit)`);
|
|
442
|
+
await refresh(true);
|
|
443
|
+
} catch (error) { notify(error.message); }
|
|
444
|
+
await loadDecisions();
|
|
445
|
+
}
|
|
446
|
+
async function discardTask(task) {
|
|
447
|
+
const note = task.applied_at ? 'Thay đổi đã được áp dụng vào workspace trước đó, nên không mất gì.' : 'Thay đổi của task CHƯA được áp dụng và sẽ mất cùng worktree.';
|
|
448
|
+
if (!confirm(`Bỏ worktree và nhánh ${task.branch || ''} của task này?\n\n${note}`)) return;
|
|
449
|
+
try { await api(`tasks/${task.task_id}/discard`, {method: 'POST', body: {confirm: true}}); notify('Đã bỏ worktree'); await refresh(true); }
|
|
450
|
+
catch (error) { notify(error.message); }
|
|
451
|
+
await loadDecisions();
|
|
452
|
+
}
|
|
453
|
+
/** Why the apply button is off, or null when it is available. Mirrors the checks the bridge repeats on the server. */
|
|
454
|
+
function applyBlock(task) {
|
|
455
|
+
if (task.discarded_at) return 'Worktree đã bị bỏ.';
|
|
456
|
+
if (task.applied_at) return 'Đã áp dụng.';
|
|
457
|
+
if (task.status !== 'completed') return `Chỉ áp dụng được task đã hoàn tất (task này: ${task.status}).`;
|
|
458
|
+
if (task.scope && task.scope.out_of_scope.length) return `Task sửa file ngoài phạm vi được giao (${task.scope.out_of_scope.slice(0, 3).join(', ')}); xem trong worktree.`;
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
function reviewBody(task) {
|
|
462
|
+
const block = applyBlock(task);
|
|
463
|
+
const mine = (state.decisions || []).filter(item => item.task_id === task.task_id);
|
|
464
|
+
return [
|
|
465
|
+
h('p', {class: 'muted', text: 'Kết quả nằm trong worktree riêng, chưa đụng tới workspace của bạn. Áp dụng ghi thay đổi vào workspace dạng chưa commit; bỏ worktree xóa thư mục và nhánh của task.'}),
|
|
466
|
+
task.applied_at ? h('div', {class: 'callout good', text: `Đã áp dụng lúc ${clock(task.applied_at)}: ${(task.applied_files || []).join(', ') || '(không rõ file)'}. Xem git status trong workspace rồi commit hoặc git restore.`}) : null,
|
|
467
|
+
task.discarded_at ? h('div', {class: 'callout', text: `Đã bỏ worktree lúc ${clock(task.discarded_at)}.`}) : null,
|
|
468
|
+
h('div', {class: 'chips'},
|
|
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'}),
|
|
470
|
+
h('button', {class: 'btn danger small', disabled: Boolean(task.discarded_at), onclick: () => discardTask(task), text: 'Bỏ worktree'})),
|
|
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,
|
|
473
|
+
mine.length ? h('div', {}, h('div', {class: 'msg-head', text: 'Nhật ký quyết định'}),
|
|
474
|
+
h('ul', {class: 'change-list'}, mine.map(item => h('li', {class: 'change-row'},
|
|
475
|
+
h('span', {class: `change-kind ${item.ok ? 'added' : 'deleted'}`, text: item.action === 'apply' ? 'Áp dụng' : 'Bỏ'}),
|
|
476
|
+
h('span', {class: 'change-path', text: `${item.ok ? '' : 'Bị từ chối: '}${item.detail}`}),
|
|
477
|
+
h('span', {class: 'change-count mono', text: clock(item.at)}))))) : null
|
|
478
|
+
];
|
|
479
|
+
}
|
|
480
|
+
function reviewPanel(task) {
|
|
481
|
+
const el = details('review', 'Duyệt kết quả worktree', ...reviewBody(task));
|
|
482
|
+
el.addEventListener('toggle', () => { if (el.open && !state.decisions) void loadDecisions(); });
|
|
483
|
+
return el;
|
|
484
|
+
}
|
|
400
485
|
function changesPanel(task) {
|
|
401
486
|
const el = details('changes', 'Thay đổi trong workspace', ...changesBody(task));
|
|
402
487
|
el.addEventListener('toggle', () => { if (el.open && (!state.changes || state.changes.task_id !== task.task_id)) loadChanges(task.task_id); });
|
|
@@ -501,6 +586,7 @@ function detailView() {
|
|
|
501
586
|
|
|
502
587
|
return h('article', {class: 'card detail'}, head, progress, h('div', {class: 'convo'}, asked, replied),
|
|
503
588
|
changesPanel(task),
|
|
589
|
+
task.isolation === 'worktree' && task.status !== 'running' && task.status !== 'queued' ? reviewPanel(task) : null,
|
|
504
590
|
details('steps', `Các bước ${agentName} báo cáo`, h('div', {id: 'steps-body'}, stepsBody(state.detail?.activity))),
|
|
505
591
|
details('logs', `Log của tiến trình ${agentName}`, logs),
|
|
506
592
|
collapsible('more:fact', 'Xem thêm thông số task', facts),
|
|
@@ -821,7 +907,7 @@ function patch() {
|
|
|
821
907
|
setRegion('list', JSON.stringify([state.q, state.filter, state.selected, o.budget?.task_tokens, o.tasks.map(t => stable(t))]), () => taskItems(o));
|
|
822
908
|
regions.count.textContent = taskCount(o);
|
|
823
909
|
const d = state.detail;
|
|
824
|
-
const detailKey = JSON.stringify([state.selected, state.logTab, state.changes, 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}}]);
|
|
825
911
|
const rebuilt = signatures.detail !== detailKey;
|
|
826
912
|
setRegion('detail', detailKey, () => detailView());
|
|
827
913
|
if (!rebuilt) patchLogs();
|