letagents 0.12.34 → 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.
Files changed (68) hide show
  1. package/README.md +0 -2
  2. package/dist/mcp/server/runtime/messages.js +18 -1
  3. package/dist/mcp/server/runtime/workspace-capture.js +16 -5
  4. package/dist/mcp/server/tools/supervised-room-turn.js +1 -1
  5. package/package.json +5 -3
  6. package/shared/activation-routing.d.mts +21 -3
  7. package/shared/activation-routing.mjs +37 -0
  8. package/shared/code-block.css +193 -0
  9. package/shared/code-highlighting.d.mts +109 -0
  10. package/shared/code-highlighting.mjs +345 -0
  11. package/shared/codex-owner-isolation.mjs +5 -3
  12. package/shared/codex-read-only-room-tools.d.mts +2 -0
  13. package/shared/codex-read-only-room-tools.mjs +24 -0
  14. package/shared/mcp-workspace-capture-worker.mjs +2 -1
  15. package/shared/message-link-preview-store.d.mts +10 -0
  16. package/shared/message-link-preview-store.mjs +77 -0
  17. package/shared/message-link-previews.d.mts +17 -0
  18. package/shared/message-link-previews.mjs +76 -0
  19. package/shared/message-pin-store.d.mts +20 -0
  20. package/shared/message-pin-store.mjs +102 -0
  21. package/shared/message-pins.d.mts +25 -0
  22. package/shared/message-pins.mjs +16 -0
  23. package/shared/message-reaction-store.d.mts +51 -0
  24. package/shared/message-reaction-store.mjs +290 -0
  25. package/shared/message-reactions.d.mts +73 -0
  26. package/shared/message-reactions.mjs +130 -0
  27. package/shared/message-search-controller.d.mts +38 -0
  28. package/shared/message-search-controller.mjs +99 -0
  29. package/shared/message-search.d.mts +46 -0
  30. package/shared/message-search.mjs +114 -0
  31. package/shared/room-message-urls.d.mts +25 -0
  32. package/shared/room-message-urls.mjs +119 -0
  33. package/shared/room-notification-client.d.mts +18 -0
  34. package/shared/room-notification-client.mjs +140 -0
  35. package/shared/room-notification-preferences.d.mts +17 -0
  36. package/shared/room-notification-preferences.mjs +34 -0
  37. package/shared/room-resource-invalidation.d.mts +5 -0
  38. package/shared/room-resource-invalidation.mjs +6 -0
  39. package/shared/room-settings.d.mts +16 -0
  40. package/shared/room-settings.mjs +14 -0
  41. package/shared/room-typing.d.mts +18 -0
  42. package/shared/room-typing.mjs +129 -0
  43. package/shared/room-unread-client.ts +128 -0
  44. package/shared/room-unread.d.mts +25 -0
  45. package/shared/room-unread.mjs +57 -0
  46. package/shared/room-unread.test.mjs +91 -0
  47. package/shared/rooms/RoomsDirectory.vue +1 -1
  48. package/shared/rooms/directory.ts +1 -0
  49. package/shared/slash-commands.d.mts +31 -0
  50. package/shared/slash-commands.mjs +48 -0
  51. package/shared/slash-commands.test.mjs +26 -0
  52. package/shared/ui/GitHubEventCard.vue +229 -0
  53. package/shared/ui/MessageReactionBar.vue +65 -0
  54. package/shared/ui/MessageReactionPicker.vue +291 -0
  55. package/shared/ui/MessageSearchResults.vue +106 -0
  56. package/shared/ui/PinnedMessages.vue +129 -0
  57. package/shared/ui/TypingIndicator.vue +134 -0
  58. package/shared/ui/message-reactions.css +254 -0
  59. package/shared/ui/message-search.css +186 -0
  60. package/shared/ui/room-agent-work.css +178 -0
  61. package/shared/ui/room-message-motion.ts +143 -0
  62. package/shared/ui/useComposerSlashCommands.ts +92 -0
  63. package/shared/ui/usePinnedMessageVisibility.ts +20 -0
  64. package/shared/ui/useRoomMessageMotion.ts +195 -0
  65. package/shared/ui/useSlidingTabIndicator.ts +72 -0
  66. package/shared/workspace-change-capture.mjs +3 -1
  67. package/shared/workspace-turn-capture.d.mts +4 -1
  68. package/shared/workspace-turn-capture.mjs +203 -6
