letagents 0.12.35 → 0.12.36
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/mcp/server/runtime/workspace-capture.js +16 -5
- package/dist/mcp/server/tools/supervised-room-turn.js +1 -1
- package/package.json +5 -3
- package/shared/activation-routing.d.mts +21 -3
- package/shared/activation-routing.mjs +37 -0
- package/shared/codex-owner-isolation.mjs +4 -2
- package/shared/codex-read-only-room-tools.d.mts +2 -0
- package/shared/codex-read-only-room-tools.mjs +24 -0
- package/shared/mcp-workspace-capture-worker.mjs +2 -1
- package/shared/message-link-previews.d.mts +1 -0
- package/shared/message-link-previews.mjs +6 -0
- package/shared/room-settings.d.mts +16 -0
- package/shared/room-settings.mjs +14 -0
- package/shared/room-typing.d.mts +4 -2
- package/shared/room-typing.mjs +22 -5
- package/shared/ui/GitHubEventCard.vue +229 -0
- package/shared/ui/PinnedMessages.vue +100 -95
- package/shared/ui/TypingIndicator.vue +123 -11
- package/shared/ui/room-agent-work.css +178 -0
- package/shared/ui/room-message-motion.ts +143 -0
- package/shared/ui/usePinnedMessageVisibility.ts +20 -0
- package/shared/ui/useRoomMessageMotion.ts +195 -0
- package/shared/ui/useSlidingTabIndicator.ts +72 -0
- package/shared/workspace-change-capture.mjs +3 -1
- package/shared/workspace-turn-capture.d.mts +4 -1
- package/shared/workspace-turn-capture.mjs +203 -6
|
@@ -1,21 +1,49 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
3
|
import { lstat, mkdtemp, open, readlink, realpath, rm } from 'node:fs/promises';
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
4
|
+
import { devNull, tmpdir } from 'node:os';
|
|
5
5
|
import { dirname, join, relative } from 'node:path';
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
7
|
import { captureWorkspaceChanges } from './workspace-change-capture.mjs';
|
|
8
8
|
const environment = () => Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')));
|
|
9
|
-
|
|
9
|
+
// Replace refs are off: with them the workspace could put any object in the place of
|
|
10
|
+
// the one a capture reads (a base's tree, the start snapshot itself).
|
|
11
|
+
function git(cwd, args, signal, index, input, extra) {
|
|
10
12
|
return new Promise((resolve, reject) => {
|
|
11
13
|
const child = execFile('git', ['-c', 'core.hooksPath=/dev/null', '--no-optional-locks', ...args], {
|
|
12
14
|
cwd, signal, timeout: 5_000, maxBuffer: 4 * 8 * 1024 * 1024, encoding: 'utf8',
|
|
13
|
-
env: { ...environment(), GIT_TERMINAL_PROMPT: '0', ...(index ? { GIT_INDEX_FILE: index } : {}) },
|
|
15
|
+
env: { ...environment(), GIT_TERMINAL_PROMPT: '0', GIT_NO_REPLACE_OBJECTS: '1', ...(index ? { GIT_INDEX_FILE: index } : {}), ...extra },
|
|
14
16
|
}, (error, stdout) => error ? reject(error) : resolve(stdout));
|
|
15
17
|
child.stdin?.on('error', () => { });
|
|
16
18
|
child.stdin?.end(input);
|
|
17
19
|
});
|
|
18
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* A Git command that starts a transport of its own (ssh, a credential or remote
|
|
23
|
+
* helper). Killing Git alone leaves a stalled transport running. So the command
|
|
24
|
+
* has its own process group, and when the time is up the whole group is killed
|
|
25
|
+
* and the answer does not wait for anything.
|
|
26
|
+
*/
|
|
27
|
+
function remoteGit(cwd, args, signal, extra) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const child = spawn('git', ['-c', 'core.hooksPath=/dev/null', '--no-optional-locks', ...args], {
|
|
30
|
+
cwd, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'ignore'],
|
|
31
|
+
env: { ...environment(), GIT_TERMINAL_PROMPT: '0', GIT_NO_REPLACE_OBJECTS: '1', ...extra },
|
|
32
|
+
});
|
|
33
|
+
let output = '';
|
|
34
|
+
const stop = () => {
|
|
35
|
+
try { process.kill(-child.pid, 'SIGKILL'); }
|
|
36
|
+
catch { child.kill('SIGKILL'); } // No process group on this platform, or it is gone.
|
|
37
|
+
child.stdout.destroy();
|
|
38
|
+
reject(new Error('The remote did not answer in time.'));
|
|
39
|
+
};
|
|
40
|
+
if (signal.aborted) stop();
|
|
41
|
+
else signal.addEventListener('abort', stop, { once: true });
|
|
42
|
+
child.stdout.setEncoding('utf8').on('data', (chunk) => { if ((output += chunk).length > 4 * 8 * 1024 * 1024) stop(); });
|
|
43
|
+
child.once('error', reject);
|
|
44
|
+
child.once('close', (code) => { signal.removeEventListener('abort', stop); code === 0 ? resolve(output) : reject(new Error('Git could not ask the remote.')); });
|
|
45
|
+
});
|
|
46
|
+
}
|
|
19
47
|
export const unavailableWorkspace = () => ({ captured_at: new Date().toISOString(), branch: null,
|
|
20
48
|
base_revision: null, state: 'unavailable', files: [], additions: 0, deletions: 0, hidden_files: 0, patch: '', patch_truncated: false });
|
|
21
49
|
const reviewRef = (identity) => `refs/letagents/workspace-review/${createHash('sha256').update(identity).digest('hex')}`;
|
|
@@ -101,11 +129,180 @@ export async function releaseWorkspaceTree(workspace, identity) {
|
|
|
101
129
|
}
|
|
102
130
|
catch { /* Retain on failure, never jeopardize provider work. */ }
|
|
103
131
|
}
|
|
104
|
-
|
|
132
|
+
/** The commit a piece of work starts on. With it and the remotes' tips, the settled capture can tell a base update from the work. */
|
|
133
|
+
export async function captureWorkspaceHead(workspace) {
|
|
134
|
+
return git(workspace, ['rev-parse', '--verify', 'HEAD^{commit}'], AbortSignal.timeout(5_000)).then(value => value.trim(), () => null);
|
|
135
|
+
}
|
|
136
|
+
const objectId = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
|
137
|
+
const noPrompt = (ssh) => ({ GIT_SSH_COMMAND: `${ssh} -o BatchMode=yes -o ConnectTimeout=5`, GIT_ASKPASS: '', SSH_ASKPASS: '', SSH_ASKPASS_REQUIRE: 'never', GCM_INTERACTIVE: 'never' });
|
|
138
|
+
const branchTips = (listed) => [...new Set(listed.flatMap(lines => lines.split('\n').map(line => line.split('\t')[0])))].filter(tip => objectId.test(tip));
|
|
139
|
+
/**
|
|
140
|
+
* The commit of every branch on every configured remote, asked from the remotes
|
|
141
|
+
* themselves before a piece of work starts. It is the only evidence of "upstream"
|
|
142
|
+
* the capture accepts: remote-tracking branches and reflogs are local files that
|
|
143
|
+
* the work can move or clear. Resolves to null when there is no remote or when a
|
|
144
|
+
* remote does not answer; all of them together get five seconds, and then Git is
|
|
145
|
+
* killed. It never rejects and never asks for a password, a passphrase or a host
|
|
146
|
+
* key. Start it without waiting, so that a slow remote cannot delay the work, and
|
|
147
|
+
* await it when the work has settled.
|
|
148
|
+
*
|
|
149
|
+
* The repository's own configuration names the remotes here, and whoever can
|
|
150
|
+
* write that configuration can name another repository. That is acceptable only
|
|
151
|
+
* for a worker that reports its own work from its own clone (the MCP capture
|
|
152
|
+
* tools). A supervisor that must not take the workspace's word asks the remote it
|
|
153
|
+
* recorded itself: captureRecordedRemoteTips.
|
|
154
|
+
*/
|
|
155
|
+
export async function captureRemoteTips(workspace) {
|
|
156
|
+
const signal = AbortSignal.timeout(5_000);
|
|
157
|
+
try {
|
|
158
|
+
const remotes = (await git(workspace, ['remote'], signal)).split('\n').filter(Boolean);
|
|
159
|
+
if (!remotes.length)
|
|
160
|
+
return null;
|
|
161
|
+
const ssh = process.env.GIT_SSH_COMMAND || (await git(workspace, ['config', '--get', 'core.sshCommand'], signal).catch(() => '')).trim() || 'ssh';
|
|
162
|
+
return branchTips(await Promise.all(remotes.map(remote => remoteGit(workspace, ['-c', 'credential.interactive=false', 'ls-remote', '--heads', '--', remote], signal, noPrompt(ssh)))));
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The same question for one remote that the caller recorded itself, with the same
|
|
170
|
+
* bound and the same silence. The remote is asked by URL, and Git runs with no
|
|
171
|
+
* repository (GIT_DIR names nothing). So nothing that the work can write in the
|
|
172
|
+
* workspace or in the repository it shares applies: not a remote's url, not a
|
|
173
|
+
* url.<base>.insteadOf rewrite, not a worktree or included configuration file.
|
|
174
|
+
* The owner's own Git configuration (global and system) still applies, because a
|
|
175
|
+
* private remote needs the owner's stored credentials, SSH command and proxy. If
|
|
176
|
+
* that configuration sends this URL somewhere else, there is no answer. Which
|
|
177
|
+
* recorded remotes are worth asking is the caller's decision. Git starts in
|
|
178
|
+
* `directory`, which is not the workspace; with no repository it does not matter
|
|
179
|
+
* where.
|
|
180
|
+
*/
|
|
181
|
+
export async function captureRecordedRemoteTips(url, directory = tmpdir()) {
|
|
182
|
+
const signal = AbortSignal.timeout(5_000);
|
|
183
|
+
try {
|
|
184
|
+
if (typeof url !== 'string' || !url)
|
|
185
|
+
return null;
|
|
186
|
+
const outside = { GIT_DIR: devNull };
|
|
187
|
+
const ssh = process.env.GIT_SSH_COMMAND || (await git(directory, ['config', '--get', 'core.sshCommand'], signal, undefined, undefined, outside).catch(() => '')).trim() || 'ssh';
|
|
188
|
+
const quiet = { ...outside, ...noPrompt(ssh) };
|
|
189
|
+
// An insteadOf rewrite, or a remote that is named like the URL, shows in the address Git would use.
|
|
190
|
+
if ((await git(directory, ['ls-remote', '--get-url', '--', url], signal, undefined, undefined, quiet)).trim() !== url)
|
|
191
|
+
return null;
|
|
192
|
+
return branchTips([await remoteGit(directory, ['-c', 'credential.interactive=false', 'ls-remote', '--heads', '--', url], signal, quiet)]);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function changedEntries(root, from, to, signal) {
|
|
199
|
+
const parts = (await git(root, ['diff-tree', '-r', '-z', '--no-renames', '--no-abbrev', from, to], signal)).split('\0');
|
|
200
|
+
const entries = new Map();
|
|
201
|
+
for (let i = 0; i + 1 < parts.length; i += 2) {
|
|
202
|
+
const [oldMode, newMode, oldId, newId] = parts[i].slice(1).split(' ');
|
|
203
|
+
entries.set(parts[i + 1], [`${oldMode} ${oldId}`, `${newMode} ${newId}`]);
|
|
204
|
+
}
|
|
205
|
+
return entries;
|
|
206
|
+
}
|
|
207
|
+
const parents = (path) => path.split('/').slice(0, -1).map((_, index, parts) => parts.slice(0, index + 1).join('/'));
|
|
208
|
+
/**
|
|
209
|
+
* The tree to compare a settled turn against so that the result is the agent's
|
|
210
|
+
* own changes. A fetch followed by a checkout, merge, rebase or pull brings
|
|
211
|
+
* upstream commits into the workspace; compared tree to tree, all of them look
|
|
212
|
+
* like work done in the turn.
|
|
213
|
+
*
|
|
214
|
+
* `remoteTips` is what the remotes held before the turn (captureRemoteTips or
|
|
215
|
+
* captureRecordedRemoteTips). The
|
|
216
|
+
* new base N is the one tip among them that HEAD gained during the turn: an
|
|
217
|
+
* ancestor of HEAD now, not an ancestor of `startingHead`. The old base B is the
|
|
218
|
+
* merge base of `startingHead` and N. Then, for each path that differs between B
|
|
219
|
+
* and N, with S the start tree and E the settled tree:
|
|
220
|
+
* - S has B's content and E has N's content: the path changed only because the
|
|
221
|
+
* base moved. It is not in the comparison.
|
|
222
|
+
* - S has B's content and E differs from N: the turn changed the path, and it is
|
|
223
|
+
* measured against N. This includes a path that was put back to its old
|
|
224
|
+
* content, where S equals E: the turn dropped an upstream change.
|
|
225
|
+
* - S differs from B: the path was changed here before the base moved. It is
|
|
226
|
+
* measured from S as before; a resolved merge conflict shows here.
|
|
227
|
+
* Every other path is measured from S as before.
|
|
228
|
+
*
|
|
229
|
+
* So a path leaves the comparison only when its settled content is, byte for
|
|
230
|
+
* byte, content that a remote already held before the turn started. Nothing made
|
|
231
|
+
* during the turn can be hidden, whatever happens to local refs afterwards.
|
|
232
|
+
* Without that certainty the result is `start`, the plain comparison: no answer
|
|
233
|
+
* from the remotes, an unknown starting commit, no tip or more than one
|
|
234
|
+
* independent tip gained, or any Git failure.
|
|
235
|
+
*/
|
|
236
|
+
async function ownStartingTree(workspace, start, startingHead, remoteTips) {
|
|
237
|
+
if (!objectId.test(start ?? '') || !objectId.test(startingHead ?? '') || !Array.isArray(remoteTips))
|
|
238
|
+
return start;
|
|
239
|
+
const signal = AbortSignal.timeout(20_000);
|
|
240
|
+
let scratch;
|
|
241
|
+
try {
|
|
242
|
+
const root = await realpath(workspace);
|
|
243
|
+
const gained = new Set((await git(root, ['rev-list', 'HEAD', `^${startingHead}`], signal)).split('\n'));
|
|
244
|
+
const candidates = [...new Set(remoteTips)].filter(tip => objectId.test(tip) && gained.has(tip));
|
|
245
|
+
if (!candidates.length || candidates.length > 256)
|
|
246
|
+
return start;
|
|
247
|
+
// A tip that another gained tip already contains is not a second base.
|
|
248
|
+
const newest = candidates.length > 1 ? (await git(root, ['merge-base', '--independent', ...candidates], signal)).split('\n').filter(Boolean) : candidates;
|
|
249
|
+
if (newest.length !== 1)
|
|
250
|
+
return start;
|
|
251
|
+
const [base] = newest;
|
|
252
|
+
const old = (await git(root, ['merge-base', startingHead, base], signal)).trim();
|
|
253
|
+
// The trees hold the files as they are on disk. With core.autocrlf those are not the
|
|
254
|
+
// committed bytes, and a sparse checkout leaves files out: an untouched file would
|
|
255
|
+
// look changed against the new base. Neither can make a changed file look untouched.
|
|
256
|
+
if (/ (?:true|yes|on|1)$/im.test(await git(root, ['config', '--get-regexp', '^core\\.(autocrlf|sparsecheckout)$'], signal).catch(() => '')))
|
|
257
|
+
return start;
|
|
258
|
+
const incoming = await changedEntries(root, `${old}^{tree}`, `${base}^{tree}`, signal);
|
|
259
|
+
const touched = await changedEntries(root, `${old}^{tree}`, `${start}^{tree}`, signal);
|
|
260
|
+
const absent = `000000 ${'0'.repeat(base.length)}`;
|
|
261
|
+
const kept = [...touched].filter(([, [, entry]]) => entry !== absent).map(([path]) => path);
|
|
262
|
+
const keptFiles = new Set(kept);
|
|
263
|
+
const keptDirectories = new Set(kept.flatMap(parents));
|
|
264
|
+
const entries = [];
|
|
265
|
+
for (const [path, [, entry]] of incoming) {
|
|
266
|
+
// A file cannot take the place of a directory that holds an earlier change, or the reverse. The earlier change stays.
|
|
267
|
+
if (touched.has(path) || (entry !== absent && (keptDirectories.has(path) || parents(path).some(parent => keptFiles.has(parent)))))
|
|
268
|
+
continue;
|
|
269
|
+
entries.push(`${entry}\t${path}\0`);
|
|
270
|
+
}
|
|
271
|
+
if (!entries.length)
|
|
272
|
+
return start;
|
|
273
|
+
scratch = await mkdtemp(join(tmpdir(), 'letagents-workspace-'));
|
|
274
|
+
const index = join(scratch, 'index');
|
|
275
|
+
await git(root, ['read-tree', `${start}^{tree}`], signal, index);
|
|
276
|
+
await git(root, ['update-index', '-z', '--index-info'], signal, index, entries.join(''));
|
|
277
|
+
return (await git(root, ['write-tree'], signal, index)).trim();
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return start;
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
if (scratch)
|
|
284
|
+
await rm(scratch, { recursive: true, force: true });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* `startingHead` and `remoteTips` describe the moment `baseline` was captured:
|
|
289
|
+
* the commit HEAD was on, and what the remotes held (the answer, or the pending
|
|
290
|
+
* question). Without both, the turn's changes are the plain difference between
|
|
291
|
+
* the two trees. The changes since the workspace started are always the plain
|
|
292
|
+
* difference.
|
|
293
|
+
*/
|
|
294
|
+
export async function captureWorkspacePair(workspace, startingRevision, baseline, identity, startingHead = null, remoteTips = null) {
|
|
105
295
|
const tree = await captureWorkspaceTree(workspace, `${identity}:settled`);
|
|
106
296
|
try {
|
|
107
297
|
const full = tree ? await captureWorkspaceChanges(workspace, startingRevision, tree, true) : unavailableWorkspace();
|
|
108
|
-
const
|
|
298
|
+
const turn = async () => {
|
|
299
|
+
// The settled files are already captured: a remote that is still silent delays only this
|
|
300
|
+
// comparison, and a question that failed in any way leaves the plain one.
|
|
301
|
+
const from = await ownStartingTree(workspace, baseline, startingHead, await Promise.resolve(remoteTips).catch(() => null));
|
|
302
|
+
const own = from === baseline ? null : await captureWorkspaceChanges(workspace, from, tree, true);
|
|
303
|
+
return own?.state === 'ready' ? { ...own, base_revision: baseline } : captureWorkspaceChanges(workspace, baseline, tree, true);
|
|
304
|
+
};
|
|
305
|
+
const changes = tree && baseline ? await turn() : unavailableWorkspace();
|
|
109
306
|
const preview = (snapshot) => ({ ...snapshot,
|
|
110
307
|
files: snapshot.files.slice(0, 200), hidden_files: snapshot.hidden_files + Math.max(0, snapshot.files.length - 200),
|
|
111
308
|
patch: snapshot.patch.slice(0, 48 * 1024), patch_truncated: snapshot.patch_truncated || snapshot.patch.length > 48 * 1024 });
|