Agent Manager commited on
Commit
2fc73bc
·
1 Parent(s): 7e49345

Harden screenshot input delivery and recovery

Browse files
docs/screenshot-input.md CHANGED
@@ -1,6 +1,6 @@
1
  # Screenshot input
2
 
3
- Status: proposed
4
 
5
  Date: 2026-08-05
6
 
@@ -61,10 +61,10 @@ denominator:
61
  | Harness | Observed path as of this design | First implementation |
62
  |---|---|---|
63
  | Claude Code | accepts an image path in a prompt; native terminals also support image paste/drop | explicit absolute path in the prompt |
64
- | Codex | `-i/--image` supports initial images; in-session image paste reads the server clipboard | explicit path, allowing `view_image`; native first-turn flag later |
65
  | Gemini CLI | `@<path>` injects supported images as multimodal context | `@<absolute-path>` |
66
  | opencode | TUI image drop and `opencode run -f/--file` are supported | explicit/drop-style absolute path |
67
- | Hermes | `--image` and the interactive `/image <path>` command are supported | `/image <absolute-path>` adapter, with explicit-path fallback |
68
  | OpenClaw | no stable image-attachment CLI contract was verified | explicit path, best effort |
69
 
70
  Every adapter must retain the explicit-path fallback. CLI flags and TUI commands
@@ -226,6 +226,10 @@ session's own directory, and never join an arbitrary browser-supplied filename.
226
  whose newest file is older than seven days. The grace period covers a crash
227
  between session persistence and upload completion.
228
  - Failed and aborted uploads delete their temporary file immediately.
 
 
 
 
229
 
230
  No separate database is required. The session-scoped directory, validated id,
231
  extension, and `stat` provide the metadata needed in v1.
@@ -262,7 +266,8 @@ and abort with `413` above 25 MiB. Once complete:
262
 
263
  1. Read the first small header from the temporary file.
264
  2. Detect PNG, JPEG, GIF, or WebP by magic bytes.
265
- 3. Reject an unsupported or mismatched body with `415`.
 
266
  4. Rename to the detected extension atomically.
267
  5. Return `201`.
268
 
@@ -358,15 +363,12 @@ trace.
358
  ### 8.2 Adapter interface
359
 
360
  Keep version-sensitive behavior in one module rather than scattered across React
361
- components and `runner.js`:
 
362
 
363
  ```ts
364
- interface AttachmentDelivery {
365
- prompt: string;
366
- prelude?: Array<{ text: string; submit: boolean; settleMs?: number }>;
367
- }
368
-
369
- formatAttachmentDelivery(cli, text, paths): AttachmentDelivery
370
  ```
371
 
372
  Initial adapters:
@@ -376,9 +378,10 @@ Initial adapters:
376
  prompt. If the command is unavailable, use the universal prompt.
377
  - all others: use the universal prompt.
378
 
379
- Codex `--image`, opencode `--file`, and other native launch flags are optional
380
- follow-ups. They should be added only with version probes/tests and must not be
381
- required for the feature to function.
 
382
 
383
  ### 8.3 First prompt without a boot race
384
 
@@ -391,8 +394,10 @@ two-step browser flow—create the session, then upload to its attachment scope
391
  2. Upload all pending images to the returned session id.
392
  3. `POST /api/sessions/:id/input` with text and attachment ids.
393
  4. If the session has never started and its CLI has `withPrompt`, store the
394
- fully formatted prompt as `pendingPrompt` and call `ensureRunning()`.
395
- 5. `commandFor()` consumes `pendingPrompt` on the first launch as it does today.
 
 
396
 
397
  Only resumed or already-started sessions use the existing boot-then-type path.
398
  If attachment upload fails after session creation, keep the stopped session and
@@ -401,19 +406,26 @@ the browser draft. Automatically deleting it would make recovery surprising.
401
  ### 8.4 Terminal insertion
402
 
403
  Live terminal paste/drop does not call `/input`, because `/input` submits a turn.
404
- After upload, the browser uses `insertText` returned by the attachment endpoint:
 
405
 
406
- ```ts
407
- term.paste(uploaded.insertText)
 
 
 
408
  ```
409
 
410
- `paste()` retains xterm's bracketed-paste handling. The browser claims terminal
411
- control before insertion exactly as it does for ordinary paste. A watcher that
412
- cannot claim control reports `Image uploaded, but this pane is watching—interact
413
- and try again` rather than pretending the path reached the CLI.
 
 
 
414
 
415
- Hermes is the one useful native exception: the browser may send `/image <path>`
416
- and Return, wait for the command to settle, and then restore focus without
417
  submitting the actual prompt. This logic should still live behind the same
418
  adapter and fall back to `insertText`.
419
 
@@ -421,7 +433,7 @@ adapter and fall back to `insertText`.
421
 
422
  Add a small module, for example `web/src/lib/imageAttachments.ts`, containing:
423
 
424
- - accepted MIME types and client-side 25 MiB check;
425
  - `imageFilesFromTransfer(DataTransfer)`;
426
  - `transferMayContainImage(DataTransfer)`;
427
  - duplicate suppression across `items` and `files`;
@@ -545,6 +557,7 @@ may reuse already uploaded ids while uploading only failed files.
545
  - The byte limit trips during streaming and leaves no partial file.
546
  - Aborted requests leave no partial file.
547
  - Concurrent uploads produce unique files.
 
548
  - Attachment ids cannot cross sessions or traverse paths.
549
  - Preview headers include CSP and `nosniff`.
550
  - A structured prompt rejects if any referenced attachment is absent.
@@ -561,7 +574,11 @@ may reuse already uploaded ids while uploading only failed files.
561
  - Plain-text paste is unchanged.
562
  - Removing a chip revokes its preview and excludes it from upload.
563
  - A multi-image submission waits for every upload before `/input`.
564
- - Terminal paste uploads and inserts a path without Return.
 
 
 
 
565
  - Terminal image drop does not trigger pane movement.
566
  - Pane movement data does not trigger the image drop UI.
567
  - The mobile fallback textarea handles both image and text paste.
 
1
  # Screenshot input
2
 
3
+ Status: implemented in draft PR
4
 
5
  Date: 2026-08-05
6
 
 
61
  | Harness | Observed path as of this design | First implementation |
62
  |---|---|---|
63
  | Claude Code | accepts an image path in a prompt; native terminals also support image paste/drop | explicit absolute path in the prompt |
64
+ | Codex | `-i/--image` supports initial images; in-session image paste reads the server clipboard | native `-i` on the first turn plus an explicit path fallback |
65
  | Gemini CLI | `@<path>` injects supported images as multimodal context | `@<absolute-path>` |
66
  | opencode | TUI image drop and `opencode run -f/--file` are supported | explicit/drop-style absolute path |
67
+ | Hermes | the current TUI supports `/image <path>` and detects a pasted standalone image path | `/image <absolute-path>` adapter, with explicit-path fallback |
68
  | OpenClaw | no stable image-attachment CLI contract was verified | explicit path, best effort |
69
 
70
  Every adapter must retain the explicit-path fallback. CLI flags and TUI commands
 
226
  whose newest file is older than seven days. The grace period covers a crash
227
  between session persistence and upload completion.
228
  - Failed and aborted uploads delete their temporary file immediately.
229
+ - Each session is capped at 200 stored screenshots and 500 MiB. Uploads for one
230
+ session are serialized so concurrent requests cannot race the quota check.
231
+ - Pruning and session deletion use asynchronous filesystem operations so a
232
+ large attachment store cannot block terminal I/O.
233
 
234
  No separate database is required. The session-scoped directory, validated id,
235
  extension, and `stat` provide the metadata needed in v1.
 
266
 
267
  1. Read the first small header from the temporary file.
268
  2. Detect PNG, JPEG, GIF, or WebP by magic bytes.
269
+ 3. Reject unsupported or malformed bytes with `415`. A missing, aliased, or
270
+ incorrect request `Content-Type` does not override byte detection.
271
  4. Rename to the detected extension atomically.
272
  5. Return `201`.
273
 
 
363
  ### 8.2 Adapter interface
364
 
365
  Keep version-sensitive behavior in one module rather than scattered across React
366
+ components and `runner.js`. The implementation exposes the universal formatted
367
+ prompt and any native prelude commands separately:
368
 
369
  ```ts
370
+ formatAttachmentDelivery(cli, text, images): string
371
+ formatAttachmentPrelude(cli, images): string[]
 
 
 
 
372
  ```
373
 
374
  Initial adapters:
 
378
  prompt. If the command is unavailable, use the universal prompt.
379
  - all others: use the universal prompt.
380
 
381
+ opencode `--file` and other native launch flags are optional follow-ups. Codex
382
+ uses its installed `--image` support on a first turn; every adapter retains the
383
+ explicit path so a missing or changed native flag is not the only route to the
384
+ file.
385
 
386
  ### 8.3 First prompt without a boot race
387
 
 
394
  2. Upload all pending images to the returned session id.
395
  3. `POST /api/sessions/:id/input` with text and attachment ids.
396
  4. If the session has never started and its CLI has `withPrompt`, store the
397
+ fully formatted prompt as `pendingPrompt`; for Codex, also retain the
398
+ validated paths as `pendingImagePaths`; then call `ensureRunning()`.
399
+ 5. `commandFor()` consumes those fields on the first launch, adding one Codex
400
+ `-i` flag per image, and clears them once the PTY starts.
401
 
402
  Only resumed or already-started sessions use the existing boot-then-type path.
403
  If attachment upload fails after session creation, keep the stopped session and
 
406
  ### 8.4 Terminal insertion
407
 
408
  Live terminal paste/drop does not call `/input`, because `/input` submits a turn.
409
+ After upload, it asks the server to insert the resolved attachments without a
410
+ Return key:
411
 
412
+ ```http
413
+ POST /api/sessions/:id/attachments/insert
414
+ Content-Type: application/json
415
+
416
+ {"attachmentIds":["att_74e0f69dc9ed772cb685999e"]}
417
  ```
418
 
419
+ The server writes `insertText` to the running PTY and acknowledges only after
420
+ the write is accepted. It never sends Return. If the process stops after upload,
421
+ the browser says the image was saved but not inserted and retains its attachment
422
+ id behind a Retry action. This avoids treating a browser-local xterm paste as
423
+ proof that a disconnected or non-controlling pane reached the CLI. Completed
424
+ terminal insertions are idempotent by attachment id, so retrying after a lost
425
+ HTTP response cannot paste the same path twice.
426
 
427
+ Hermes is the one useful native exception: the server may send `/image <path>`
428
+ and Return, briefly wait for the command to settle, and then restore focus without
429
  submitting the actual prompt. This logic should still live behind the same
430
  adapter and fall back to `insertText`.
431
 
 
433
 
434
  Add a small module, for example `web/src/lib/imageAttachments.ts`, containing:
435
 
436
+ - accepted MIME hints, byte/size checks, and client-side 25 MiB check;
437
  - `imageFilesFromTransfer(DataTransfer)`;
438
  - `transferMayContainImage(DataTransfer)`;
439
  - duplicate suppression across `items` and `files`;
 
557
  - The byte limit trips during streaming and leaves no partial file.
558
  - Aborted requests leave no partial file.
559
  - Concurrent uploads produce unique files.
560
+ - Concurrent uploads cannot race the per-session byte/count quota.
561
  - Attachment ids cannot cross sessions or traverse paths.
562
  - Preview headers include CSP and `nosniff`.
563
  - A structured prompt rejects if any referenced attachment is absent.
 
574
  - Plain-text paste is unchanged.
575
  - Removing a chip revokes its preview and excludes it from upload.
576
  - A multi-image submission waits for every upload before `/input`.
577
+ - Every chip mutation remains disabled for the complete multi-image send.
578
+ - Terminal paste receives a server acknowledgement and inserts without Return.
579
+ - A terminal stopped after upload reports saved-but-not-inserted, disables new
580
+ attachments, and can retry the stored attachment after restart.
581
+ - Remote composers show a visible explanation on touch layouts.
582
  - Terminal image drop does not trigger pane movement.
583
  - Pane movement data does not trigger the image drop UI.
584
  - The mobile fallback textarea handles both image and text paste.
server/package.json CHANGED
@@ -13,7 +13,8 @@
13
  "scripts": {
14
  "start": "node src/index.js",
15
  "dev": "node --watch src/index.js",
16
- "test:ui": "node terminal-ui.test.mjs",
 
17
  "test": "node test/attachments.test.mjs && node test/repin.test.mjs && node test/opencode-resume.test.mjs && node test/terminal-modes.test.mjs && node migration.test.mjs && node resize.test.mjs"
18
  },