@@ -0,0 +1,195 @@
1
+ import { inject, nextTick, onBeforeUnmount, onDeactivated, onMounted, onScopeDispose, provide, ref, shallowRef, watch, type InjectionKey, type Ref } from 'vue';
2
+ import { appendedMotionMessages, captureWork, createRoomMessageAnimator, sameMotionAgent, type MotionMessage, type MessageMotionIdentity, type WorkGeometry } from './room-message-motion';
3
+
4
+ interface SendOrigin {
5
+ text: string; bounds: DOMRect; keyboard: boolean; messageId?: string;
6
+ }
7
+ function createSendContext() {
8
+ const revision = ref(0);
9
+ let pending: SendOrigin | null = null;
10
+ return {
11
+ revision,
12
+ capture(text: string, input: HTMLTextAreaElement | null) {
13
+ if (!input) return () => {};
14
+ const origin = { text, bounds: input.getBoundingClientRect(), keyboard: document.activeElement === input };
15
+ pending = origin;
16
+ return () => { if (pending === origin) pending = null; };
17
+ },
18
+ confirmation(text: string) {
19
+ const origin = pending?.text === text ? pending : null;
20
+ return (messageId: string) => {
21
+ if (origin && pending === origin) { origin.messageId = messageId; revision.value++; }
22
+ };
23
+ },
24
+ consume(message: MotionMessage) {
25
+ if (!pending || !(pending.messageId ? pending.messageId === message.id : message.outgoing && pending.text === message.text)) return null;
26
+ const result = pending; pending = null; return result;
27
+ },
28
+ peekId() { return pending?.messageId; },
29
+ clear() { pending = null; },
30
+ };
31
+ }
32
+ const sendKey: InjectionKey<ReturnType<typeof createSendContext>> = Symbol('room-message-send-motion');
33
+ export function provideRoomMessageMotion(scope: () => string | null | undefined) {
34
+ const context = createSendContext();
35
+ provide(sendKey, context);
36
+ watch(scope, context.clear, { flush: 'sync' });
37
+ onBeforeUnmount(context.clear);
38
+ onDeactivated(context.clear);
39
+ return context;
40
+ }
41
+ export function injectRoomMessageMotion() { return inject(sendKey, null); }
42
+
43
+ /** Keep the visible origin across independently delivered work/message updates.
44
+ * One second covers a brief stream handoff, without leaving no-reply turns working.
45
+ * This is presentation only: it never changes agent state or room messages.
46
+ */
47
+ export function useRoomWorkHandoff<T>(options: {
48
+ work: () => readonly T[];
49
+ identity: (work: T) => MessageMotionIdentity;
50
+ after: (work: T) => string | null | undefined;
51
+ messages: () => readonly MotionMessage[];
52
+ scope: () => string | null | undefined;
53
+ enabled: () => boolean;
54
+ }) {
55
+ const work = shallowRef<readonly T[]>([]);
56
+ let pending: { value: T; expires: number }[] = [];
57
+ let previous: readonly T[] = [];
58
+ let timer: ReturnType<typeof setTimeout> | undefined;
59
+ const clear = () => {
60
+ clearTimeout(timer); timer = undefined; pending = [];
61
+ work.value = previous = options.work();
62
+ };
63
+ const sameTurn = (a: T, b: T) => sameMotionAgent(options.identity(a), options.identity(b)) && options.after(a) === options.after(b);
64
+ function update() {
65
+ clearTimeout(timer); timer = undefined;
66
+ const current = options.work(), messages = options.messages(), now = Date.now();
67
+ if (!options.enabled() || (typeof document !== 'undefined' && document.visibilityState === 'hidden')
68
+ || (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches)) { clear(); return; }
69
+ const eligible = (value: T) => {
70
+ const after = options.after(value), start = messages.findIndex(message => message.id === after);
71
+ if (!after || start < 0) return false;
72
+ // A matching reply consumes this turn. A new source from the same
73
+ // agent must not inherit a previous turn's presentation.
74
+ return !messages.slice(start + 1).some(message => sameMotionAgent(options.identity(value), message))
75
+ && !current.some(item => sameMotionAgent(options.identity(item), options.identity(value)) && !sameTurn(item, value));
76
+ };
77
+ pending = pending.filter(item => item.expires > now && eligible(item.value) && !current.some(value => sameTurn(value, item.value)));
78
+ for (const value of previous) {
79
+ if (!current.some(item => sameTurn(item, value)) && !pending.some(item => sameTurn(item.value, value)) && eligible(value)) {
80
+ pending.push({ value, expires: now + 1000 });
81
+ }
82
+ }
83
+ previous = current;
84
+ work.value = [...current, ...pending.map(item => item.value)];
85
+ if (pending.length) timer = setTimeout(update, Math.max(0, Math.min(...pending.map(item => item.expires)) - now));
86
+ }
87
+ watch([options.work, options.messages], update, { immediate: true, deep: true });
88
+ watch(options.scope, () => { clear(); work.value = previous = []; }, { flush: 'sync' });
89
+ watch(options.enabled, enabled => { if (!enabled) clear(); }, { flush: 'sync' });
90
+ onScopeDispose(() => { clearTimeout(timer); });
91
+ return { work, clear };
92
+ }
93
+
94
+ /** Lists own scrolling/history; an own send requests latest before measuring its landing. */
95
+ export function useRoomMessageMotion(options: {
96
+ element: Ref<HTMLElement | null>;
97
+ /** Work rows pinned outside the scrolling list. A reply still grows from its row there. */
98
+ work?: Ref<HTMLElement | null>;
99
+ messages: () => MotionMessage[];
100
+ scope: () => string | null | undefined;
101
+ ready: () => boolean;
102
+ following: () => boolean;
103
+ scrollToLatest: () => void;
104
+ onInterrupt?: () => void;
105
+ }) {
106
+ const send = injectRoomMessageMotion();
107
+ const animator = createRoomMessageAnimator(() => options.element.value, () => options.work?.value ?? null);
108
+ let previous = options.messages().map(message => message.stableId);
109
+ let revision = 0;
110
+ let media: MediaQueryList | null = null;
111
+ const cancel = () => { revision++; animator.cancel(); };
112
+ const interrupt = () => { cancel(); options.onInterrupt?.(); };
113
+ watch(options.scope, () => { interrupt(); previous = options.messages().map(message => message.stableId); }, { flush: 'sync' });
114
+ watch(options.ready, ready => { if (!ready) interrupt(); });
115
+ watch([options.messages, () => send?.revision.value], async ([messages]) => {
116
+ const seen = new Set(previous);
117
+ const appended = appendedMotionMessages(previous, messages);
118
+ previous = messages.map(message => message.stableId);
119
+ // A first message in an empty room is allowed only when this composer sent it.
120
+ // A late acknowledgement consumes the origin without replaying an earlier echo.
121
+ const outgoing = messages.find(message => message.id === send?.peekId() || message.outgoing);
122
+ const candidates = [...appended];
123
+ if (outgoing && !candidates.includes(outgoing)) candidates.push(outgoing);
124
+ const sends = new Map<string, SendOrigin>();
125
+ for (const message of candidates) {
126
+ const source = send?.consume(message);
127
+ if (source) sends.set(message.id, source);
128
+ }
129
+ if (!appended.length && !sends.size) return;
130
+ const element = options.element.value;
131
+ const following = options.following();
132
+ // Capture the current presentation before cancelling a previous flight, so
133
+ // rapid arrivals continue from where the visible rows actually are.
134
+ const viewport = element?.getBoundingClientRect();
135
+ const positions = new Map<HTMLElement, DOMRect>();
136
+ if (element && viewport && following && options.ready()) {
137
+ for (const row of element.querySelectorAll<HTMLElement>('[data-message-id], [data-msg-id]')) {
138
+ const bounds = row.getBoundingClientRect();
139
+ if (bounds.bottom > viewport.top && bounds.top < viewport.bottom) positions.set(row, bounds);
140
+ }
141
+ }
142
+ cancel();
143
+ const currentRevision = revision;
144
+ if (!element || !options.ready() || (!following && !sends.size)) return;
145
+ const sources = new Map<string, WorkGeometry>();
146
+ const used = new Set<HTMLElement>();
147
+ const workRows = [...(options.work?.value ?? element).querySelectorAll<HTMLElement>('[data-motion-work]')];
148
+ for (const message of appended) {
149
+ const matches = workRows.filter(work => !used.has(work) && sameMotionAgent(message, { session: work.dataset.motionSession, key: work.dataset.motionAgent })
150
+ && (!work.dataset.motionAfter || (messages.findIndex(item => item.id === work.dataset.motionAfter) >= 0 && messages.findIndex(item => item.id === work.dataset.motionAfter) < messages.indexOf(message))));
151
+ if (matches.length !== 1) continue;
152
+ const source = captureWork(matches[0]);
153
+ if (source) { sources.set(message.id, source); used.add(matches[0]); }
154
+ }
155
+ await nextTick();
156
+ // Scroll followers may themselves be queued by this render (e.g. an API acknowledgement).
157
+ await nextTick();
158
+ if (currentRevision !== revision || !options.ready() || element !== options.element.value) return;
159
+ // This is navigation requested by the sender, including reduced motion and
160
+ // a late web acknowledgement after its stream echo. Never replay that echo.
161
+ if (sends.size) options.scrollToLatest();
162
+ if (media?.matches || document.visibilityState === 'hidden') return;
163
+ animator.move(positions);
164
+ const rows = [...element.querySelectorAll<HTMLElement>('[data-message-id], [data-msg-id]')];
165
+ for (const message of candidates) {
166
+ const row = rows.find(row => (row.dataset.messageId || row.dataset.msgId) === message.id);
167
+ if (!row) continue;
168
+ const bounds = row.getBoundingClientRect(), viewport = element.getBoundingClientRect();
169
+ if (bounds.bottom < viewport.top || bounds.top > viewport.bottom) continue;
170
+ const origin = sends.get(message.id), work = sources.get(message.id);
171
+ if (origin && !seen.has(message.stableId)) animator.send(row, origin.bounds, origin.keyboard);
172
+ else if (work) animator.reply(row, work);
173
+ else if (appended.includes(message)) animator.reveal(row);
174
+ }
175
+ });
176
+ const onKey = (event: KeyboardEvent) => { if (['PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown'].includes(event.key)) interrupt(); };
177
+ onMounted(() => {
178
+ media = typeof window.matchMedia === 'function' ? window.matchMedia('(prefers-reduced-motion: reduce)') : null;
179
+ media?.addEventListener('change', interrupt);
180
+ window.addEventListener('resize', interrupt);
181
+ options.element.value?.addEventListener('wheel', interrupt, { passive: true });
182
+ options.element.value?.addEventListener('touchstart', interrupt, { passive: true });
183
+ options.element.value?.addEventListener('pointerdown', interrupt, { passive: true });
184
+ options.element.value?.addEventListener('keydown', onKey);
185
+ });
186
+ onDeactivated(interrupt);
187
+ onBeforeUnmount(() => {
188
+ interrupt(); media?.removeEventListener('change', interrupt); window.removeEventListener('resize', interrupt);
189
+ options.element.value?.removeEventListener('wheel', interrupt);
190
+ options.element.value?.removeEventListener('touchstart', interrupt);
191
+ options.element.value?.removeEventListener('pointerdown', interrupt);
192
+ options.element.value?.removeEventListener('keydown', onKey);
193
+ });
194
+ return { cancel: interrupt };
195
+ }
@@ -0,0 +1,72 @@
1
+ import { onBeforeUnmount, onMounted, onUpdated, ref, watch } from "vue";
2
+
3
+ /** One underline, shared by the desktop and web room headers. */
4
+ export function useSlidingTabIndicator(activeTab: () => string) {
5
+ const tabsElement = ref<HTMLElement | null>(null);
6
+ const indicatorElement = ref<HTMLElement | null>(null);
7
+ let animation: Animation | null = null;
8
+ let motion: MediaQueryList | undefined;
9
+ let resizeObserver: ResizeObserver | undefined;
10
+ let animateNextChange = false;
11
+ let placed = false;
12
+
13
+ function position(animate = false): void {
14
+ const tabs = tabsElement.value;
15
+ const indicator = indicatorElement.value;
16
+ const selected = tabs?.querySelector<HTMLElement>('[aria-current="page"]');
17
+ if (!tabs || !indicator || !selected) {
18
+ animation?.cancel();
19
+ if (indicator) indicator.style.visibility = "hidden";
20
+ placed = false;
21
+ return;
22
+ }
23
+ const width = selected.offsetWidth;
24
+ if (!width) return;
25
+ const target = `translate(${selected.offsetLeft}px, ${selected.offsetTop + selected.offsetHeight - 2}px)`;
26
+ // Content updates and ResizeObserver notifications must not restart a running move.
27
+ if (placed && indicator.style.transform === target && indicator.style.width === `${width}px`) return;
28
+ const previous = indicator.getBoundingClientRect();
29
+ const origin = tabs.getBoundingClientRect();
30
+ animation?.cancel();
31
+ animation = null;
32
+ indicator.style.width = `${width}px`;
33
+ indicator.style.transform = target;
34
+ indicator.style.visibility = "visible";
35
+ if (animate && placed && !motion?.matches && indicator.animate) {
36
+ animation = indicator.animate([
37
+ { transform: `translate(${previous.left - origin.left + tabs.scrollLeft}px, ${previous.top - origin.top + tabs.scrollTop}px) scaleX(${previous.width / width})` },
38
+ { transform: `${target} scaleX(1)` },
39
+ ], { duration: 240, easing: "cubic-bezier(.22, 1, .36, 1)" });
40
+ }
41
+ placed = true;
42
+ }
43
+
44
+ function prepareTabChange(event: MouseEvent, tab: string): void {
45
+ animateNextChange = event.detail > 0 && tab !== activeTab();
46
+ }
47
+
48
+ function syncLayout(): void { position(); }
49
+ function syncMotion(): void { animation?.cancel(); animation = null; position(); }
50
+
51
+ watch(activeTab, () => {
52
+ position(animateNextChange);
53
+ animateNextChange = false;
54
+ }, { flush: "post" });
55
+ onUpdated(syncLayout);
56
+ onMounted(() => {
57
+ motion = window.matchMedia?.("(prefers-reduced-motion: reduce)");
58
+ motion?.addEventListener("change", syncMotion);
59
+ if (typeof ResizeObserver !== "undefined" && tabsElement.value) {
60
+ resizeObserver = new ResizeObserver(syncLayout);
61
+ resizeObserver.observe(tabsElement.value);
62
+ }
63
+ syncLayout();
64
+ });
65
+ onBeforeUnmount(() => {
66
+ animation?.cancel();
67
+ resizeObserver?.disconnect();
68
+ motion?.removeEventListener("change", syncMotion);
69
+ });
70
+
71
+ return { tabsElement, indicatorElement, prepareTabChange };
72
+ }
@@ -5,10 +5,12 @@ import { join } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import { WORKSPACE_FILE_LIMIT, WORKSPACE_PATCH_LIMIT, parseWorkspaceChangeSummary } from './workspace-change-summary.mjs';
7
7
  const execute = promisify(execFile);
8
+ // Replace refs are off: with them the workspace could put another tree in the place of the
9
+ // one a comparison starts from, and the comparison would show nothing.
8
10
  async function git(cwd, args, limit = 4 * 1024 * 1024) {
9
11
  const result = await execute('git', ['--no-optional-locks', ...args], {
10
12
  cwd, encoding: 'utf8', timeout: 5_000, maxBuffer: limit,
11
- env: { ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))), GIT_TERMINAL_PROMPT: '0' },
13
+ env: { ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))), GIT_TERMINAL_PROMPT: '0', GIT_NO_REPLACE_OBJECTS: '1' },
12
14
  });
13
15
  return result.stdout;
14
16
  }
@@ -3,7 +3,10 @@ import type { WorkspaceReview } from './workspace-review.mjs';
3
3
  export function unavailableWorkspace(): WorkspaceChangeSummary;
4
4
  export function captureWorkspaceTree(workspace: string, identity: string): Promise<string | null>;
5
5
  export function releaseWorkspaceTree(workspace: string, identity: string): Promise<void>;
6
- export function captureWorkspacePair(workspace: string, startingRevision: string | null, baseline: string | null, identity: string): Promise<{
6
+ export function captureWorkspaceHead(workspace: string): Promise<string | null>;
7
+ export function captureRemoteTips(workspace: string): Promise<string[] | null>;
8
+ export function captureRecordedRemoteTips(url: string | null | undefined, directory?: string): Promise<string[] | null>;
9
+ export function captureWorkspacePair(workspace: string, startingRevision: string | null, baseline: string | null, identity: string, startingHead?: string | null, remoteTips?: readonly string[] | null | Promise<readonly string[] | null>): Promise<{
7
10
  workspace: WorkspaceChangeSummary;
8
11
  contribution: { changes: WorkspaceChangeSummary; summary: string | null };
9
12
  review: WorkspaceReview;
@@ -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
- function git(cwd, args, signal, index, input) {
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
- export async function captureWorkspacePair(workspace, startingRevision, baseline, identity) {
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 changes = tree && baseline ? await captureWorkspaceChanges(workspace, baseline, tree, true) : unavailableWorkspace();
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 });