19
  "engines": {
 
13
  "scripts": {
14
  "start": "node src/index.js",
15
  "dev": "node --watch src/index.js",
16
+ "test:ui": "node terminal-ui.test.mjs && node screenshot-input.test.mjs",
17
+ "test:screenshots": "node screenshot-input.test.mjs",
18
  "test": "node test/attachments.test.mjs && node test/repin.test.mjs && node test/opencode-resume.test.mjs && node test/terminal-modes.test.mjs && node migration.test.mjs && node resize.test.mjs"
19
  },
20
  "engines": {
server/screenshot-input.test.mjs ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Screenshot-input integration checks in a real browser:
4
+ * - stored bytes, response MIME, and preview headers come from detection;
5
+ * - a stopped terminal never reports a false insertion success;
6
+ * - an uploaded-but-uninserted screenshot can be retried without reupload;
7
+ * - attachment chips cannot mutate an in-flight send;
8
+ * - remote limitations are visible rather than tooltip-only.
9
+ *
10
+ * Set SCREENSHOT_PUBLIC_DIR to a prebuilt web/dist to skip the build.
11
+ */
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { spawn, spawnSync } from 'node:child_process';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { chromium } from 'playwright';
18
+
19
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
20
+ const ROOT = path.dirname(HERE);
21
+ const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'am-screenshot-ui-'));
22
+ const PUBLIC_DIR = process.env.SCREENSHOT_PUBLIC_DIR || path.join(DATA_DIR, 'public');
23
+ const API = 'http://127.0.0.1:7896';
24
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
25
+
26
+ const png = Buffer.alloc(45);
27
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(png);
28
+ png.writeUInt32BE(13, 8); Buffer.from('IHDR').copy(png, 12);
29
+ png.writeUInt32BE(1, 16); png.writeUInt32BE(1, 20);
30
+ Buffer.from('IEND').copy(png, 37);
31
+
32
+ let failures = 0;
33
+ const check = (name, ok, detail = '') => {
34
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` ${detail}` : ''}`);
35
+ if (!ok) failures += 1;
36
+ };
37
+ const waitFor = async (fn, timeout = 15_000) => {
38
+ const until = Date.now() + timeout;
39
+ while (Date.now() < until) {
40
+ try { if (await fn()) return true; } catch {}
41
+ await sleep(100);
42
+ }
43
+ return false;
44
+ };
45
+ const apiJson = async (url, init) => {
46
+ const response = await fetch(`${API}${url}`, init);
47
+ const body = await response.json().catch(() => ({}));
48
+ return { response, body };
49
+ };
50
+
51
+ if (!process.env.SCREENSHOT_PUBLIC_DIR) {
52
+ const build = spawnSync('npm', ['run', 'build', '--', '--outDir', PUBLIC_DIR], {
53
+ cwd: path.join(ROOT, 'web'), encoding: 'utf8',
54
+ });
55
+ if (build.status !== 0) throw new Error(`web build failed:\n${build.stdout}\n${build.stderr}`);
56
+ }
57
+
58
+ const backend = spawn('node', ['src/index.js'], {
59
+ cwd: HERE,
60
+ env: {
61
+ ...process.env,
62
+ PORT: '7896', DATA_DIR, PUBLIC_DIR, AM_BASHRC: '/nonexistent', SPACE_HOST: '',
63
+ AM_TEST_REPAINT_CMD: 'bash --noprofile --norc',
64
+ },
65
+ stdio: ['ignore', 'pipe', 'pipe'],
66
+ });
67
+ let logs = '';
68
+ backend.stdout.on('data', (data) => { logs += data; });
69
+ backend.stderr.on('data', (data) => { logs += data; });
70
+
71
+ let browser;
72
+ try {
73
+ if (!await waitFor(() => fetch(`${API}/api/health`).then((response) => response.ok).catch(() => false), 60_000)) {
74
+ throw new Error(`server did not start:\n${logs.slice(-2000)}`);
75
+ }
76
+ await fetch(`${API}/api/welcome/seen`, { method: 'POST' });
77
+
78
+ const created = await apiJson('/api/sessions', {
79
+ method: 'POST', headers: { 'content-type': 'application/json' },
80
+ body: JSON.stringify({ cli: 'test-repaint', name: 'screenshot-e2e', path: '.' }),
81
+ });
82
+ const id = created.body.id;
83
+ if (!id) throw new Error(`session creation failed: ${JSON.stringify(created.body)}`);
84
+
85
+ // The browser's declared type is deliberately non-canonical. The response
86
+ // and raw preview must reflect the PNG bytes, not that metadata.
87
+ const upload = await fetch(`${API}/api/sessions/${id}/attachments`, {
88
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: png,
89
+ });
90
+ const stored = await upload.json();
91
+ check('upload MIME is detected from bytes', upload.status === 201 && stored.mime === 'image/png',
92
+ JSON.stringify({ status: upload.status, mime: stored.mime }));
93
+ const preview = await fetch(`${API}${stored.previewUrl}`);
94
+ check('preview uses private, sandboxed response headers',
95
+ preview.headers.get('content-type') === 'image/png'
96
+ && preview.headers.get('cache-control') === 'no-store'
97
+ && preview.headers.get('x-content-type-options') === 'nosniff'
98
+ && preview.headers.get('content-security-policy') === 'sandbox');
99
+
100
+ const missingId = `att_${'0'.repeat(24)}`;
101
+ const mixedSend = await apiJson(`/api/sessions/${id}/input`, {
102
+ method: 'POST', headers: { 'content-type': 'application/json' },
103
+ body: JSON.stringify({ text: 'must not send partially', attachmentIds: [stored.id, missingId] }),
104
+ });
105
+ const sessionsAfterReject = await (await fetch(`${API}/api/sessions`)).json();
106
+ check('structured send rejects atomically before starting the agent',
107
+ mixedSend.response.status === 404
108
+ && sessionsAfterReject.find((session) => session.id === id)?.running === false,
109
+ JSON.stringify({ status: mixedSend.response.status, error: mixedSend.body.error }));
110
+
111
+ const remoteCreated = await apiJson('/api/sessions', {
112
+ method: 'POST', headers: { 'content-type': 'application/json' },
113
+ body: JSON.stringify({ cli: 'remote', name: 'remote-screenshot-e2e', path: '.' }),
114
+ });
115
+ const remoteUpload = await fetch(`${API}/api/sessions/${remoteCreated.body.id}/attachments`, {
116
+ method: 'POST', headers: { 'content-type': 'image/png' }, body: png,
117
+ });
118
+ const remoteUploadBody = await remoteUpload.json();
119
+ check('remote uploads are rejected with an actionable reason',
120
+ remoteUpload.status === 400 && remoteUploadBody.error.includes('cannot read files stored on this Space'));
121
+
122
+ const bakedChromium = '/opt/pw-browsers/chromium-1208/chrome-linux64/chrome';
123
+ const chromiumExecutable = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
124
+ || (fs.existsSync(bakedChromium) ? bakedChromium : undefined);
125
+ browser = await chromium.launch({
126
+ headless: true,
127
+ ...(chromiumExecutable ? { executablePath: chromiumExecutable } : {}),
128
+ });
129
+ const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
130
+ await page.goto(API, { waitUntil: 'domcontentloaded' });
131
+ await page.locator('.sidebar .row[title^="screenshot-e2e"]').first().click();
132
+ await page.locator('.tile-terminal:not(.tile-cached) .xterm-screen').waitFor({ state: 'visible' });
133
+ await page.locator('.pane-head .ph-image').waitFor({ state: 'visible' });
134
+ await waitFor(() => page.locator('.pane-head .ph-image').isEnabled());
135
+
136
+ // Let the upload finish, stop the process, and only then expose the response
137
+ // to the UI. The following insert request must fail authoritatively.
138
+ let disconnectedAttachmentId;
139
+ await page.route(`**/api/sessions/${id}/attachments`, async (route) => {
140
+ const response = await route.fetch();
141
+ disconnectedAttachmentId = (await response.json()).id;
142
+ await fetch(`${API}/api/sessions/${id}/stop`, { method: 'POST' });
143
+ await route.fulfill({ response });
144
+ }, { times: 1 });
145
+ await page.locator('.pane-head .image-file-input').setInputFiles({
146
+ name: 'disconnect.png', mimeType: 'image/png', buffer: png,
147
+ });
148
+ const retryStatus = page.locator('.term-image-status.has-action');
149
+ await retryStatus.waitFor({ state: 'visible' });
150
+ const failedText = await retryStatus.textContent();
151
+ check('a stopped terminal reports saved-but-not-inserted, never success',
152
+ !!failedText?.includes('saved but not inserted') && !failedText.includes('press Enter'),
153
+ JSON.stringify({ failedText }));
154
+ await page.locator('.term-exit').waitFor({ state: 'visible' });
155
+ check('terminal attachment picker is unavailable while stopped',
156
+ await page.locator('.pane-head .ph-image').isDisabled());
157
+
158
+ await page.locator('.term-exit .tx-btn').click();
159
+ await waitFor(() => page.locator('.pane-head .ph-image').isEnabled(), 20_000);
160
+ const retry = retryStatus.locator('button');
161
+ await retry.click();
162
+ await page.locator('.term-image-status.success').waitFor({ state: 'visible' });
163
+ const successText = await page.locator('.term-image-status.success').textContent();
164
+ check('retry inserts the already-uploaded screenshot after restart',
165
+ !!successText?.includes('inserted') && successText.includes('press Enter'),
166
+ JSON.stringify({ successText }));
167
+ const terminalTail = await (await fetch(`${API}/api/agents/${id}/tail?lines=80`)).json();
168
+ check('terminal retry reaches the PTY without submitting the prompt',
169
+ terminalTail.text.includes('Screenshot:') && !terminalTail.text.includes('command not found'));
170
+ const repeatedInsert = await apiJson(`/api/sessions/${id}/attachments/insert`, {
171
+ method: 'POST', headers: { 'content-type': 'application/json' },
172
+ body: JSON.stringify({ attachmentIds: [disconnectedAttachmentId] }),
173
+ });
174
+ const tailAfterRepeat = await (await fetch(`${API}/api/agents/${id}/tail?lines=80`)).json();
175
+ const insertedCount = (value) => (value.match(/Screenshot:/g) || []).length;
176
+ check('retry is idempotent if the successful HTTP response was lost',
177
+ repeatedInsert.body.repeated === true
178
+ && insertedCount(tailAfterRepeat.text) === insertedCount(terminalTail.text));
179
+
180
+ const watcher = await browser.newPage({ viewport: { width: 1000, height: 700 } });
181
+ await watcher.goto(API, { waitUntil: 'domcontentloaded' });
182
+ await watcher.locator('.sidebar .row[title^="screenshot-e2e"]').first().click();
183
+ await watcher.locator('.ph-role', { hasText: 'watching' }).waitFor({ state: 'visible' });
184
+ check('a shared-terminal watcher cannot inject into the controller composer',
185
+ await watcher.locator('.pane-head .ph-image').isDisabled()
186
+ && (await watcher.locator('.pane-head .ph-image').getAttribute('title'))?.includes('take control'));
187
+ await watcher.close();
188
+
189
+ // Remote support is phase two, but its reason must be readable on touch
190
+ // devices where a disabled button's title can never be discovered.
191
+ await page.locator('.bolt-btn').click();
192
+ await page.locator('.quick-cli[title^="Remote agent"]').click();
193
+ const remoteReason = page.locator('.quick .image-attachments-note');
194
+ check('remote screenshot limitation is visibly explained',
195
+ await remoteReason.isVisible() && (await remoteReason.textContent())?.includes('cannot read files stored on this Space'));
196
+
197
+ // Switch back to the test harness and hold the second upload. Once the first
198
+ // chip says uploaded, every attachment mutation must remain disabled until
199
+ // the single logical send transaction finishes.
200
+ await page.locator('.quick-cli[title="Repaint fixture"]').click();
201
+ await page.locator('.quick-prompt').fill('inspect both screenshots');
202
+ await page.locator('.quick-prompt').evaluate((element, bytes) => {
203
+ const raw = atob(bytes);
204
+ const data = Uint8Array.from(raw, (character) => character.charCodeAt(0));
205
+ const transfer = new DataTransfer();
206
+ transfer.items.add(new File([data], 'first.jpg', { type: 'image/jpg' }));
207
+ element.dispatchEvent(new ClipboardEvent('paste', {
208
+ bubbles: true, cancelable: true, clipboardData: transfer,
209
+ }));
210
+ }, png.toString('base64'));
211
+ check('clipboard image paste creates one deduplicated chip and preserves prompt text',
212
+ await page.locator('.quick .image-chip').count() === 1
213
+ && await page.locator('.quick-prompt').inputValue() === 'inspect both screenshots');
214
+ await page.locator('.quick .image-file-input').setInputFiles({
215
+ name: 'second.png', mimeType: 'image/png', buffer: png,
216
+ });
217
+ let releaseSecond;
218
+ let sawSecond;
219
+ const secondReached = new Promise((resolve) => { sawSecond = resolve; });
220
+ const secondHold = new Promise((resolve) => { releaseSecond = resolve; });
221
+ let uploadCount = 0;
222
+ await page.route('**/api/sessions/*/attachments', async (route) => {
223
+ uploadCount += 1;
224
+ if (uploadCount === 2) {
225
+ sawSecond();
226
+ await secondHold;
227
+ }
228
+ await route.continue();
229
+ });
230
+ await page.locator('.quick-prompt').press('Enter');
231
+ await Promise.race([
232
+ secondReached,
233
+ sleep(20_000).then(() => { throw new Error('second upload did not start'); }),
234
+ ]);
235
+ const removeButtons = page.locator('.quick .image-chip > button');
236
+ check('all attachment mutations stay locked for the full send transaction',
237
+ await page.locator('.quick .image-pick').isDisabled()
238
+ && await removeButtons.nth(0).isDisabled()
239
+ && await removeButtons.nth(1).isDisabled());
240
+ releaseSecond();
241
+ await page.locator('.controls').waitFor({ state: 'hidden', timeout: 30_000 });
242
+ } finally {
243
+ try { await browser?.close(); } catch {}
244
+ backend.kill('SIGKILL');
245
+ fs.rmSync(DATA_DIR, { recursive: true, force: true });
246
+ }
247
+
248
+ console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
249
+ process.exit(failures ? 1 : 0);
server/src/attachments.js CHANGED
@@ -6,6 +6,11 @@ import { pipeline } from 'node:stream/promises';
6
  import { STATE_DIR } from './config.js';
7
 
8
  export const ATTACHMENT_LIMIT = 25 * 1024 * 1024;
 
 
 
 
 
9
  export const ATTACHMENT_ID = /^att_[a-f0-9]{24}$/;
10
  export const IMAGE_MIMES = Object.freeze([
11
  'image/png',
@@ -22,6 +27,7 @@ const EXTENSIONS = Object.freeze({
22
  'image/gif': 'gif',
23
  });
24
  const uploadWindows = new Map();
 
25
 
26
  function httpError(statusCode, message) {
27
  const error = new Error(message);
@@ -47,6 +53,39 @@ function checkUploadRate(sessionId) {
47
  uploadWindows.set(sessionId, recent);
48
  }
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  export function detectImageMime(bytes) {
51
  if (bytes.length >= 8
52
  && bytes[0] === 0x89 && bytes.subarray(1, 4).toString('ascii') === 'PNG'
@@ -108,57 +147,63 @@ const responseShape = (sessionId, id, mime, bytes, filePath) => ({
108
 
109
  /** Stream one browser image into the session-owned attachment store. */
110
  export async function receiveImage(readable, sessionId, contentType) {
111
- const declared = String(contentType || '').split(';', 1)[0].trim().toLowerCase();
112
- if (!IMAGE_MIMES.includes(declared)) throw httpError(415, 'use PNG, JPEG, GIF, or WebP');
 
 
113
  checkUploadRate(sessionId);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
 
115
- const dir = sessionDir(sessionId);
116
- await fs.promises.mkdir(dir, { recursive: true });
117
- const id = `att_${crypto.randomBytes(12).toString('hex')}`;
118
- const temporary = path.join(dir, `.${id}.${crypto.randomBytes(4).toString('hex')}.part`);
119
- let bytes = 0;
120
- const limiter = new Transform({
121
- transform(chunk, _encoding, callback) {
122
- bytes += chunk.length;
123
- if (bytes > ATTACHMENT_LIMIT) return callback(httpError(413, 'image is larger than 25 MB'));
124
- callback(null, chunk);
125
- },
126
- });
127
-
128
- try {
129
- await pipeline(readable, limiter, fs.createWriteStream(temporary, { flags: 'wx' }));
130
- if (bytes === 0) throw httpError(413, 'image is empty');
131
-
132
- const handle = await fs.promises.open(temporary, 'r');
133
- const header = Buffer.alloc(32);
134
- const tail = Buffer.alloc(Math.min(16, bytes));
135
- let bytesRead = 0;
136
- let tailBytesRead = 0;
137
  try {
138
- ({ bytesRead } = await handle.read(header, 0, header.length, 0));
139
- ({ bytesRead: tailBytesRead } = await handle.read(tail, 0, tail.length, Math.max(0, bytes - tail.length)));
140
- } finally {
141
- await handle.close();
142
- }
143
- const headerBytes = header.subarray(0, bytesRead);
144
- const tailBytes = tail.subarray(0, tailBytesRead);
145
- const detected = detectImageMime(headerBytes);
146
- if (!detected || detected !== declared) {
147
- throw httpError(415, detected
148
- ? `image bytes are ${detected}, not ${declared}`
149
- : 'file is not a supported raster image');
150
- }
151
- if (!imageEnvelopeIsValid(detected, headerBytes, tailBytes, bytes)) {
152
- throw httpError(415, 'image is truncated or malformed');
153
- }
154
 
155
- const finalPath = path.join(dir, `${id}.${EXTENSIONS[detected]}`);
156
- await fs.promises.rename(temporary, finalPath);
157
- return responseShape(sessionId, id, detected, bytes, finalPath);
158
- } catch (error) {
159
- await fs.promises.unlink(temporary).catch(() => {});
160
- throw error;
161
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  }
163
 
164
  /** Resolve an untrusted attachment id within exactly one session. */
@@ -183,17 +228,17 @@ export function resolveImages(sessionId, attachmentIds) {
183
  return attachmentIds.map((id) => resolveImage(sessionId, id));
184
  }
185
 
186
- export function removeSessionAttachments(sessionId) {
187
  uploadWindows.delete(sessionId);
188
- fs.rmSync(sessionDir(sessionId), { recursive: true, force: true });
189
  }
190
 
191
  /** Remove only old orphan stores; recent crash leftovers keep a seven-day grace. */
192
- export function pruneAttachmentDirs(sessionIds, now = Date.now()) {
193
  const live = new Set(sessionIds);
194
  const cutoff = now - 7 * 24 * 60 * 60 * 1000;
195
  let entries = [];
196
- try { entries = fs.readdirSync(ATTACHMENTS_DIR, { withFileTypes: true }); } catch { return; }
197
  for (const entry of entries) {
198
  if (!entry.isDirectory()) continue;
199
  const dir = path.join(ATTACHMENTS_DIR, entry.name);
@@ -201,24 +246,33 @@ export function pruneAttachmentDirs(sessionIds, now = Date.now()) {
201
  // Normal request failures remove these immediately; this is the crash
202
  // backstop, with the same grace period as orphan session directories.
203
  try {
204
- for (const name of fs.readdirSync(dir)) {
205
  const part = path.join(dir, name);
206
- if (name.endsWith('.part') && fs.lstatSync(part).isFile() && fs.statSync(part).mtimeMs < cutoff) {
207
- fs.unlinkSync(part);
 
208
  }
209
  }
210
  } catch {}
211
  if (live.has(entry.name)) continue;
212
  let newest = 0;
213
  try {
214
- newest = Math.max(fs.statSync(dir).mtimeMs, ...fs.readdirSync(dir).map((name) => fs.lstatSync(path.join(dir, name)).mtimeMs));
 
 
215
  } catch { continue; }
216
- if (newest < cutoff) fs.rmSync(dir, { recursive: true, force: true });
217
  }
218
  }
219
 
220
  const quotePath = (filePath) => JSON.stringify(filePath);
221
 
 
 
 
 
 
 
222
  /** Keep CLI-version-specific formatting out of request handlers and React. */
223
  export function formatAttachmentDelivery(cli, text, images) {
224
  const prompt = String(text || '').trim()
 
6
  import { STATE_DIR } from './config.js';
7
 
8
  export const ATTACHMENT_LIMIT = 25 * 1024 * 1024;
9
+ // A single prompt is capped separately at five images. This lifetime cap keeps
10
+ // a forgotten session from growing without bound while still leaving room for
11
+ // many ordinary screenshot turns.
12
+ export const SESSION_ATTACHMENT_LIMIT = 500 * 1024 * 1024;
13
+ export const SESSION_ATTACHMENT_COUNT_LIMIT = 200;
14
  export const ATTACHMENT_ID = /^att_[a-f0-9]{24}$/;
15
  export const IMAGE_MIMES = Object.freeze([
16
  'image/png',
 
27
  'image/gif': 'gif',
28
  });
29
  const uploadWindows = new Map();
30
+ const uploadLocks = new Map();
31
 
32
  function httpError(statusCode, message) {
33
  const error = new Error(message);
 
53
  uploadWindows.set(sessionId, recent);
54
  }
55
 
56
+ // Serialize writes within one session so concurrent uploads cannot each pass a
57
+ // stale quota check. Different sessions still stream in parallel.
58
+ async function withUploadLock(sessionId, task) {
59
+ const previous = uploadLocks.get(sessionId) || Promise.resolve();
60
+ let release;
61
+ const hold = new Promise((resolve) => { release = resolve; });
62
+ const tail = previous.catch(() => {}).then(() => hold);
63
+ uploadLocks.set(sessionId, tail);
64
+ await previous.catch(() => {});
65
+ try {
66
+ return await task();
67
+ } finally {
68
+ release();
69
+ if (uploadLocks.get(sessionId) === tail) uploadLocks.delete(sessionId);
70
+ }
71
+ }
72
+
73
+ async function attachmentUsage(dir) {
74
+ let entries = [];
75
+ try { entries = await fs.promises.readdir(dir); } catch { return { bytes: 0, count: 0 }; }
76
+ let bytes = 0;
77
+ let count = 0;
78
+ for (const name of entries) {
79
+ try {
80
+ const stat = await fs.promises.lstat(path.join(dir, name));
81
+ if (!stat.isFile() || stat.isSymbolicLink()) continue;
82
+ bytes += stat.size;
83
+ if (ATTACHMENT_ID.test(path.parse(name).name)) count += 1;
84
+ } catch {}
85
+ }
86
+ return { bytes, count };
87
+ }
88
+
89
  export function detectImageMime(bytes) {
90
  if (bytes.length >= 8
91
  && bytes[0] === 0x89 && bytes.subarray(1, 4).toString('ascii') === 'PNG'
 
147
 
148
  /** Stream one browser image into the session-owned attachment store. */
149
  export async function receiveImage(readable, sessionId, contentType) {
150
+ // Browser MIME metadata is advisory. Clipboard implementations commonly
151
+ // omit it or use aliases such as image/jpg; the byte signature below is the
152
+ // security boundary and determines the stored extension and response type.
153
+ void contentType;
154
  checkUploadRate(sessionId);
155
+ return withUploadLock(sessionId, async () => {
156
+ const dir = sessionDir(sessionId);
157
+ await fs.promises.mkdir(dir, { recursive: true });
158
+ const usage = await attachmentUsage(dir);
159
+ if (usage.count >= SESSION_ATTACHMENT_COUNT_LIMIT) {
160
+ throw httpError(413, `this session already has ${SESSION_ATTACHMENT_COUNT_LIMIT} screenshots`);
161
+ }
162
+ const id = `att_${crypto.randomBytes(12).toString('hex')}`;
163
+ const temporary = path.join(dir, `.${id}.${crypto.randomBytes(4).toString('hex')}.part`);
164
+ let bytes = 0;
165
+ const limiter = new Transform({
166
+ transform(chunk, _encoding, callback) {
167
+ bytes += chunk.length;
168
+ if (bytes > ATTACHMENT_LIMIT) return callback(httpError(413, 'image is larger than 25 MB'));
169
+ if (usage.bytes + bytes > SESSION_ATTACHMENT_LIMIT) {
170
+ return callback(httpError(413, 'this session has reached its 500 MB screenshot limit'));
171
+ }
172
+ callback(null, chunk);
173
+ },
174
+ });
175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  try {
177
+ await pipeline(readable, limiter, fs.createWriteStream(temporary, { flags: 'wx' }));
178
+ if (bytes === 0) throw httpError(413, 'image is empty');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
 
180
+ const handle = await fs.promises.open(temporary, 'r');
181
+ const header = Buffer.alloc(32);
182
+ const tail = Buffer.alloc(Math.min(16, bytes));
183
+ let bytesRead = 0;
184
+ let tailBytesRead = 0;
185
+ try {
186
+ ({ bytesRead } = await handle.read(header, 0, header.length, 0));
187
+ ({ bytesRead: tailBytesRead } = await handle.read(tail, 0, tail.length, Math.max(0, bytes - tail.length)));
188
+ } finally {
189
+ await handle.close();
190
+ }
191
+ const headerBytes = header.subarray(0, bytesRead);
192
+ const tailBytes = tail.subarray(0, tailBytesRead);
193
+ const detected = detectImageMime(headerBytes);
194
+ if (!detected) throw httpError(415, 'file is not a supported raster image');
195
+ if (!imageEnvelopeIsValid(detected, headerBytes, tailBytes, bytes)) {
196
+ throw httpError(415, 'image is truncated or malformed');
197
+ }
198
+
199
+ const finalPath = path.join(dir, `${id}.${EXTENSIONS[detected]}`);
200
+ await fs.promises.rename(temporary, finalPath);
201
+ return responseShape(sessionId, id, detected, bytes, finalPath);
202
+ } catch (error) {
203
+ await fs.promises.unlink(temporary).catch(() => {});
204
+ throw error;
205
+ }
206
+ });
207
  }
208
 
209
  /** Resolve an untrusted attachment id within exactly one session. */
 
228
  return attachmentIds.map((id) => resolveImage(sessionId, id));
229
  }
230
 
231
+ export async function removeSessionAttachments(sessionId) {
232
  uploadWindows.delete(sessionId);
233
+ await withUploadLock(sessionId, () => fs.promises.rm(sessionDir(sessionId), { recursive: true, force: true }));
234
  }
235
 
236
  /** Remove only old orphan stores; recent crash leftovers keep a seven-day grace. */
237
+ export async function pruneAttachmentDirs(sessionIds, now = Date.now()) {
238
  const live = new Set(sessionIds);
239
  const cutoff = now - 7 * 24 * 60 * 60 * 1000;
240
  let entries = [];
241
+ try { entries = await fs.promises.readdir(ATTACHMENTS_DIR, { withFileTypes: true }); } catch { return; }
242
  for (const entry of entries) {
243
  if (!entry.isDirectory()) continue;
244
  const dir = path.join(ATTACHMENTS_DIR, entry.name);
 
246
  // Normal request failures remove these immediately; this is the crash
247
  // backstop, with the same grace period as orphan session directories.
248
  try {
249
+ for (const name of await fs.promises.readdir(dir)) {
250
  const part = path.join(dir, name);
251
+ const stat = await fs.promises.lstat(part);
252
+ if (name.endsWith('.part') && stat.isFile() && !stat.isSymbolicLink() && stat.mtimeMs < cutoff) {
253
+ await fs.promises.unlink(part);
254
  }
255
  }
256
  } catch {}
257
  if (live.has(entry.name)) continue;
258
  let newest = 0;
259
  try {
260
+ const names = await fs.promises.readdir(dir);
261
+ const stats = await Promise.all(names.map((name) => fs.promises.lstat(path.join(dir, name))));
262
+ newest = Math.max((await fs.promises.stat(dir)).mtimeMs, ...stats.map((stat) => stat.mtimeMs));
263
  } catch { continue; }
264
+ if (newest < cutoff) await fs.promises.rm(dir, { recursive: true, force: true });
265
  }
266
  }
267
 
268
  const quotePath = (filePath) => JSON.stringify(filePath);
269
 
270
+ /** TUI commands that attach native image context before the textual prompt. */
271
+ export function formatAttachmentPrelude(cli, images) {
272
+ if (cli !== 'hermes') return [];
273
+ return images.map((image) => `/image ${quotePath(image.path)}`);
274
+ }
275
+
276
  /** Keep CLI-version-specific formatting out of request handlers and React. */
277
  export function formatAttachmentDelivery(cli, text, images) {
278
  const prompt = String(text || '').trim()
server/src/config.js CHANGED
@@ -84,7 +84,9 @@ export const CLIS = [
84
  withPrompt: (q) => `claude ${q}`,
85
  setup: setupHint('ANTHROPIC_API_KEY') },
86
  { id: 'codex', label: 'Codex', bin: 'codex', color: '#5eb6a6', run: 'codex', cont: 'codex resume --last', resizeMode: 'repaint',
87
- withPrompt: (q) => `codex ${q}`,
 
 
88
  setup: setupHint('OPENAI_API_KEY') },
89
  { id: 'gemini', label: 'Gemini CLI', bin: 'gemini', color: '#4796e3', run: 'gemini', cont: null, resizeMode: 'repaint',
90
  withPrompt: (q) => `gemini -i ${q}`, // -i = interactive session seeded with the prompt
 
84
  withPrompt: (q) => `claude ${q}`,
85
  setup: setupHint('ANTHROPIC_API_KEY') },
86
  { id: 'codex', label: 'Codex', bin: 'codex', color: '#5eb6a6', run: 'codex', cont: 'codex resume --last', resizeMode: 'repaint',
87
+ // `q` and image paths arrive shell-quoted from runner.commandFor(). Repeat
88
+ // -i because Codex's variadic flag would otherwise consume the prompt.
89
+ withPrompt: (q, images = []) => `codex${images.length ? ` ${images.map((image) => `-i ${image}`).join(' ')}` : ''} ${q}`,
90
  setup: setupHint('OPENAI_API_KEY') },
91
  { id: 'gemini', label: 'Gemini CLI', bin: 'gemini', color: '#4796e3', run: 'gemini', cont: null, resizeMode: 'repaint',
92
  withPrompt: (q) => `gemini -i ${q}`, // -i = interactive session seeded with the prompt
server/src/index.js CHANGED
@@ -16,7 +16,7 @@ import * as store from './sessions.js';
16
  import * as groups from './groups.js';
17
  import * as order from './order.js';
18
  import * as demo from './demo.js';
19
- import { attach, agentInfo, deriveState, stop, stopAll, ensureRunning, sendInput, isRunning, capturePane, ghosttyReady, ghosttyError, installClaudeRepinHook } from './runner.js';
20
 
21
  // Control frames ride the terminal socket behind a leading NUL pair, which real
22
  // PTY output never begins with. Same sentinel the old copy-mode hint used, so the
@@ -32,14 +32,15 @@ import { shareSession, shareNamespace, findTrace, shareAccess, grantAccess, revo
32
  importBundle, listBundles, SHAREABLE_CLIS } from './share.js';
33
  import * as backup from './backup.js';
34
  import {
35
- formatAttachmentDelivery, pruneAttachmentDirs, receiveImage, removeSessionAttachments,
36
  resolveImage, resolveImages,
37
  } from './attachments.js';
38
 
39
  ensureDirs();
40
  refreshVersions();
41
  store.init();
42
- pruneAttachmentDirs(store.list().map((session) => session.id));
 
43
  groups.init();
44
  order.init();
45
  demo.init();
@@ -155,7 +156,14 @@ process.on('unhandledRejection', (e) => console.error('[unhandledRejection]', e)
155
  process.on('uncaughtException', (e) => console.error('[uncaughtException]', e));
156
 
157
  const app = express();
158
- app.use(express.json());
 
 
 
 
 
 
 
159
 
160
  // Safety lock: with no authentication, only serve the terminal backend when the
161
  // Space is private. If it's public, block every working API (and /ws below) and
@@ -264,16 +272,24 @@ async function deliver(session, { text, attachments = [] }, from) {
264
  return false;
265
  }
266
  const prompt = formatAttachmentDelivery(session.cli, text, attachments);
 
267
  // A session created before its first prompt can still use the CLI's launch
268
  // argument. This is especially important for quickstart attachments: upload
269
  // needs a session id first, but typing into a half-booted TUI loses turns.
270
  const cli = cliById(session.cli);
271
- if (!session.everStarted && cli?.withPrompt) {
272
- store.update(session.id, { pendingPrompt: prompt });
 
 
 
273
  return ensureRunning(store.get(session.id) || session);
274
  }
275
  const started = ensureRunning(session);
276
  if (started) await sleep(3500); // let the CLI boot before the keystrokes land
 
 
 
 
277
  await sendInput(session.id, prompt);
278
  return started;
279
  }
@@ -299,6 +315,10 @@ app.post('/api/sessions/:id/input', async (req, res) => {
299
 
300
  const canAttachImages = (session) => session.cli !== 'shell'
301
  && !PASSIVE_CLIS.includes(session.cli) && !isRemote(session.cli);
 
 
 
 
302
 
303
  // Managed screenshots live under STATE_DIR, never in the user's repository.
304
  // The raw body is streamed and capped in attachments.js; express.json ignores
@@ -320,6 +340,39 @@ app.post('/api/sessions/:id/attachments', async (req, res) => {
320
  }
321
  });
322
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  app.get('/api/sessions/:id/attachments/:attachmentId/raw', (req, res) => {
324
  const s = store.get(req.params.id);
325
  if (!s) return res.status(404).json({ error: 'not found' });
@@ -2065,7 +2118,7 @@ app.put('/api/trace/:id/source', (req, res) => {
2065
  res.json({ ok: true, traceSource: { kind, ref } });
2066
  });
2067
 
2068
- app.delete('/api/sessions/:id', (req, res) => {
2069
  const s = store.get(req.params.id);
2070
  if (!s) return res.status(404).json({ error: 'not found' });
2071
  stop(s.id);
@@ -2076,7 +2129,8 @@ app.delete('/api/sessions/:id', (req, res) => {
2076
  groups.detachSession(s.id);
2077
  order.drop(`s:${s.id}`);
2078
  store.remove(s.id);
2079
- try { removeSessionAttachments(s.id); } catch (e) { console.error('[attachments.remove]', e && e.message); }
 
2080
  res.json({ ok: true });
2081
  });
2082
 
 
16
  import * as groups from './groups.js';
17
  import * as order from './order.js';
18
  import * as demo from './demo.js';
19
+ import { attach, agentInfo, deriveState, stop, stopAll, ensureRunning, sendInput, pasteInput, isRunning, capturePane, ghosttyReady, ghosttyError, installClaudeRepinHook } from './runner.js';
20
 
21
  // Control frames ride the terminal socket behind a leading NUL pair, which real
22
  // PTY output never begins with. Same sentinel the old copy-mode hint used, so the
 
32
  importBundle, listBundles, SHAREABLE_CLIS } from './share.js';
33
  import * as backup from './backup.js';
34
  import {
35
+ formatAttachmentDelivery, formatAttachmentPrelude, pruneAttachmentDirs, receiveImage, removeSessionAttachments,
36
  resolveImage, resolveImages,
37
  } from './attachments.js';
38
 
39
  ensureDirs();
40
  refreshVersions();
41
  store.init();
42
+ pruneAttachmentDirs(store.list().map((session) => session.id))
43
+ .catch((e) => console.error('[attachments.prune]', e && e.message));
44
  groups.init();
45
  order.init();
46
  demo.init();
 
156
  process.on('uncaughtException', (e) => console.error('[uncaughtException]', e));
157
 
158
  const app = express();
159
+ const jsonBody = express.json();
160
+ app.use((req, res, next) => {
161
+ // The attachment route determines type from bytes. Skip JSON parsing even
162
+ // when untrusted browser metadata falsely claims application/json, or the
163
+ // global parser would reject valid PNG bytes before the streaming route.
164
+ if (req.method === 'POST' && /^\/api\/sessions\/[^/]+\/attachments$/.test(req.path)) return next();
165
+ return jsonBody(req, res, next);
166
+ });
167
 
168
  // Safety lock: with no authentication, only serve the terminal backend when the
169
  // Space is private. If it's public, block every working API (and /ws below) and
 
272
  return false;
273
  }
274
  const prompt = formatAttachmentDelivery(session.cli, text, attachments);
275
+ const prelude = formatAttachmentPrelude(session.cli, attachments);
276
  // A session created before its first prompt can still use the CLI's launch
277
  // argument. This is especially important for quickstart attachments: upload
278
  // needs a session id first, but typing into a half-booted TUI loses turns.
279
  const cli = cliById(session.cli);
280
+ if (!session.everStarted && cli?.withPrompt && prelude.length === 0) {
281
+ store.update(session.id, {
282
+ pendingPrompt: prompt,
283
+ pendingImagePaths: session.cli === 'codex' ? attachments.map((image) => image.path) : undefined,
284
+ });
285
  return ensureRunning(store.get(session.id) || session);
286
  }
287
  const started = ensureRunning(session);
288
  if (started) await sleep(3500); // let the CLI boot before the keystrokes land
289
+ for (const command of prelude) {
290
+ await sendInput(session.id, command);
291
+ await sleep(500);
292
+ }
293
  await sendInput(session.id, prompt);
294
  return started;
295
  }
 
315
 
316
  const canAttachImages = (session) => session.cli !== 'shell'
317
  && !PASSIVE_CLIS.includes(session.cli) && !isRemote(session.cli);
318
+ // A lost HTTP response must not make the Retry action paste the same path a
319
+ // second time. Attachment ids are unique and the terminal UI never intentionally
320
+ // inserts one twice, so this bounded per-session set is a natural idempotency key.
321
+ const terminalAttachmentInsertions = new Map();
322
 
323
  // Managed screenshots live under STATE_DIR, never in the user's repository.
324
  // The raw body is streamed and capped in attachments.js; express.json ignores
 
340
  }
341
  });
342
 
343
+ // Insert terminal attachments without pressing Return on the operator's
344
+ // prompt. This server acknowledgement is the source of truth for the terminal
345
+ // overlay; a browser-local xterm paste can be dropped after a disconnect or
346
+ // when another viewer owns the input lease.
347
+ app.post('/api/sessions/:id/attachments/insert', async (req, res) => {
348
+ const s = store.get(req.params.id);
349
+ if (!s) return res.status(404).json({ error: 'not found' });
350
+ if (!canAttachImages(s)) return res.status(400).json({ error: `${s.cli} pane cannot accept screenshots` });
351
+ try {
352
+ const attachments = resolveImages(s.id, (req.body || {}).attachmentIds ?? []);
353
+ if (!attachments.length) return res.status(400).json({ error: 'no screenshots to insert' });
354
+ const inserted = terminalAttachmentInsertions.get(s.id) || new Set();
355
+ const pending = attachments.filter((image) => !inserted.has(image.id));
356
+ const mode = s.cli === 'hermes' ? 'attached' : 'inserted';
357
+ if (!pending.length) return res.json({ ok: true, mode, repeated: true });
358
+ terminalAttachmentInsertions.set(s.id, inserted);
359
+ const prelude = formatAttachmentPrelude(s.cli, pending);
360
+ if (prelude.length) {
361
+ for (let index = 0; index < prelude.length; index += 1) {
362
+ await sendInput(s.id, prelude[index]);
363
+ inserted.add(pending[index].id);
364
+ await sleep(500);
365
+ }
366
+ } else {
367
+ pasteInput(s.id, pending.map((image) => image.insertText).join(''));
368
+ for (const image of pending) inserted.add(image.id);
369
+ }
370
+ return res.json({ ok: true, mode });
371
+ } catch (e) {
372
+ return res.status(e.statusCode || 409).json({ error: String(e.message || e) });
373
+ }
374
+ });
375
+
376
  app.get('/api/sessions/:id/attachments/:attachmentId/raw', (req, res) => {
377
  const s = store.get(req.params.id);
378
  if (!s) return res.status(404).json({ error: 'not found' });
 
2118
  res.json({ ok: true, traceSource: { kind, ref } });
2119
  });
2120
 
2121
+ app.delete('/api/sessions/:id', async (req, res) => {
2122
  const s = store.get(req.params.id);
2123
  if (!s) return res.status(404).json({ error: 'not found' });
2124
  stop(s.id);
 
2129
  groups.detachSession(s.id);
2130
  order.drop(`s:${s.id}`);
2131
  store.remove(s.id);
2132
+ terminalAttachmentInsertions.delete(s.id);
2133
+ try { await removeSessionAttachments(s.id); } catch (e) { console.error('[attachments.remove]', e && e.message); }
2134
  res.json({ ok: true });
2135
  });
2136
 
server/src/runner.js CHANGED
@@ -104,6 +104,7 @@ const MAX_COLS = 1000;
104
  const MAX_ROWS = 500;
105
 
106
  const hosts = new Map(); // session id -> host
 
107
 
108
  function djb2(s) {
109
  let h = 5381;
@@ -112,7 +113,7 @@ function djb2(s) {
112
  }
113
 
114
  export function isRunning(id) {
115
- return hosts.has(id);
116
  }
117
 
118
  export function ghosttyReady() {
@@ -1168,6 +1169,9 @@ export function commandFor(session) {
1168
  // (claude 'p', codex 'p', gemini -i 'p', opencode --prompt 'p') — the CLI
1169
  // starts already working on it, no typing race against a booting TUI.
1170
  const q0 = !session.everStarted && session.pendingPrompt ? shq(session.pendingPrompt) : '';
 
 
 
1171
 
1172
  // Claude keys conversations by working directory, so grouped sessions sharing
1173
  // a folder would all `--continue` onto the SAME most-recent conversation. Pin
@@ -1238,7 +1242,7 @@ export function commandFor(session) {
1238
  const shared = list().some((o) => o.id !== session.id && o.cli === 'opencode' && (o.path ?? o.id) === folder);
1239
  const base = session.everStarted && cli.cont && !shared
1240
  ? `${cli.cont} || exec ${cli.run}`
1241
- : `exec ${q0 && cli.withPrompt ? cli.withPrompt(q0) : cli.run}`;
1242
  return `${guard}${base}`;
1243
  }
1244
 
@@ -1256,7 +1260,7 @@ export function commandFor(session) {
1256
  // the agent is the PTY's foreground process; when it exits the session ends —
1257
  // a clear "done" signal — and the fallback preserves that.
1258
  if (session.everStarted && cli.cont) return `${cli.cont} || exec ${cli.run}`;
1259
- if (q0 && cli.withPrompt) return `exec ${cli.withPrompt(q0)}`;
1260
  return `exec ${cli.run}`;
1261
  }
1262
 
@@ -1397,6 +1401,7 @@ export function ensureRunning(session, cols = 120, rows = 34) {
1397
 
1398
  term.onExit(() => {
1399
  hosts.delete(session.id);
 
1400
  if (host.gridTimer) { clearTimeout(host.gridTimer); host.gridTimer = null; }
1401
  if (host.traceHistoryTimer) { clearTimeout(host.traceHistoryTimer); host.traceHistoryTimer = null; }
1402
  if (host.resizeCapture) {
@@ -1411,8 +1416,11 @@ export function ensureRunning(session, cols = 120, rows = 34) {
1411
  });
1412
 
1413
  hosts.set(session.id, host);
 
1414
  if (!persistedHistory && captureResize) hydrateTraceHistory(session, host);
1415
- if (!session.everStarted) update(session.id, { everStarted: true, pendingPrompt: undefined });
 
 
1416
  if (session.cli === 'codex') scheduleCodexCapture(session, workdir);
1417
  if (session.cli === 'opencode') scheduleOpencodeCapture(session, workdir);
1418
  if (session.cli === 'claude') scheduleClaudeCapture(session, workdir);
@@ -1496,7 +1504,7 @@ export function attach(session, cols, rows) {
1496
  /** Type a line into the session's terminal (works with no browser attached). */
1497
  export async function sendInput(id, text) {
1498
  const host = hosts.get(id);
1499
- if (!host) throw new Error('session is not running');
1500
  // Multi-line prompts go in as a bracketed paste so the CLI's composer treats
1501
  // the inner newlines as soft line breaks instead of submitting early.
1502
  const payload = text.includes('\n') ? `\x1b[200~${text}\x1b[201~` : text;
@@ -1508,6 +1516,16 @@ export async function sendInput(id, text) {
1508
  host.pty.write('\r');
1509
  }
1510
 
 
 
 
 
 
 
 
 
 
 
1511
  /**
1512
  * The session's rendered screen plus `lines` of scrollback above it — what a
1513
  * human would see in the pane. Used by the agent API so one agent can watch
@@ -1534,6 +1552,7 @@ export function capturePane(id, lines = 80) {
1534
  export function stop(id) {
1535
  const host = hosts.get(id);
1536
  if (!host) return;
 
1537
  try { host.pty.kill(); } catch {}
1538
  }
1539
 
@@ -1543,6 +1562,7 @@ export function stop(id) {
1543
  */
1544
  export function stopAll() {
1545
  for (const host of hosts.values()) {
 
1546
  try { host.pty.kill(); } catch {}
1547
  }
1548
  }
 
104
  const MAX_ROWS = 500;
105
 
106
  const hosts = new Map(); // session id -> host
107
+ const stopping = new Set();
108
 
109
  function djb2(s) {
110
  let h = 5381;
 
113
  }
114
 
115
  export function isRunning(id) {
116
+ return hosts.has(id) && !stopping.has(id);
117
  }
118
 
119
  export function ghosttyReady() {
 
1169
  // (claude 'p', codex 'p', gemini -i 'p', opencode --prompt 'p') — the CLI
1170
  // starts already working on it, no typing race against a booting TUI.
1171
  const q0 = !session.everStarted && session.pendingPrompt ? shq(session.pendingPrompt) : '';
1172
+ const q0Images = !session.everStarted && Array.isArray(session.pendingImagePaths)
1173
+ ? session.pendingImagePaths.map((image) => shq(String(image))) : [];
1174
+ const firstCommand = () => cli.withPrompt(q0, q0Images);
1175
 
1176
  // Claude keys conversations by working directory, so grouped sessions sharing
1177
  // a folder would all `--continue` onto the SAME most-recent conversation. Pin
 
1242
  const shared = list().some((o) => o.id !== session.id && o.cli === 'opencode' && (o.path ?? o.id) === folder);
1243
  const base = session.everStarted && cli.cont && !shared
1244
  ? `${cli.cont} || exec ${cli.run}`
1245
+ : `exec ${q0 && cli.withPrompt ? firstCommand() : cli.run}`;
1246
  return `${guard}${base}`;
1247
  }
1248
 
 
1260
  // the agent is the PTY's foreground process; when it exits the session ends —
1261
  // a clear "done" signal — and the fallback preserves that.
1262
  if (session.everStarted && cli.cont) return `${cli.cont} || exec ${cli.run}`;
1263
+ if (q0 && cli.withPrompt) return `exec ${firstCommand()}`;
1264
  return `exec ${cli.run}`;
1265
  }
1266
 
 
1401
 
1402
  term.onExit(() => {
1403
  hosts.delete(session.id);
1404
+ stopping.delete(session.id);
1405
  if (host.gridTimer) { clearTimeout(host.gridTimer); host.gridTimer = null; }
1406
  if (host.traceHistoryTimer) { clearTimeout(host.traceHistoryTimer); host.traceHistoryTimer = null; }
1407
  if (host.resizeCapture) {
 
1416
  });
1417
 
1418
  hosts.set(session.id, host);
1419
+ stopping.delete(session.id);
1420
  if (!persistedHistory && captureResize) hydrateTraceHistory(session, host);
1421
+ if (!session.everStarted) update(session.id, {
1422
+ everStarted: true, pendingPrompt: undefined, pendingImagePaths: undefined,
1423
+ });
1424
  if (session.cli === 'codex') scheduleCodexCapture(session, workdir);
1425
  if (session.cli === 'opencode') scheduleOpencodeCapture(session, workdir);
1426
  if (session.cli === 'claude') scheduleClaudeCapture(session, workdir);
 
1504
  /** Type a line into the session's terminal (works with no browser attached). */
1505
  export async function sendInput(id, text) {
1506
  const host = hosts.get(id);
1507
+ if (!host || stopping.has(id)) throw new Error('session is not running');
1508
  // Multi-line prompts go in as a bracketed paste so the CLI's composer treats
1509
  // the inner newlines as soft line breaks instead of submitting early.
1510
  const payload = text.includes('\n') ? `\x1b[200~${text}\x1b[201~` : text;
 
1516
  host.pty.write('\r');
1517
  }
1518
 
1519
+ /** Insert text into a running terminal's composer without submitting it. */
1520
+ export function pasteInput(id, text) {
1521
+ const host = hosts.get(id);
1522
+ if (!host || stopping.has(id)) throw new Error('session is not running');
1523
+ const value = String(text || '');
1524
+ if (!value) return;
1525
+ const payload = value.includes('\n') ? `\x1b[200~${value}\x1b[201~` : value;
1526
+ host.pty.write(payload);
1527
+ }
1528
+
1529
  /**
1530
  * The session's rendered screen plus `lines` of scrollback above it — what a
1531
  * human would see in the pane. Used by the agent API so one agent can watch
 
1552
  export function stop(id) {
1553
  const host = hosts.get(id);
1554
  if (!host) return;
1555
+ stopping.add(id);
1556
  try { host.pty.kill(); } catch {}
1557
  }
1558
 
 
1562
  */
1563
  export function stopAll() {
1564
  for (const host of hosts.values()) {
1565
+ stopping.add(host.id);
1566
  try { host.pty.kill(); } catch {}
1567
  }
1568
  }
server/terminal-ui.test.mjs CHANGED
@@ -102,7 +102,13 @@ try {
102
  }));
103
  await sleep(600);
104
 
105
- browser = await chromium.launch({ headless: true });
 
 
 
 
 
 
106
  const context = await browser.newContext({
107
  viewport: { width: 1280, height: 800 },
108
  permissions: ['clipboard-read', 'clipboard-write'],
 
102
  }));
103
  await sleep(600);
104
 
105
+ const bakedChromium = '/opt/pw-browsers/chromium-1208/chrome-linux64/chrome';
106
+ const chromiumExecutable = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
107
+ || (fs.existsSync(bakedChromium) ? bakedChromium : undefined);
108
+ browser = await chromium.launch({
109
+ headless: true,
110
+ ...(chromiumExecutable ? { executablePath: chromiumExecutable } : {}),
111
+ });
112
  const context = await browser.newContext({
113
  viewport: { width: 1280, height: 800 },
114
  permissions: ['clipboard-read', 'clipboard-write'],
server/test/attachments.test.mjs CHANGED
@@ -8,9 +8,12 @@ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'am-attachments-'));
8
  process.env.DATA_DIR = root;
9
 
10
  const {
11
- detectImageMime, formatAttachmentDelivery, pruneAttachmentDirs, receiveImage, removeSessionAttachments,
12
- resolveImage, resolveImages,
 
13
  } = await import('../src/attachments.js');
 
 
14
 
15
  const png = Buffer.alloc(45);
16
  Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(png);
@@ -43,10 +46,10 @@ try {
43
  assert.throws(() => resolveImage('codex-123abc', '../sessions.json'), /not found/);
44
  assert.throws(() => resolveImages('codex-123abc', Array(6).fill(stored.id)), /at most five/);
45
 
46
- await assert.rejects(
47
- receiveImage(Readable.from([png]), 'codex-123abc', 'image/jpeg'),
48
- (error) => error.statusCode === 415,
49
- );
50
  await assert.rejects(
51
  receiveImage(Readable.from([png.subarray(0, 24)]), 'codex-123abc', 'image/png'),
52
  (error) => error.statusCode === 415 && /malformed/.test(error.message),
@@ -55,12 +58,66 @@ try {
55
  receiveImage(Readable.from([]), 'codex-123abc', 'image/png'),
56
  (error) => error.statusCode === 413,
57
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
  const formatted = formatAttachmentDelivery('codex', 'Compare this', [stored]);
60
  assert.match(formatted, /Compare this/);
61
  assert.match(formatted, /Attached screenshots:/);
62
  assert.match(formatted, new RegExp(stored.id));
63
  assert.match(formatAttachmentDelivery('gemini', '', [stored]), /Please inspect the attached screenshot\./);
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
  const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
66
  const oldPart = path.join(path.dirname(stored.path), '.crashed.part');
@@ -71,11 +128,11 @@ try {
71
  fs.writeFileSync(path.join(orphan, 'old'), 'old');
72
  fs.utimesSync(path.join(orphan, 'old'), old, old);
73
  fs.utimesSync(orphan, old, old);
74
- pruneAttachmentDirs(['codex-123abc']);
75
  assert.equal(fs.existsSync(oldPart), false);
76
  assert.equal(fs.existsSync(orphan), false);
77
 
78
- removeSessionAttachments('codex-123abc');
79
  assert.equal(fs.existsSync(path.dirname(stored.path)), false);
80
  console.log('attachment tests passed');
81
  } finally {
 
8
  process.env.DATA_DIR = root;
9
 
10
  const {
11
+ ATTACHMENT_LIMIT, SESSION_ATTACHMENT_LIMIT, detectImageMime, formatAttachmentDelivery,
12
+ formatAttachmentPrelude, pruneAttachmentDirs, receiveImage, removeSessionAttachments, resolveImage,
13
+ resolveImages,
14
  } = await import('../src/attachments.js');
15
+ const { cliById } = await import('../src/config.js');
16
+ const { commandFor } = await import('../src/runner.js');
17
 
18
  const png = Buffer.alloc(45);
19
  Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(png);
 
46
  assert.throws(() => resolveImage('codex-123abc', '../sessions.json'), /not found/);
47
  assert.throws(() => resolveImages('codex-123abc', Array(6).fill(stored.id)), /at most five/);
48
 
49
+ const mislabeled = await receiveImage(Readable.from([png]), 'codex-123abc', 'image/jpeg');
50
+ assert.equal(mislabeled.mime, 'image/png');
51
+ const untyped = await receiveImage(Readable.from([png]), 'codex-123abc', '');
52
+ assert.equal(untyped.mime, 'image/png');
53
  await assert.rejects(
54
  receiveImage(Readable.from([png.subarray(0, 24)]), 'codex-123abc', 'image/png'),
55
  (error) => error.statusCode === 415 && /malformed/.test(error.message),
 
58
  receiveImage(Readable.from([]), 'codex-123abc', 'image/png'),
59
  (error) => error.statusCode === 413,
60
  );
61
+ await assert.rejects(
62
+ receiveImage(Readable.from([Buffer.alloc(ATTACHMENT_LIMIT + 1)]), 'large-123abc', 'image/png'),
63
+ (error) => error.statusCode === 413 && /25 MB/.test(error.message),
64
+ );
65
+ assert.equal(fs.readdirSync(path.join(root, 'state', 'attachments', 'large-123abc')).some((name) => name.endsWith('.part')), false);
66
+
67
+ const aborted = new Readable({
68
+ read() {
69
+ this.push(png.subarray(0, 20));
70
+ this.destroy(new Error('request aborted'));
71
+ },
72
+ });
73
+ await assert.rejects(receiveImage(aborted, 'aborted-123abc', 'image/png'), /request aborted/);
74
+ assert.equal(fs.readdirSync(path.join(root, 'state', 'attachments', 'aborted-123abc')).some((name) => name.endsWith('.part')), false);
75
+
76
+ const quotaDir = path.join(root, 'state', 'attachments', 'quota-123abc');
77
+ fs.mkdirSync(quotaDir, { recursive: true });
78
+ const quotaFile = path.join(quotaDir, 'existing.bin');
79
+ fs.writeFileSync(quotaFile, '');
80
+ fs.truncateSync(quotaFile, SESSION_ATTACHMENT_LIMIT);
81
+ await assert.rejects(
82
+ receiveImage(Readable.from([png]), 'quota-123abc', 'image/png'),
83
+ (error) => error.statusCode === 413 && /500 MB/.test(error.message),
84
+ );
85
+
86
+ const raceQuotaDir = path.join(root, 'state', 'attachments', 'race-quota-123abc');
87
+ fs.mkdirSync(raceQuotaDir, { recursive: true });
88
+ const raceQuotaFile = path.join(raceQuotaDir, 'existing.bin');
89
+ fs.writeFileSync(raceQuotaFile, '');
90
+ fs.truncateSync(raceQuotaFile, SESSION_ATTACHMENT_LIMIT - png.length);
91
+ const raced = await Promise.allSettled([
92
+ receiveImage(Readable.from([png]), 'race-quota-123abc', 'image/png'),
93
+ receiveImage(Readable.from([png]), 'race-quota-123abc', 'image/png'),
94
+ ]);
95
+ assert.equal(raced.filter((result) => result.status === 'fulfilled').length, 1);
96
+ assert.equal(raced.filter((result) => result.status === 'rejected'
97
+ && result.reason.statusCode === 413).length, 1);
98
+
99
+ const concurrent = await Promise.all(Array.from({ length: 4 }, () =>
100
+ receiveImage(Readable.from([png]), 'parallel-123abc', 'application/octet-stream')));
101
+ assert.equal(new Set(concurrent.map((image) => image.id)).size, concurrent.length);
102
 
103
  const formatted = formatAttachmentDelivery('codex', 'Compare this', [stored]);
104
  assert.match(formatted, /Compare this/);
105
  assert.match(formatted, /Attached screenshots:/);
106
  assert.match(formatted, new RegExp(stored.id));
107
  assert.match(formatAttachmentDelivery('gemini', '', [stored]), /Please inspect the attached screenshot\./);
108
+ assert.deepEqual(formatAttachmentPrelude('hermes', [stored]), [`/image ${JSON.stringify(stored.path)}`]);
109
+ assert.deepEqual(formatAttachmentPrelude('codex', [stored]), []);
110
+ assert.equal(
111
+ cliById('codex').withPrompt("'compare both'", ["'/tmp/first image.png'", "'/tmp/second.png'"]),
112
+ "codex -i '/tmp/first image.png' -i '/tmp/second.png' 'compare both'",
113
+ );
114
+ assert.equal(
115
+ commandFor({
116
+ id: 'codex-first-image', cli: 'codex', everStarted: false,
117
+ pendingPrompt: 'compare both', pendingImagePaths: ['/tmp/first image.png'],
118
+ }),
119
+ "exec codex -i '/tmp/first image.png' 'compare both'",
120
+ );
121
 
122
  const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
123
  const oldPart = path.join(path.dirname(stored.path), '.crashed.part');
 
128
  fs.writeFileSync(path.join(orphan, 'old'), 'old');
129
  fs.utimesSync(path.join(orphan, 'old'), old, old);
130
  fs.utimesSync(orphan, old, old);
131
+ await pruneAttachmentDirs(['codex-123abc']);
132
  assert.equal(fs.existsSync(oldPart), false);
133
  assert.equal(fs.existsSync(orphan), false);
134
 
135
+ await removeSessionAttachments('codex-123abc');
136
  assert.equal(fs.existsSync(path.dirname(stored.path)), false);
137
  console.log('attachment tests passed');
138
  } finally {
web/src/App.tsx CHANGED
@@ -520,7 +520,10 @@ export default function App() {
520
  await refresh();
521
  setActiveRef(`s:${sessionId}`);
522
  } catch (e) {
523
- showErr('Couldn’t quickstart the agent')(e);
 
 
 
524
  throw e;
525
  }
526
  };
 
520
  await refresh();
521
  setActiveRef(`s:${sessionId}`);
522
  } catch (e) {
523
+ // Quickstart owns a persistent inline recovery state (including the
524
+ // stopped session created before an upload), so a second generic toast
525
+ // obscures the useful error and makes one failure look like two.
526
+ console.error('Couldn’t quickstart the agent', e);
527
  throw e;
528
  }
529
  };
web/src/api.ts CHANGED
@@ -190,17 +190,26 @@ export interface ImageAttachment {
190
  }
191
 
192
  export const uploadImageAttachment = async (id: string, file: File): Promise<ImageAttachment> => {
 
 
 
 
193
  const response = await fetch(`/api/sessions/${id}/attachments`, {
194
  method: 'POST',
195
- headers: {
196
- 'content-type': file.type,
197
- 'x-file-name': encodeURIComponent(file.name || 'Screenshot'),
198
- },
199
  body: file,
200
  });
201
  return jsonOrError(response);
202
  };
203
 
 
 
 
 
 
 
 
 
204
  export const sendInput = (id: string, text: string, attachmentIds: string[] = []): Promise<{ ok: boolean; started?: boolean }> =>
205
  fetch(`/api/sessions/${id}/input`, { method: 'POST', headers: HEADERS, body: JSON.stringify({ text, attachmentIds }) }).then(jsonOrError);
206
 
 
190
  }
191
 
192
  export const uploadImageAttachment = async (id: string, file: File): Promise<ImageAttachment> => {
193
+ const headers: Record<string, string> = {
194
+ 'x-file-name': encodeURIComponent(file.name || 'Screenshot'),
195
+ };
196
+ if (file.type) headers['content-type'] = file.type;
197
  const response = await fetch(`/api/sessions/${id}/attachments`, {
198
  method: 'POST',
199
+ headers,
 
 
 
200
  body: file,
201
  });
202
  return jsonOrError(response);
203
  };
204
 
205
+ export const insertImageAttachments = (
206
+ id: string,
207
+ attachmentIds: string[],
208
+ ): Promise<{ ok: boolean; mode: 'inserted' | 'attached'; repeated?: boolean }> =>
209
+ fetch(`/api/sessions/${id}/attachments/insert`, {
210
+ method: 'POST', headers: HEADERS, body: JSON.stringify({ attachmentIds }),
211
+ }).then(jsonOrError);
212
+
213
  export const sendInput = (id: string, text: string, attachmentIds: string[] = []): Promise<{ ok: boolean; started?: boolean }> =>
214
  fetch(`/api/sessions/${id}/input`, { method: 'POST', headers: HEADERS, body: JSON.stringify({ text, attachmentIds }) }).then(jsonOrError);
215
 
web/src/components/ImageAttachments.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useRef } from 'react';
2
  import type { PendingImage } from '../lib/imageAttachments';
3
  import { IMAGE_ACCEPT } from '../lib/imageAttachments';
4
 
@@ -14,14 +14,17 @@ export default function ImageAttachments({ images, disabled, disabledReason, onF
14
  onRemove: (key: string) => void;
15
  }) {
16
  const picker = useRef<HTMLInputElement>(null);
 
 
17
  return (
18
- <div className={`image-attachments${images.length ? ' has-images' : ''}`}>
19
  <button
20
  type="button"
21
  className="image-pick"
22
  disabled={disabled}
23
  title={disabledReason || 'Attach screenshots'}
24
- aria-label={disabledReason || 'Attach screenshots'}
 
25
  onClick={() => picker.current?.click()}
26
  >
27
  <svg viewBox="0 0 18 18" aria-hidden="true">
@@ -42,17 +45,24 @@ export default function ImageAttachments({ images, disabled, disabledReason, onF
42
  event.currentTarget.value = '';
43
  }}
44
  />
 
45
  {images.map((image) => (
46
  <div key={image.key} className={`image-chip ${image.status}`}>
47
- <img src={image.previewUrl} alt="" />
 
 
 
 
 
 
48
  <span className="image-chip-copy">
49
  <span className="image-chip-name">{image.file.name || 'Screenshot'}</span>
50
- <span className="image-chip-meta">
51
  {image.status === 'uploading' ? 'uploading…'
52
  : image.error || (image.status === 'uploaded' ? 'uploaded' : fmtBytes(image.file.size))}
53
  </span>
54
  </span>
55
- <button type="button" onClick={() => onRemove(image.key)} disabled={image.status === 'uploading'} aria-label={`Remove ${image.file.name || 'screenshot'}`}>×</button>
56
  </div>
57
  ))}
58
  </div>
 
1
+ import { useId, useRef } from 'react';
2
  import type { PendingImage } from '../lib/imageAttachments';
3
  import { IMAGE_ACCEPT } from '../lib/imageAttachments';
4
 
 
14
  onRemove: (key: string) => void;
15
  }) {
16
  const picker = useRef<HTMLInputElement>(null);
17
+ const reasonId = useId();
18
+ const showReason = !!disabled && !!disabledReason;
19
  return (
20
+ <div className={`image-attachments${images.length ? ' has-images' : ''}${showReason ? ' has-note' : ''}`}>
21
  <button
22
  type="button"
23
  className="image-pick"
24
  disabled={disabled}
25
  title={disabledReason || 'Attach screenshots'}
26
+ aria-label="Attach screenshots"
27
+ aria-describedby={showReason ? reasonId : undefined}
28
  onClick={() => picker.current?.click()}
29
  >
30
  <svg viewBox="0 0 18 18" aria-hidden="true">
 
45
  event.currentTarget.value = '';
46
  }}
47
  />
48
+ {showReason && <span id={reasonId} className="image-attachments-note">{disabledReason}</span>}
49
  {images.map((image) => (
50
  <div key={image.key} className={`image-chip ${image.status}`}>
51
+ {image.error === 'unsupported image type' ? (
52
+ <span className="image-chip-placeholder mono" aria-hidden="true">IMG</span>
53
+ ) : (
54
+ <a className="image-chip-preview" href={image.previewUrl} target="_blank" rel="noreferrer" title={`Preview ${image.file.name || 'screenshot'}`}>
55
+ <img src={image.previewUrl} alt="" />
56
+ </a>
57
+ )}
58
  <span className="image-chip-copy">
59
  <span className="image-chip-name">{image.file.name || 'Screenshot'}</span>
60
+ <span className="image-chip-meta" aria-live="polite">
61
  {image.status === 'uploading' ? 'uploading…'
62
  : image.error || (image.status === 'uploaded' ? 'uploaded' : fmtBytes(image.file.size))}
63
  </span>
64
  </span>
65
+ <button type="button" onClick={() => onRemove(image.key)} disabled={disabled || image.status === 'uploading'} aria-label={`Remove ${image.file.name || 'screenshot'}`}>×</button>
66
  </div>
67
  ))}
68
  </div>
web/src/components/Overview.tsx CHANGED
@@ -60,21 +60,30 @@ function Card({ s, color, pending, isMobile, onOpen, onClose }: {
60
  useEffect(() => () => revokePendingImages(imagesRef.current), []);
61
 
62
  const addImages = (files: File[]) => {
63
- if (!allowImages || !files.length) return;
64
- const next = pendingImagesFromFiles(files, images.length);
65
- setImages((current) => [...current, ...next.images]);
 
 
66
  setImageError(next.error);
67
  };
68
  const removeImage = (key: string) => {
 
69
  setImages((current) => {
70
  const removed = current.find((image) => image.key === key);
71
  if (removed) URL.revokeObjectURL(removed.previewUrl);
72
- return current.filter((image) => image.key !== key);
 
 
73
  });
74
  setImageError(null);
75
  };
76
  const updateImage = (key: string, patch: Partial<PendingImage>) => {
77
- setImages((current) => current.map((image) => image.key === key ? { ...image, ...patch } : image));
 
 
 
 
78
  };
79
 
80
  // After you send (or when the transcript shows a prompt newer than the last
@@ -92,15 +101,17 @@ function Card({ s, color, pending, isMobile, onOpen, onClose }: {
92
 
93
  const send = async () => {
94
  const text = draft.trim();
95
- if ((!text && !images.length) || sending) return;
 
96
  setSending(true);
97
  setFailed(null);
98
  try {
99
- const attachments = await uploadPendingImages(s.id, images, updateImage);
100
  await api.sendInput(s.id, text, attachments.map((image) => image.id));
101
- const optimisticText = text || defaultImagePrompt(images.length);
102
  setDraft('');
103
- revokePendingImages(images);
 
104
  setImages([]);
105
  setImageError(null);
106
  setSent({ text: optimisticText, at: Date.now() });
@@ -186,11 +197,11 @@ function Card({ s, color, pending, isMobile, onOpen, onClose }: {
186
 
187
  <div
188
  className={`ov-composer${dropActive ? ' image-drop' : ''}`}
189
- onDragEnter={(event) => { if (allowImages && transferMayContainImage(event.dataTransfer)) { event.preventDefault(); setDropActive(true); } }}
190
- onDragOver={(event) => { if (allowImages && transferMayContainImage(event.dataTransfer)) { event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; } }}
191
  onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDropActive(false); }}
192
  onDrop={(event) => {
193
- if (!allowImages || !transferMayContainImage(event.dataTransfer)) return;
194
  event.preventDefault(); event.stopPropagation(); setDropActive(false);
195
  addImages(imageFilesFromTransfer(event.dataTransfer));
196
  }}
@@ -198,7 +209,7 @@ function Card({ s, color, pending, isMobile, onOpen, onClose }: {
198
  <ImageAttachments
199
  images={images}
200
  disabled={sending || !allowImages}
201
- disabledReason={!allowImages ? 'Screenshots are not available for remote agents yet' : undefined}
202
  onFiles={addImages}
203
  onRemove={removeImage}
204
  />
@@ -216,7 +227,7 @@ function Card({ s, color, pending, isMobile, onOpen, onClose }: {
216
  spellCheck={false}
217
  onPaste={(event) => {
218
  const files = imageFilesFromTransfer(event.clipboardData);
219
- if (!allowImages || !files.length) return;
220
  event.preventDefault(); addImages(files);
221
  }}
222
  onChange={(e) => { setDraft(e.target.value); e.currentTarget.style.height = 'auto'; e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`; }}
@@ -234,7 +245,7 @@ function Card({ s, color, pending, isMobile, onOpen, onClose }: {
234
  {(draft.trim() || images.length > 0) && !isMobile && <span className="ov-hint">↵ send · ⇧↵ newline</span>}
235
  </div>
236
  </div>
237
- {(imageError || failed) && <div className="ov-note">{imageError || failed}</div>}
238
  </div>
239
  );
240
  }
 
60
  useEffect(() => () => revokePendingImages(imagesRef.current), []);
61
 
62
  const addImages = (files: File[]) => {
63
+ if (!allowImages || sending || !files.length) return;
64
+ const next = pendingImagesFromFiles(files, imagesRef.current.length);
65
+ const merged = [...imagesRef.current, ...next.images];
66
+ imagesRef.current = merged;
67
+ setImages(merged);
68
  setImageError(next.error);
69
  };
70
  const removeImage = (key: string) => {
71
+ if (sending) return;
72
  setImages((current) => {
73
  const removed = current.find((image) => image.key === key);
74
  if (removed) URL.revokeObjectURL(removed.previewUrl);
75
+ const next = current.filter((image) => image.key !== key);
76
+ imagesRef.current = next;
77
+ return next;
78
  });
79
  setImageError(null);
80
  };
81
  const updateImage = (key: string, patch: Partial<PendingImage>) => {
82
+ setImages((current) => {
83
+ const next = current.map((image) => image.key === key ? { ...image, ...patch } : image);
84
+ imagesRef.current = next;
85
+ return next;
86
+ });
87
  };
88
 
89
  // After you send (or when the transcript shows a prompt newer than the last
 
101
 
102
  const send = async () => {
103
  const text = draft.trim();
104
+ const batch = imagesRef.current;
105
+ if ((!text && !batch.length) || sending) return;
106
  setSending(true);
107
  setFailed(null);
108
  try {
109
+ const attachments = await uploadPendingImages(s.id, batch, updateImage);
110
  await api.sendInput(s.id, text, attachments.map((image) => image.id));
111
+ const optimisticText = text || defaultImagePrompt(batch.length);
112
  setDraft('');
113
+ revokePendingImages(batch);
114
+ imagesRef.current = [];
115
  setImages([]);
116
  setImageError(null);
117
  setSent({ text: optimisticText, at: Date.now() });
 
197
 
198
  <div
199
  className={`ov-composer${dropActive ? ' image-drop' : ''}`}
200
+ onDragEnter={(event) => { if (allowImages && !sending && transferMayContainImage(event.dataTransfer)) { event.preventDefault(); setDropActive(true); } }}
201
+ onDragOver={(event) => { if (allowImages && !sending && transferMayContainImage(event.dataTransfer)) { event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; } }}
202
  onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDropActive(false); }}
203
  onDrop={(event) => {
204
+ if (!allowImages || sending || !transferMayContainImage(event.dataTransfer)) return;
205
  event.preventDefault(); event.stopPropagation(); setDropActive(false);
206
  addImages(imageFilesFromTransfer(event.dataTransfer));
207
  }}
 
209
  <ImageAttachments
210
  images={images}
211
  disabled={sending || !allowImages}
212
+ disabledReason={!allowImages ? 'Screenshots are not available for remote agents yet — that agent cannot read files stored on this Space.' : undefined}
213
  onFiles={addImages}
214
  onRemove={removeImage}
215
  />
 
227
  spellCheck={false}
228
  onPaste={(event) => {
229
  const files = imageFilesFromTransfer(event.clipboardData);
230
+ if (!allowImages || sending || !files.length) return;
231
  event.preventDefault(); addImages(files);
232
  }}
233
  onChange={(e) => { setDraft(e.target.value); e.currentTarget.style.height = 'auto'; e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`; }}
 
245
  {(draft.trim() || images.length > 0) && !isMobile && <span className="ov-hint">↵ send · ⇧↵ newline</span>}
246
  </div>
247
  </div>
248
+ {(imageError || failed) && <div className="ov-note" role="alert">{imageError || failed}</div>}
249
  </div>
250
  );
251
  }
web/src/components/Sidebar.tsx CHANGED
@@ -108,18 +108,28 @@ export default function Sidebar({
108
  useEffect(() => () => revokePendingImages(quickImagesRef.current), []);
109
 
110
  const updateQuickImage = (key: string, patch: Partial<PendingImage>) => {
111
- setQuickImages((current) => current.map((image) => image.key === key ? { ...image, ...patch } : image));
 
 
 
 
112
  };
113
  const addQuickImages = (files: File[]) => {
114
- const next = pendingImagesFromFiles(files, quickImages.length);
115
- setQuickImages((current) => [...current, ...next.images]);
 
 
 
116
  setQuickError(next.error);
117
  };
118
  const removeQuickImage = (key: string) => {
 
119
  setQuickImages((current) => {
120
  const removed = current.find((image) => image.key === key);
121
  if (removed) URL.revokeObjectURL(removed.previewUrl);
122
- return current.filter((image) => image.key !== key);
 
 
123
  });
124
  setQuickError(null);
125
  };
@@ -127,9 +137,13 @@ export default function Sidebar({
127
  // server-created session. Changing its identity starts a fresh target.
128
  const resetQuickTarget = () => {
129
  setQuickSessionId(null);
130
- setQuickImages((current) => current.map((image) => image.attachment
131
- ? { ...image, attachment: undefined, status: 'pending', error: undefined }
132
- : image));
 
 
 
 
133
  };
134
 
135
  const clearDrag = () => { setDragRef(null); setDrop(null); onDragState?.(null); };
@@ -155,7 +169,8 @@ export default function Sidebar({
155
  const bump = (id: string, d: number) => setCart((c) => ({ ...c, [id]: Math.max(0, (c[id] || 0) + d) }));
156
  const closePanel = () => {
157
  if (panel === 'quick') {
158
- revokePendingImages(quickImages);
 
159
  setQuickImages([]);
160
  setQuickSessionId(null);
161
  setQuickSending(false);
@@ -410,18 +425,18 @@ export default function Sidebar({
410
  <div
411
  className={`widget quick${quickDrop ? ' image-drop' : ''}`}
412
  onDragEnter={(event) => {
413
- if (quickMode === 'agent' && quickCli && !isRemote(quickCli) && transferMayContainImage(event.dataTransfer)) {
414
  event.preventDefault(); setQuickDrop(true);
415
  }
416
  }}
417
  onDragOver={(event) => {
418
- if (quickMode === 'agent' && quickCli && !isRemote(quickCli) && transferMayContainImage(event.dataTransfer)) {
419
  event.preventDefault(); event.dataTransfer.dropEffect = 'copy';
420
  }
421
  }}
422
  onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setQuickDrop(false); }}
423
  onDrop={(event) => {
424
- if (quickMode !== 'agent' || !quickCli || isRemote(quickCli) || !transferMayContainImage(event.dataTransfer)) return;
425
  event.preventDefault(); event.stopPropagation(); setQuickDrop(false);
426
  addQuickImages(imageFilesFromTransfer(event.dataTransfer));
427
  }}
@@ -432,7 +447,7 @@ export default function Sidebar({
432
  key={c.id}
433
  className={`quick-cli${quickMode === 'agent' && quickCli === c.id ? ' on' : ''}${c.available ? '' : ' off'}`}
434
  title={c.available ? c.label : `${c.label} (not installed)`}
435
- disabled={!c.available}
436
  style={quickMode === 'agent' && quickCli === c.id ? { borderColor: c.color } : undefined}
437
  onClick={() => { setQuickMode('agent'); if (quickCli !== c.id) resetQuickTarget(); setQuickCli(c.id); }}
438
  ><Logo cli={c.id} size={14} /></button>
@@ -441,7 +456,8 @@ export default function Sidebar({
441
  <button
442
  className={`quick-cli quick-grp${quickMode === 'group' ? ' on' : ''}`}
443
  title="New group"
444
- onClick={() => setQuickMode('group')}
 
445
  >
446
  <span className="grp-mini">
447
  <Logo cli="claude" size={8} />
@@ -454,10 +470,11 @@ export default function Sidebar({
454
  <button
455
  className={`quick-cli${quickMode === 'agent' && quickCli === 'remote' ? ' on' : ''}`}
456
  title="Remote agent — an agent on another machine"
 
457
  style={quickMode === 'agent' && quickCli === 'remote' ? { borderColor: remoteCli.color } : undefined}
458
  onClick={() => {
459
  setQuickMode('agent'); setQuickCli('remote'); setQuickSessionId(null);
460
- revokePendingImages(quickImages); setQuickImages([]); setQuickError(null);
461
  }}
462
  ><Logo cli="remote" size={14} /></button>
463
  )}
@@ -465,7 +482,12 @@ export default function Sidebar({
465
 
466
  {quickMode === 'agent' ? (
467
  <>
468
- {quickError && <div className="open-trace-err">{quickError}</div>}
 
 
 
 
 
469
  <textarea
470
  autoFocus
471
  rows={1}
@@ -474,7 +496,7 @@ export default function Sidebar({
474
  value={quickPrompt}
475
  disabled={quickSending}
476
  onPaste={(event) => {
477
- if (!quickCli || isRemote(quickCli)) return;
478
  const files = imageFilesFromTransfer(event.clipboardData);
479
  if (!files.length) return;
480
  event.preventDefault(); addQuickImages(files);
@@ -488,7 +510,7 @@ export default function Sidebar({
488
  <ImageAttachments
489
  images={quickImages}
490
  disabled={quickSending || !quickCli || isRemote(quickCli)}
491
- disabledReason={quickCli && isRemote(quickCli) ? 'Screenshots are not available for remote agents yet' : undefined}
492
  onFiles={addQuickImages}
493
  onRemove={removeQuickImage}
494
  />
 
108
  useEffect(() => () => revokePendingImages(quickImagesRef.current), []);
109
 
110
  const updateQuickImage = (key: string, patch: Partial<PendingImage>) => {
111
+ setQuickImages((current) => {
112
+ const next = current.map((image) => image.key === key ? { ...image, ...patch } : image);
113
+ quickImagesRef.current = next;
114
+ return next;
115
+ });
116
  };
117
  const addQuickImages = (files: File[]) => {
118
+ if (quickSending) return;
119
+ const next = pendingImagesFromFiles(files, quickImagesRef.current.length);
120
+ const merged = [...quickImagesRef.current, ...next.images];
121
+ quickImagesRef.current = merged;
122
+ setQuickImages(merged);
123
  setQuickError(next.error);
124
  };
125
  const removeQuickImage = (key: string) => {
126
+ if (quickSending) return;
127
  setQuickImages((current) => {
128
  const removed = current.find((image) => image.key === key);
129
  if (removed) URL.revokeObjectURL(removed.previewUrl);
130
+ const next = current.filter((image) => image.key !== key);
131
+ quickImagesRef.current = next;
132
+ return next;
133
  });
134
  setQuickError(null);
135
  };
 
137
  // server-created session. Changing its identity starts a fresh target.
138
  const resetQuickTarget = () => {
139
  setQuickSessionId(null);
140
+ setQuickImages((current) => {
141
+ const next = current.map((image) => image.attachment
142
+ ? { ...image, attachment: undefined, status: 'pending' as const, error: undefined }
143
+ : image);
144
+ quickImagesRef.current = next;
145
+ return next;
146
+ });
147
  };
148
 
149
  const clearDrag = () => { setDragRef(null); setDrop(null); onDragState?.(null); };
 
169
  const bump = (id: string, d: number) => setCart((c) => ({ ...c, [id]: Math.max(0, (c[id] || 0) + d) }));
170
  const closePanel = () => {
171
  if (panel === 'quick') {
172
+ revokePendingImages(quickImagesRef.current);
173
+ quickImagesRef.current = [];
174
  setQuickImages([]);
175
  setQuickSessionId(null);
176
  setQuickSending(false);
 
425
  <div
426
  className={`widget quick${quickDrop ? ' image-drop' : ''}`}
427
  onDragEnter={(event) => {
428
+ if (!quickSending && quickMode === 'agent' && quickCli && !isRemote(quickCli) && transferMayContainImage(event.dataTransfer)) {
429
  event.preventDefault(); setQuickDrop(true);
430
  }
431
  }}
432
  onDragOver={(event) => {
433
+ if (!quickSending && quickMode === 'agent' && quickCli && !isRemote(quickCli) && transferMayContainImage(event.dataTransfer)) {
434
  event.preventDefault(); event.dataTransfer.dropEffect = 'copy';
435
  }
436
  }}
437
  onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setQuickDrop(false); }}
438
  onDrop={(event) => {
439
+ if (quickSending || quickMode !== 'agent' || !quickCli || isRemote(quickCli) || !transferMayContainImage(event.dataTransfer)) return;
440
  event.preventDefault(); event.stopPropagation(); setQuickDrop(false);
441
  addQuickImages(imageFilesFromTransfer(event.dataTransfer));
442
  }}
 
447
  key={c.id}
448
  className={`quick-cli${quickMode === 'agent' && quickCli === c.id ? ' on' : ''}${c.available ? '' : ' off'}`}
449
  title={c.available ? c.label : `${c.label} (not installed)`}
450
+ disabled={!c.available || quickSending}
451
  style={quickMode === 'agent' && quickCli === c.id ? { borderColor: c.color } : undefined}
452
  onClick={() => { setQuickMode('agent'); if (quickCli !== c.id) resetQuickTarget(); setQuickCli(c.id); }}
453
  ><Logo cli={c.id} size={14} /></button>
 
456
  <button
457
  className={`quick-cli quick-grp${quickMode === 'group' ? ' on' : ''}`}
458
  title="New group"
459
+ disabled={quickSending}
460
+ onClick={() => setQuickMode('group')}
461
  >
462
  <span className="grp-mini">
463
  <Logo cli="claude" size={8} />
 
470
  <button
471
  className={`quick-cli${quickMode === 'agent' && quickCli === 'remote' ? ' on' : ''}`}
472
  title="Remote agent — an agent on another machine"
473
+ disabled={quickSending}
474
  style={quickMode === 'agent' && quickCli === 'remote' ? { borderColor: remoteCli.color } : undefined}
475
  onClick={() => {
476
  setQuickMode('agent'); setQuickCli('remote'); setQuickSessionId(null);
477
+ revokePendingImages(quickImagesRef.current); quickImagesRef.current = []; setQuickImages([]); setQuickError(null);
478
  }}
479
  ><Logo cli="remote" size={14} /></button>
480
  )}
 
482
 
483
  {quickMode === 'agent' ? (
484
  <>
485
+ {quickError && <div className="open-trace-err" role="alert">{quickError}</div>}
486
+ {quickError && quickSessionId && (
487
+ <div className="quick-recovery mono">
488
+ The agent was created. Retry will reuse it; delete it from the agent row if you want to start over.
489
+ </div>
490
+ )}
491
  <textarea
492
  autoFocus
493
  rows={1}
 
496
  value={quickPrompt}
497
  disabled={quickSending}
498
  onPaste={(event) => {
499
+ if (quickSending || !quickCli || isRemote(quickCli)) return;
500
  const files = imageFilesFromTransfer(event.clipboardData);
501
  if (!files.length) return;
502
  event.preventDefault(); addQuickImages(files);
 
510
  <ImageAttachments
511
  images={quickImages}
512
  disabled={quickSending || !quickCli || isRemote(quickCli)}
513
+ disabledReason={quickCli && isRemote(quickCli) ? 'Screenshots are not available for remote agents yet — that agent cannot read files stored on this Space.' : undefined}
514
  onFiles={addQuickImages}
515
  onRemove={removeQuickImage}
516
  />
web/src/components/TerminalPane.tsx CHANGED
@@ -9,8 +9,9 @@ import { STATE_LABEL } from '../types';
9
  import Logo from './Logo';
10
  import { CloseGlyph, RefreshGlyph } from './icons';
11
  import * as api from '../api';
 
12
  import {
13
- IMAGE_ACCEPT, IMAGE_MIMES, MAX_IMAGE_BYTES, MAX_IMAGES, imageFilesFromTransfer,
14
  transferMayContainImage,
15
  } from '../lib/imageAttachments';
16
 
@@ -228,7 +229,12 @@ export default function TerminalPane({
228
  const [pasteOpen, setPasteOpen] = useState(false);
229
  const [imageDrop, setImageDrop] = useState(false);
230
  const [imageStatus, setImageStatus] = useState<{ kind: 'uploading' | 'success' | 'error'; text: string } | null>(null);
 
 
231
  const supportsImages = session.cli !== 'shell';
 
 
 
232
  const commitName = () => {
233
  const v = draft.trim();
234
  if (v && v !== session.name) onRename?.(v);
@@ -251,30 +257,97 @@ export default function TerminalPane({
251
  if (linger) imageStatusTimerRef.current = window.setTimeout(() => setImageStatus(null), linger);
252
  };
253
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  uploadImagesRef.current = (files: File[]) => {
255
  if (!supportsImages) return;
 
 
 
 
 
 
 
 
 
 
 
 
256
  if (imageUploadBusyRef.current) return;
257
- const images = files
258
- .filter((file) => (IMAGE_MIMES as readonly string[]).includes(file.type))
259
- .slice(0, MAX_IMAGES);
 
 
 
260
  if (!images.length) { showImageStatus({ kind: 'error', text: 'Use PNG, JPEG, GIF, or WebP' }, 4000); return; }
261
- const invalid = images.find((file) => file.size === 0 || file.size > MAX_IMAGE_BYTES);
262
- if (invalid) { showImageStatus({ kind: 'error', text: invalid.size ? 'Image is larger than 25 MB' : 'Image is empty' }, 4000); return; }
263
  imageUploadBusyRef.current = true;
 
264
  void (async () => {
 
265
  try {
266
  for (let index = 0; index < images.length; index += 1) {
267
  showImageStatus({ kind: 'uploading', text: `uploading screenshot${images.length > 1 ? ` ${index + 1}/${images.length}` : ''}…` });
268
  const attachment = await api.uploadImageAttachment(session.id, images[index]);
269
- claimRef.current();
270
- termRef.current?.paste(attachment.insertText);
271
  }
272
- termRef.current?.focus();
273
- showImageStatus({ kind: 'success', text: `${images.length === 1 ? 'Screenshot' : `${images.length} screenshots`} inserted — press Enter when ready` }, 3000);
274
  } catch (error) {
275
- showImageStatus({ kind: 'error', text: error instanceof Error ? error.message : 'screenshot upload failed' }, 5000);
 
 
 
 
 
 
 
 
276
  } finally {
277
  imageUploadBusyRef.current = false;
 
278
  }
279
  })();
280
  };
@@ -927,7 +1000,13 @@ export default function TerminalPane({
927
  <>
928
  <button
929
  className="mini-btn ph-image"
930
- title="Attach screenshot"
 
 
 
 
 
 
931
  draggable={false}
932
  onMouseDown={(event) => event.stopPropagation()}
933
  onClick={(event) => { event.stopPropagation(); imagePickerRef.current?.click(); }}
@@ -944,6 +1023,7 @@ export default function TerminalPane({
944
  type="file"
945
  accept={IMAGE_ACCEPT}
946
  multiple
 
947
  onChange={(event) => {
948
  uploadImagesRef.current(Array.from(event.currentTarget.files || []));
949
  event.currentTarget.value = '';
@@ -957,7 +1037,18 @@ export default function TerminalPane({
957
  <div className={`term-host${imageDrop ? ' image-drop' : ''}`} ref={frameRef}>
958
  <div className="term-fill" ref={hostRef} />
959
  </div>
960
- {imageStatus && <div className={`term-image-status ${imageStatus.kind} mono`}>{imageStatus.text}</div>}
 
 
 
 
 
 
 
 
 
 
 
961
  {isMobile && conn === 'connected' && (
962
  // Control keys the phone keyboard lacks — needed for TUI menus (model
963
  // pickers, etc.). preventDefault keeps terminal focus so the keyboard
 
9
  import Logo from './Logo';
10
  import { CloseGlyph, RefreshGlyph } from './icons';
11
  import * as api from '../api';
12
+ import type { ImageAttachment } from '../api';
13
  import {
14
+ IMAGE_ACCEPT, MAX_IMAGES, imageFileError, imageFilesFromTransfer, looksLikeImageFile,
15
  transferMayContainImage,
16
  } from '../lib/imageAttachments';
17
 
 
229
  const [pasteOpen, setPasteOpen] = useState(false);
230
  const [imageDrop, setImageDrop] = useState(false);
231
  const [imageStatus, setImageStatus] = useState<{ kind: 'uploading' | 'success' | 'error'; text: string } | null>(null);
232
+ const [imageUploadBusy, setImageUploadBusy] = useState(false);
233
+ const [pendingInsert, setPendingInsert] = useState<ImageAttachment[]>([]);
234
  const supportsImages = session.cli !== 'shell';
235
+ const hasInputControl = viewers <= 1 || controller;
236
+ const canAttachImages = supportsImages && conn === 'connected' && hasInputControl
237
+ && !imageUploadBusy && pendingInsert.length === 0;
238
  const commitName = () => {
239
  const v = draft.trim();
240
  if (v && v !== session.name) onRename?.(v);
 
257
  if (linger) imageStatusTimerRef.current = window.setTimeout(() => setImageStatus(null), linger);
258
  };
259
 
260
+ const insertTerminalImages = async (attachments: ImageAttachment[]) => {
261
+ try {
262
+ const result = await api.insertImageAttachments(session.id, attachments.map((image) => image.id));
263
+ setPendingInsert([]);
264
+ const count = attachments.length;
265
+ showImageStatus({
266
+ kind: 'success',
267
+ text: result.mode === 'attached'
268
+ ? `${count === 1 ? 'Screenshot' : `${count} screenshots`} attached — continue typing`
269
+ : `${count === 1 ? 'Screenshot' : `${count} screenshots`} inserted — press Enter when ready`,
270
+ }, 3000);
271
+ termRef.current?.focus();
272
+ return true;
273
+ } catch {
274
+ setPendingInsert(attachments);
275
+ showImageStatus({
276
+ kind: 'error',
277
+ text: `${attachments.length === 1 ? 'Screenshot was' : 'Screenshots were'} saved but not inserted`,
278
+ });
279
+ return false;
280
+ }
281
+ };
282
+
283
+ const retryTerminalInsert = async () => {
284
+ if (!pendingInsert.length || imageUploadBusyRef.current) return;
285
+ if (conn !== 'connected') {
286
+ showImageStatus({ kind: 'error', text: 'Restart or reconnect the agent before retrying' });
287
+ return;
288
+ }
289
+ if (!hasInputControl) {
290
+ showImageStatus({ kind: 'error', text: 'Interact with the terminal to take control before retrying' });
291
+ return;
292
+ }
293
+ imageUploadBusyRef.current = true;
294
+ setImageUploadBusy(true);
295
+ showImageStatus({ kind: 'uploading', text: 'inserting saved screenshot…' });
296
+ try { await insertTerminalImages(pendingInsert); } finally {
297
+ imageUploadBusyRef.current = false;
298
+ setImageUploadBusy(false);
299
+ }
300
+ };
301
+
302
  uploadImagesRef.current = (files: File[]) => {
303
  if (!supportsImages) return;
304
+ if (conn !== 'connected') {
305
+ showImageStatus({ kind: 'error', text: 'Restart or reconnect the agent before attaching screenshots' }, 5000);
306
+ return;
307
+ }
308
+ if (!hasInputControl) {
309
+ showImageStatus({ kind: 'error', text: 'Interact with the terminal to take control before attaching screenshots' }, 5000);
310
+ return;
311
+ }
312
+ if (pendingInsert.length) {
313
+ showImageStatus({ kind: 'error', text: 'Retry the saved screenshot before attaching another' });
314
+ return;
315
+ }
316
  if (imageUploadBusyRef.current) return;
317
+ const candidates = files.filter(looksLikeImageFile);
318
+ if (candidates.length > MAX_IMAGES) {
319
+ showImageStatus({ kind: 'error', text: `Attach at most ${MAX_IMAGES} screenshots at a time` }, 4000);
320
+ return;
321
+ }
322
+ const images = candidates.slice(0, MAX_IMAGES);
323
  if (!images.length) { showImageStatus({ kind: 'error', text: 'Use PNG, JPEG, GIF, or WebP' }, 4000); return; }
324
+ const invalid = images.map((file) => imageFileError(file)).find(Boolean);
325
+ if (invalid) { showImageStatus({ kind: 'error', text: invalid }, 4000); return; }
326
  imageUploadBusyRef.current = true;
327
+ setImageUploadBusy(true);
328
  void (async () => {
329
+ const attachments: ImageAttachment[] = [];
330
  try {
331
  for (let index = 0; index < images.length; index += 1) {
332
  showImageStatus({ kind: 'uploading', text: `uploading screenshot${images.length > 1 ? ` ${index + 1}/${images.length}` : ''}…` });
333
  const attachment = await api.uploadImageAttachment(session.id, images[index]);
334
+ attachments.push(attachment);
 
335
  }
336
+ showImageStatus({ kind: 'uploading', text: `inserting screenshot${images.length === 1 ? '' : 's'}…` });
337
+ await insertTerminalImages(attachments);
338
  } catch (error) {
339
+ if (attachments.length) {
340
+ setPendingInsert(attachments);
341
+ showImageStatus({
342
+ kind: 'error',
343
+ text: `${attachments.length} screenshot${attachments.length === 1 ? '' : 's'} saved; another failed to upload`,
344
+ });
345
+ } else {
346
+ showImageStatus({ kind: 'error', text: error instanceof Error ? error.message : 'screenshot upload failed' }, 5000);
347
+ }
348
  } finally {
349
  imageUploadBusyRef.current = false;
350
+ setImageUploadBusy(false);
351
  }
352
  })();
353
  };
 
1000
  <>
1001
  <button
1002
  className="mini-btn ph-image"
1003
+ title={conn === 'connected'
1004
+ ? (!hasInputControl
1005
+ ? 'Interact with the terminal to take control before attaching screenshots'
1006
+ : (pendingInsert.length ? 'Retry the saved screenshot first' : 'Attach screenshot'))
1007
+ : 'Restart or reconnect the agent to attach screenshots'}
1008
+ aria-label="Attach screenshot"
1009
+ disabled={!canAttachImages}
1010
  draggable={false}
1011
  onMouseDown={(event) => event.stopPropagation()}
1012
  onClick={(event) => { event.stopPropagation(); imagePickerRef.current?.click(); }}
 
1023
  type="file"
1024
  accept={IMAGE_ACCEPT}
1025
  multiple
1026
+ disabled={!canAttachImages}
1027
  onChange={(event) => {
1028
  uploadImagesRef.current(Array.from(event.currentTarget.files || []));
1029
  event.currentTarget.value = '';
 
1037
  <div className={`term-host${imageDrop ? ' image-drop' : ''}`} ref={frameRef}>
1038
  <div className="term-fill" ref={hostRef} />
1039
  </div>
1040
+ {imageStatus && (
1041
+ <div
1042
+ className={`term-image-status ${imageStatus.kind}${pendingInsert.length ? ' has-action' : ''} mono`}
1043
+ role={imageStatus.kind === 'error' ? 'alert' : 'status'}
1044
+ aria-live={imageStatus.kind === 'error' ? 'assertive' : 'polite'}
1045
+ >
1046
+ <span>{imageStatus.text}</span>
1047
+ {pendingInsert.length > 0 && (
1048
+ <button type="button" onClick={retryTerminalInsert} disabled={imageUploadBusy || conn !== 'connected' || !hasInputControl}>retry</button>
1049
+ )}
1050
+ </div>
1051
+ )}
1052
  {isMobile && conn === 'connected' && (
1053
  // Control keys the phone keyboard lacks — needed for TUI menus (model
1054
  // pickers, etc.). preventDefault keeps terminal focus so the keyboard
web/src/lib/imageAttachments.ts CHANGED
@@ -18,10 +18,37 @@ export interface PendingImage {
18
  }
19
 
20
  const identity = (file: File) => `${file.name}\u0000${file.type}\u0000${file.size}\u0000${file.lastModified}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  export function transferMayContainImage(transfer: DataTransfer) {
23
- return Array.from(transfer.items || []).some((item) => item.kind === 'file' && item.type.startsWith('image/'))
24
- || Array.from(transfer.files || []).some((file) => file.type.startsWith('image/'));
 
25
  }
26
 
27
  /** DataTransfer exposes the same file through both items and files in Chromium. */
@@ -29,7 +56,7 @@ export function imageFilesFromTransfer(transfer: DataTransfer) {
29
  const files: File[] = [];
30
  const seen = new Set<string>();
31
  const add = (file: File | null) => {
32
- if (!file || !file.type.startsWith('image/')) return;
33
  const id = identity(file);
34
  if (seen.has(id)) return;
35
  seen.add(id);
@@ -45,10 +72,7 @@ export function imageFilesFromTransfer(transfer: DataTransfer) {
45
  export function pendingImagesFromFiles(files: File[], currentCount = 0) {
46
  const remaining = Math.max(0, MAX_IMAGES - currentCount);
47
  const accepted = files.slice(0, remaining).map((file): PendingImage => {
48
- let error: string | undefined;
49
- if (!(IMAGE_MIMES as readonly string[]).includes(file.type)) error = 'unsupported image type';
50
- else if (file.size > MAX_IMAGE_BYTES) error = 'too large (25 MB max)';
51
- else if (file.size === 0) error = 'empty image';
52
  return {
53
  key: globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random()}`,
54
  file,
@@ -82,8 +106,7 @@ export async function uploadPendingImages(
82
  attachments.push(image.attachment);
83
  continue;
84
  }
85
- if (!(IMAGE_MIMES as readonly string[]).includes(image.file.type)
86
- || image.file.size === 0 || image.file.size > MAX_IMAGE_BYTES) {
87
  throw new Error(image.error || 'invalid image');
88
  }
89
  update(image.key, { status: 'uploading', error: undefined });
 
18
  }
19
 
20
  const identity = (file: File) => `${file.name}\u0000${file.type}\u0000${file.size}\u0000${file.lastModified}`;
21
+ const IMAGE_EXTENSION = /\.(png|jpe?g|gif|webp)$/i;
22
+
23
+ export function normalizedImageMime(type: string) {
24
+ const value = String(type || '').split(';', 1)[0].trim().toLowerCase();
25
+ return value === 'image/jpg' || value === 'image/pjpeg' ? 'image/jpeg' : value;
26
+ }
27
+
28
+ // Clipboard and drag sources sometimes omit MIME metadata. Use it as a hint,
29
+ // not as proof; the server validates the actual bytes before storing anything.
30
+ export function looksLikeImageFile(file: Pick<File, 'name' | 'type'>) {
31
+ const type = normalizedImageMime(file.type);
32
+ return !type || type === 'application/octet-stream'
33
+ || type.startsWith('image/') || IMAGE_EXTENSION.test(file.name || '');
34
+ }
35
+
36
+ export function imageFileError(file: Pick<File, 'name' | 'type' | 'size'>) {
37
+ if (file.size > MAX_IMAGE_BYTES) return 'too large (25 MB max)';
38
+ if (file.size === 0) return 'empty image';
39
+ const type = normalizedImageMime(file.type);
40
+ if (type.startsWith('image/') && !(IMAGE_MIMES as readonly string[]).includes(type)) {
41
+ return 'unsupported image type';
42
+ }
43
+ if (type && type !== 'application/octet-stream' && !type.startsWith('image/')
44
+ && !IMAGE_EXTENSION.test(file.name || '')) return 'unsupported image type';
45
+ return undefined;
46
+ }
47
 
48
  export function transferMayContainImage(transfer: DataTransfer) {
49
+ return Array.from(transfer.items || []).some((item) => item.kind === 'file'
50
+ && (item.type.startsWith('image/') || looksLikeImageFile(item.getAsFile() || { name: '', type: item.type })))
51
+ || Array.from(transfer.files || []).some(looksLikeImageFile);
52
  }
53
 
54
  /** DataTransfer exposes the same file through both items and files in Chromium. */
 
56
  const files: File[] = [];
57
  const seen = new Set<string>();
58
  const add = (file: File | null) => {
59
+ if (!file || !looksLikeImageFile(file)) return;
60
  const id = identity(file);
61
  if (seen.has(id)) return;
62
  seen.add(id);
 
72
  export function pendingImagesFromFiles(files: File[], currentCount = 0) {
73
  const remaining = Math.max(0, MAX_IMAGES - currentCount);
74
  const accepted = files.slice(0, remaining).map((file): PendingImage => {
75
+ const error = imageFileError(file);
 
 
 
76
  return {
77
  key: globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random()}`,
78
  file,
 
106
  attachments.push(image.attachment);
107
  continue;
108
  }
109
+ if (imageFileError(image.file)) {
 
110
  throw new Error(image.error || 'invalid image');
111
  }
112
  update(image.key, { status: 'uploading', error: undefined });
web/src/styles.css CHANGED
@@ -164,6 +164,7 @@ body {
164
  .quick-foot .quick-hint { margin-left: auto; white-space: nowrap; }
165
  .quick-more { background: none; border: none; padding: 0; font: inherit; font-size: 10.5px; font-weight: 600; color: var(--accent); cursor: pointer; white-space: nowrap; }
166
  .quick-more:hover { text-decoration: underline; }
 
167
  .cart-row.off { opacity: 0.45; }
168
 
169
  /* screenshot attachments: one compact operational row shared by composers */
@@ -173,10 +174,14 @@ body {
173
  .image-pick:hover:not(:disabled) { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
174
  .image-pick:disabled { opacity: 0.38; cursor: not-allowed; }
175
  .image-pick svg { width: 15px; height: 15px; }
 
176
  .image-chip { min-width: 0; max-width: 205px; height: 36px; display: flex; align-items: center; gap: 6px; padding: 3px 5px 3px 3px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--panel); }
177
  .image-chip.error { border-color: color-mix(in srgb, var(--danger) 48%, var(--border)); }
178
  .image-chip.uploading { border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); }
179
- .image-chip img { flex: none; width: 29px; height: 28px; border-radius: 3px; object-fit: cover; background: var(--panel-2); }
 
 
 
180
  .image-chip-copy { min-width: 0; display: flex; flex-direction: column; line-height: 1.15; }
181
  .image-chip-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10.5px; font-weight: 600; }
182
  .image-chip-meta { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; color: var(--muted); font: 9.5px var(--font-mono); }
@@ -363,7 +368,9 @@ body {
363
  .ov-composer { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 7px 8px; border-top: 1px solid var(--border); padding-top: 7px; }
364
  .ov-composer.image-drop { outline: 1px dashed var(--accent); outline-offset: 5px; background: var(--drop); }
365
  .ov-composer .image-attachments.has-images { grid-column: 1 / -1; }
366
- .ov-composer .image-attachments.has-images + .ov-live { grid-column: 1 / -1; }
 
 
367
  .ov-composer .ov-live { border-top: none; padding-top: 0; }
368
  .ov-live { display: flex; gap: 8px; align-items: center; border-top: 1px solid var(--border); padding-top: 7px; font-size: 13px; }
369
  .ov-live .ov-p { color: var(--accent); font-weight: 700; font-size: 13px; }
@@ -604,6 +611,10 @@ body {
604
  .term-image-status.uploading { color: var(--accent); animation: breathe 1.2s ease-in-out infinite; }
605
  .term-image-status.success { color: var(--go); }
606
  .term-image-status.error { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); }
 
 
 
 
607
  /* The grid is measured against this filler, so it must stay unpadded: FitAddon
608
  reads the PARENT's computed height and does NOT subtract its padding, so a
609
  padded parent made the grid count a row that doesn't fit and the bottom line
 
164
  .quick-foot .quick-hint { margin-left: auto; white-space: nowrap; }
165
  .quick-more { background: none; border: none; padding: 0; font: inherit; font-size: 10.5px; font-weight: 600; color: var(--accent); cursor: pointer; white-space: nowrap; }
166
  .quick-more:hover { text-decoration: underline; }
167
+ .quick-recovery { margin-top: -3px; color: var(--muted); font-size: 9.5px; line-height: 1.35; }
168
  .cart-row.off { opacity: 0.45; }
169
 
170
  /* screenshot attachments: one compact operational row shared by composers */
 
174
  .image-pick:hover:not(:disabled) { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
175
  .image-pick:disabled { opacity: 0.38; cursor: not-allowed; }
176
  .image-pick svg { width: 15px; height: 15px; }
177
+ .image-attachments-note { min-width: 0; color: var(--muted); font-size: 10px; line-height: 1.3; }
178
  .image-chip { min-width: 0; max-width: 205px; height: 36px; display: flex; align-items: center; gap: 6px; padding: 3px 5px 3px 3px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--panel); }
179
  .image-chip.error { border-color: color-mix(in srgb, var(--danger) 48%, var(--border)); }
180
  .image-chip.uploading { border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); }
181
+ .image-chip-preview { flex: none; width: 29px; height: 28px; border-radius: 3px; overflow: hidden; background: var(--panel-2); }
182
+ .image-chip-preview:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
183
+ .image-chip img { display: block; width: 29px; height: 28px; object-fit: cover; }
184
+ .image-chip-placeholder { flex: none; width: 29px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border-radius: 3px; background: var(--panel-2); color: var(--muted); font-size: 8px; }
185
  .image-chip-copy { min-width: 0; display: flex; flex-direction: column; line-height: 1.15; }
186
  .image-chip-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10.5px; font-weight: 600; }
187
  .image-chip-meta { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; color: var(--muted); font: 9.5px var(--font-mono); }
 
368
  .ov-composer { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 7px 8px; border-top: 1px solid var(--border); padding-top: 7px; }
369
  .ov-composer.image-drop { outline: 1px dashed var(--accent); outline-offset: 5px; background: var(--drop); }
370
  .ov-composer .image-attachments.has-images { grid-column: 1 / -1; }
371
+ .ov-composer .image-attachments.has-note { grid-column: 1 / -1; }
372
+ .ov-composer .image-attachments.has-images + .ov-live,
373
+ .ov-composer .image-attachments.has-note + .ov-live { grid-column: 1 / -1; }
374
  .ov-composer .ov-live { border-top: none; padding-top: 0; }
375
  .ov-live { display: flex; gap: 8px; align-items: center; border-top: 1px solid var(--border); padding-top: 7px; font-size: 13px; }
376
  .ov-live .ov-p { color: var(--accent); font-weight: 700; font-size: 13px; }
 
611
  .term-image-status.uploading { color: var(--accent); animation: breathe 1.2s ease-in-out infinite; }
612
  .term-image-status.success { color: var(--go); }
613
  .term-image-status.error { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); }
614
+ .term-image-status.has-action { display: flex; align-items: center; gap: 8px; pointer-events: auto; }
615
+ .term-image-status button { padding: 0; border: 0; background: none; color: inherit; font: inherit; font-weight: 700; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
616
+ .term-image-status button:disabled { opacity: 0.45; cursor: default; }
617
+ .pane-head .ph-image:disabled { opacity: 0.35; cursor: default; }
618
  /* The grid is measured against this filler, so it must stay unpadded: FitAddon
619
  reads the PARENT's computed height and does NOT subtract its padding, so a
620
  padded parent made the grid count a row that doesn't fit and the bottom line