@logicseek/hello-cc 1.0.2 → 1.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +111 -23
  2. package/README.md +10 -1
  3. package/README.zh-CN.md +8 -1
  4. package/docs/commands.md +28 -4
  5. package/docs/commands.zh-CN.md +20 -4
  6. package/docs/dsh.md +2 -2
  7. package/docs/dsh.zh-CN.md +3 -3
  8. package/docs/native.md +84 -10
  9. package/docs/native.zh-CN.md +66 -5
  10. package/docs/web-handoff.zh-CN.md +11 -3
  11. package/lib/cli/app.mjs +42 -1
  12. package/lib/cli/commands/coordination.mjs +7 -2
  13. package/lib/cli/commands/dsh.mjs +1 -1
  14. package/lib/cli/commands/gc.mjs +9 -4
  15. package/lib/cli/commands/hook.mjs +6 -1
  16. package/lib/cli/commands/install.mjs +48 -6
  17. package/lib/cli/commands/lifecycle.mjs +14 -3
  18. package/lib/cli/commands/native.mjs +16 -9
  19. package/lib/cli/commands/query.mjs +15 -10
  20. package/lib/cli/commands/run.mjs +7 -6
  21. package/lib/core/coordination/results.mjs +27 -10
  22. package/lib/db/connection.mjs +135 -10
  23. package/lib/db/schema.mjs +59 -0
  24. package/lib/discover.mjs +4 -4
  25. package/lib/guidance.mjs +3 -2
  26. package/lib/integrations/codex-interactions.mjs +13 -11
  27. package/lib/integrations/dsh-collaboration.mjs +6 -12
  28. package/lib/integrations/dsh-cordis.mjs +17 -13
  29. package/lib/integrations/dsh.mjs +12 -9
  30. package/lib/integrations/hooks.mjs +132 -40
  31. package/lib/integrations/mcp-elicitation.mjs +116 -0
  32. package/lib/integrations/mcp-url-elicitation.mjs +25 -0
  33. package/lib/integrations/native/interactions.mjs +2 -1
  34. package/lib/integrations/peers/identity.mjs +42 -7
  35. package/lib/process/identity.mjs +15 -14
  36. package/lib/runtime/buffer-evidence.mjs +2 -1
  37. package/lib/runtime/native/service.mjs +11 -5
  38. package/lib/runtime/native/store.mjs +70 -18
  39. package/lib/runtime/paths.mjs +17 -2
  40. package/lib/runtime/private-file.mjs +76 -0
  41. package/lib/runtime/private-state.mjs +546 -0
  42. package/lib/runtime/project-context.mjs +2 -1
  43. package/lib/runtime/project-path.mjs +148 -12
  44. package/lib/runtime/project-trust.mjs +79 -0
  45. package/lib/runtime/projects.mjs +6 -3
  46. package/lib/runtime/state-migration.mjs +697 -0
  47. package/lib/runtime/state.mjs +22 -11
  48. package/lib/shared/automatic-result-key.mjs +6 -0
  49. package/lib/shared/json-file.mjs +55 -6
  50. package/lib/ui/help.mjs +6 -4
  51. package/lib/web/codex-app-server.mjs +26 -5
  52. package/lib/web/codex-sessions.mjs +5 -3
  53. package/lib/web/cookie-auth.mjs +1 -1
  54. package/lib/web/external-sessions.mjs +54 -5
  55. package/lib/web/http-routes.mjs +13 -9
  56. package/lib/web/http.mjs +20 -2
  57. package/lib/web/native-sessions.mjs +14 -7
  58. package/lib/web/peer-actions.mjs +27 -27
  59. package/lib/web/project-contexts.mjs +2 -3
  60. package/lib/web/runtime-main.mjs +22 -5
  61. package/lib/web/runtime.mjs +18 -4
  62. package/lib/web/session-serialize.mjs +1 -1
  63. package/lib/web/startup.mjs +115 -22
  64. package/lib/web/task-results.mjs +20 -17
  65. package/lib/web/terminal-assets.mjs +18 -0
  66. package/lib/web/tls.mjs +84 -33
  67. package/lib/web/tmux-sessions.mjs +2 -2
  68. package/lib/web/tmux-stream.mjs +3 -4
  69. package/lib/web/ui-codex.mjs +161 -32
  70. package/lib/web/ui-interactions.mjs +137 -4
  71. package/lib/web/ui-native.mjs +31 -13
  72. package/lib/web/ui-template.mjs +10 -3
  73. package/package.json +1 -1
  74. package/scripts/mcp-form-live-fixture.mjs +81 -0
  75. package/scripts/native-installed-acceptance.mjs +413 -0
  76. package/scripts/regression.mjs +46 -29
  77. package/scripts/web-mcp-form-acceptance.mjs +119 -0
  78. package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
  79. package/scripts/web-native-interaction-acceptance.mjs +77 -10
package/lib/web/tls.mjs CHANGED
@@ -5,12 +5,18 @@ import { spawnSync } from 'node:child_process';
5
5
  import { createPublicKey, randomBytes, X509Certificate } from 'node:crypto';
6
6
  import { isIP } from 'node:net';
7
7
  import { CliError } from '../shared/errors.mjs';
8
- import { globalStateDir } from '../runtime/paths.mjs';
8
+ import { ensurePrivateGlobalSubdirectory } from '../runtime/private-state.mjs';
9
+ import { readPrivateTextFile } from '../runtime/private-file.mjs';
10
+ import { unsafeDirectoryAcl } from '../runtime/project-trust.mjs';
9
11
 
10
12
  const CERT_RENEW_BEFORE_MS = 7 * 24 * 60 * 60 * 1000;
11
13
  const GENERATION_CLEANUP_GRACE_MS = 60 * 60 * 1000;
12
14
  const GENERATION_NAME_RE = /^generation-[a-z0-9-]+$/i;
13
15
  const OWNED_GENERATION_NAME_RE = /^generation-[a-z0-9]+-(\d+)-[a-f0-9]+$/i;
16
+ const TLS_KEY_MAX_BYTES = 16384;
17
+ const TLS_CERT_MAX_BYTES = 65536;
18
+ const TLS_POINTER_MAX_BYTES = 16384;
19
+ const TLS_MARKER_MAX_BYTES = 128;
14
20
 
15
21
  // hello-cc can serve the web console over HTTPS with an auto-generated
16
22
  // self-signed certificate. This protects against passive LAN sniffing of the
@@ -93,10 +99,48 @@ export function tlsCredentialsAreReusable({ key, cert, sanEntries, nowMs = Date.
93
99
  }
94
100
  }
95
101
 
102
+ // The tls/ parent is validated before this module runs, but a generation may
103
+ // have been planted while an older installation still left that parent public.
104
+ // Do not follow or repair a foreign, redirected, or cross-UID-writable child.
105
+ function trustedTlsDirectory(directory, { tighten = false } = {}) {
106
+ let fd;
107
+ try {
108
+ const before = fs.lstatSync(directory, { bigint: true });
109
+ if (before.isSymbolicLink() || !before.isDirectory() ||
110
+ before.uid !== BigInt(process.getuid())) return null;
111
+ fd = fs.openSync(directory, fs.constants.O_RDONLY |
112
+ fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
113
+ let opened = fs.fstatSync(fd, { bigint: true });
114
+ if (!opened.isDirectory() || opened.dev !== before.dev || opened.ino !== before.ino ||
115
+ opened.uid !== before.uid || (opened.mode & 0o022n) !== 0n ||
116
+ unsafeDirectoryAcl(directory, opened)) return null;
117
+ if (tighten && (opened.mode & 0o777n) !== 0o700n) {
118
+ fs.fchmodSync(fd, 0o700);
119
+ opened = fs.fstatSync(fd, { bigint: true });
120
+ if ((opened.mode & 0o777n) !== 0o700n) return null;
121
+ }
122
+ const after = fs.lstatSync(directory, { bigint: true });
123
+ if (after.isSymbolicLink() || after.dev !== opened.dev || after.ino !== opened.ino ||
124
+ after.uid !== opened.uid) return null;
125
+ return opened;
126
+ } catch {
127
+ return null;
128
+ } finally {
129
+ if (fd !== undefined) fs.closeSync(fd);
130
+ }
131
+ }
132
+
133
+ function readPrivateMarker(file) {
134
+ try { return readPrivateTextFile(file, { maxBytes: TLS_MARKER_MAX_BYTES }); }
135
+ catch { return null; }
136
+ }
137
+
96
138
  function readReusableCredentials(keyPath, certPath, sanEntries) {
97
139
  try {
98
- const key = fs.readFileSync(keyPath, 'utf8');
99
- const cert = fs.readFileSync(certPath, 'utf8');
140
+ if (!trustedTlsDirectory(path.dirname(keyPath), { tighten: true })) return null;
141
+ const key = readPrivateTextFile(keyPath, { maxBytes: TLS_KEY_MAX_BYTES });
142
+ const cert = readPrivateTextFile(certPath, { maxBytes: TLS_CERT_MAX_BYTES });
143
+ if (key === null || cert === null) return null;
100
144
  if (!tlsCredentialsAreReusable({ key, cert, sanEntries })) return null;
101
145
  return { key, cert, certPath };
102
146
  } catch {
@@ -165,7 +209,11 @@ function generateCredentials(dir, keyPath, certPath, sanEntries) {
165
209
 
166
210
  function readCurrentGeneration(dir) {
167
211
  try {
168
- const pointer = JSON.parse(fs.readFileSync(path.join(dir, 'current.json'), 'utf8'));
212
+ const text = readPrivateTextFile(path.join(dir, 'current.json'), {
213
+ maxBytes: TLS_POINTER_MAX_BYTES
214
+ });
215
+ if (text === null) return null;
216
+ const pointer = JSON.parse(text);
169
217
  const generation = String(pointer?.generation || '');
170
218
  return GENERATION_NAME_RE.test(generation) ? generation : null;
171
219
  } catch {
@@ -188,12 +236,12 @@ function generationCreationIsActive(generationDir, generation) {
188
236
  const nameMatch = generation.match(OWNED_GENERATION_NAME_RE);
189
237
  const namePid = nameMatch ? Number.parseInt(nameMatch[1], 10) : null;
190
238
  try {
191
- const ownerPid = Number.parseInt(fs.readFileSync(creatingPath, 'utf8').trim(), 10);
239
+ const ownerPid = Number.parseInt(readPrivateMarker(creatingPath)?.trim(), 10);
192
240
  if (Number.isInteger(ownerPid) && ownerPid > 0 && (!namePid || ownerPid === namePid)) {
193
241
  return processIsAlive(ownerPid);
194
242
  }
195
243
  } catch {}
196
- if (fs.existsSync(path.join(generationDir, '.published'))) return false;
244
+ if (readPrivateMarker(path.join(generationDir, '.published')) !== null) return false;
197
245
 
198
246
  // Backward compatibility for generations created before lifecycle markers
199
247
  // were added, including the mkdir-to-marker window in a concurrent creator.
@@ -214,19 +262,23 @@ function writeGenerationCreatingMarker(generationDir) {
214
262
  function markGenerationPublished(generationDir) {
215
263
  const creatingPath = path.join(generationDir, '.creating');
216
264
  const publishedPath = path.join(generationDir, '.published');
265
+ if (readPrivateMarker(creatingPath) !== null) {
266
+ try { fs.renameSync(creatingPath, publishedPath); return; } catch {}
267
+ }
268
+ if (readPrivateMarker(publishedPath) !== null) return;
269
+ let fd;
217
270
  try {
218
- fs.renameSync(creatingPath, publishedPath);
271
+ fd = fs.openSync(publishedPath, fs.constants.O_WRONLY | fs.constants.O_CREAT |
272
+ fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
273
+ fs.writeFileSync(fd, `${process.pid}\n`);
219
274
  } catch {
220
- try { fs.writeFileSync(publishedPath, `${process.pid}\n`, { mode: 0o600 }); } catch {}
221
- if (fs.existsSync(publishedPath)) {
222
- try { fs.rmSync(creatingPath, { force: true }); } catch {}
223
- }
275
+ // A bad preexisting marker must not block use of otherwise safe credentials.
276
+ } finally {
277
+ if (fd !== undefined) fs.closeSync(fd);
224
278
  }
225
- try { fs.chmodSync(publishedPath, 0o600); } catch {}
226
279
  }
227
280
 
228
281
  function currentGenerationCredentials(dir, sanEntries) {
229
- const pointerPath = path.join(dir, 'current.json');
230
282
  try {
231
283
  const generation = readCurrentGeneration(dir);
232
284
  if (!generation) return null;
@@ -235,10 +287,6 @@ function currentGenerationCredentials(dir, sanEntries) {
235
287
  const certPath = path.join(generationDir, 'self-signed.crt');
236
288
  const credentials = readReusableCredentials(keyPath, certPath, sanEntries);
237
289
  if (!credentials) return null;
238
- try { fs.chmodSync(generationDir, 0o700); } catch {}
239
- try { fs.chmodSync(keyPath, 0o600); } catch {}
240
- try { fs.chmodSync(certPath, 0o600); } catch {}
241
- try { fs.chmodSync(pointerPath, 0o600); } catch {}
242
290
  markGenerationPublished(generationDir);
243
291
  return credentials;
244
292
  } catch {
@@ -252,7 +300,6 @@ function publishGeneration(dir, generation) {
252
300
  try {
253
301
  fs.writeFileSync(tmpPointerPath, `${JSON.stringify({ generation })}\n`, { mode: 0o600 });
254
302
  fs.renameSync(tmpPointerPath, pointerPath);
255
- try { fs.chmodSync(pointerPath, 0o600); } catch {}
256
303
  } finally {
257
304
  try { fs.rmSync(tmpPointerPath, { force: true }); } catch {}
258
305
  }
@@ -266,11 +313,8 @@ function pruneOldGenerations(dir, t = Date.now()) {
266
313
  const generations = fs.readdirSync(dir, { withFileTypes: true })
267
314
  .filter((entry) => entry.isDirectory() && GENERATION_NAME_RE.test(entry.name))
268
315
  .map((entry) => {
269
- try {
270
- return { name: entry.name, mtimeMs: fs.statSync(path.join(dir, entry.name)).mtimeMs };
271
- } catch {
272
- return null;
273
- }
316
+ const stat = trustedTlsDirectory(path.join(dir, entry.name));
317
+ return stat ? { name: entry.name, mtimeMs: Number(stat.mtimeMs), dev: stat.dev, ino: stat.ino } : null;
274
318
  })
275
319
  .filter(Boolean)
276
320
  .sort((a, b) => b.mtimeMs - a.mtimeMs);
@@ -279,12 +323,17 @@ function pruneOldGenerations(dir, t = Date.now()) {
279
323
  for (const generation of generations) {
280
324
  if (generation.name === current) continue;
281
325
  const generationDir = path.join(dir, generation.name);
282
- const hasCreatingMarker = fs.existsSync(path.join(generationDir, '.creating'));
326
+ const hasCreatingMarker = readPrivateMarker(path.join(generationDir, '.creating')) !== null;
283
327
  if (generationCreationIsActive(generationDir, generation.name)) continue;
284
328
  let complete = false;
285
329
  try {
286
- complete = fs.statSync(path.join(generationDir, 'self-signed.key')).size > 0 &&
287
- fs.statSync(path.join(generationDir, 'self-signed.crt')).size > 0;
330
+ const key = readPrivateTextFile(path.join(generationDir, 'self-signed.key'), {
331
+ maxBytes: TLS_KEY_MAX_BYTES
332
+ });
333
+ const cert = readPrivateTextFile(path.join(generationDir, 'self-signed.crt'), {
334
+ maxBytes: TLS_CERT_MAX_BYTES
335
+ });
336
+ complete = Boolean(key?.length && cert?.length);
288
337
  } catch {}
289
338
  if (!keptPrevious && complete && !hasCreatingMarker) {
290
339
  keptPrevious = true;
@@ -293,9 +342,15 @@ function pruneOldGenerations(dir, t = Date.now()) {
293
342
  // A concurrently generated directory may not be published yet. Leave
294
343
  // recent candidates alone; a later startup will collect stale losers.
295
344
  if (t - generation.mtimeMs < GENERATION_CLEANUP_GRACE_MS) continue;
296
- // current.json can change while this best-effort scan is running.
297
- if (readCurrentGeneration(dir) === generation.name) continue;
345
+ // current.json can change while this best-effort scan is running. An
346
+ // unreadable or concurrently replaced pointer is not proof that this
347
+ // generation is stale, so leave cleanup for a later startup.
348
+ const latestCurrent = readCurrentGeneration(dir);
349
+ if (!latestCurrent) return;
350
+ if (latestCurrent === generation.name) continue;
298
351
  if (generationCreationIsActive(generationDir, generation.name)) continue;
352
+ const beforeDelete = trustedTlsDirectory(generationDir);
353
+ if (!beforeDelete || beforeDelete.dev !== generation.dev || beforeDelete.ino !== generation.ino) continue;
299
354
  try { fs.rmSync(generationDir, { recursive: true, force: true }); } catch {}
300
355
  }
301
356
  } catch {
@@ -338,10 +393,8 @@ function createGeneration(dir, sanEntries, source = null) {
338
393
  * browser trust decision is stable.
339
394
  */
340
395
  export function ensureSelfSignedCert(extraHosts = []) {
341
- const dir = path.join(globalStateDir(), 'tls');
396
+ const dir = ensurePrivateGlobalSubdirectory('tls');
342
397
  const sanEntries = lanIpSanEntries(Array.isArray(extraHosts) ? extraHosts : [extraHosts]);
343
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
344
- try { fs.chmodSync(dir, 0o700); } catch {}
345
398
 
346
399
  const existing = currentGenerationCredentials(dir, sanEntries);
347
400
  if (existing) {
@@ -356,8 +409,6 @@ export function ensureSelfSignedCert(extraHosts = []) {
356
409
  sanEntries
357
410
  );
358
411
  if (legacy) {
359
- try { fs.chmodSync(path.join(dir, 'self-signed.key'), 0o600); } catch {}
360
- try { fs.chmodSync(path.join(dir, 'self-signed.crt'), 0o600); } catch {}
361
412
  return createGeneration(dir, sanEntries, legacy);
362
413
  }
363
414
 
@@ -801,7 +801,7 @@ export function createTmuxSessions(deps) {
801
801
 
802
802
  function writeSessionInput(session, data) {
803
803
  if (session.type === 'external') {
804
- fs.appendFileSync(session.inFile, data);
804
+ fs.appendFileSync(session.inFile, data, { mode: 0o600 });
805
805
  } else if (session.type === 'tmux') {
806
806
  tmuxSendLiteral(session.pane, data);
807
807
  scheduleTmuxInputRefresh(session);
@@ -822,7 +822,7 @@ export function createTmuxSessions(deps) {
822
822
 
823
823
  function resizeSession(session, cols, rows) {
824
824
  if (session.type === 'external') {
825
- try { fs.appendFileSync(session.resizeFile, JSON.stringify({ cols, rows }) + '\n'); } catch {}
825
+ try { fs.appendFileSync(session.resizeFile, JSON.stringify({ cols, rows }) + '\n', { mode: 0o600 }); } catch {}
826
826
  } else if (session.type === 'tmux') {
827
827
  // Resize the tmux window to match the browser terminal so the captured
828
828
  // mirror has identical geometry — a prerequisite for accurate cursor
@@ -7,12 +7,12 @@ import path from 'node:path';
7
7
  import { spawnSync } from 'node:child_process';
8
8
  import { runTmux, tmuxCapturePane, tmuxCursorInfo, tmuxCursorPayload } from '../terminal/tmux.mjs';
9
9
  import { CliError } from '../shared/errors.mjs';
10
+ import { ensurePrivateProjectBufferDirectory } from '../runtime/project-path.mjs';
10
11
 
11
12
  export function createTmuxStream({
12
13
  broadcast,
13
14
  now,
14
15
  refreshPeerIoHeartbeat,
15
- bufferDirectory,
16
16
  withBufferDirectoryLease,
17
17
  shellQuoteArg,
18
18
  ctx
@@ -69,8 +69,7 @@ export function createTmuxStream({
69
69
  function startTmuxStream(session) {
70
70
  const safePane = String(session.pane).replace(/[^A-Za-z0-9_-]/g, '');
71
71
  const safeId = String(session.id).replace(/[^A-Za-z0-9_.-]/g, '_');
72
- const streamDirectory = bufferDirectory(session.ctx || ctx);
73
- fs.mkdirSync(streamDirectory, { recursive: true });
72
+ const streamDirectory = ensurePrivateProjectBufferDirectory((session.ctx || ctx).root);
74
73
  const pipeFile = path.join(streamDirectory, `tmux-${safePane}-${safeId}.pipe`);
75
74
  session.pipeFile = pipeFile;
76
75
  try { runTmux(['pipe-pane', '-t', session.pane]); } catch {}
@@ -89,7 +88,7 @@ export function createTmuxStream({
89
88
  const message = (mkfifo.stderr || mkfifo.stdout || '').trim() || 'mkfifo failed';
90
89
  throw new CliError('TMUX_STREAM_ERROR', message);
91
90
  }
92
- try { fs.chmodSync(pipeFile, 0o600); } catch {}
91
+ fs.chmodSync(pipeFile, 0o600);
93
92
  session.streamFd = fs.openSync(pipeFile, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
94
93
  });
95
94
  } catch (err) {
@@ -42,6 +42,11 @@ export function codexPanelHtml() {
42
42
  .codex-code-header { display:flex; align-items:center; justify-content:space-between; padding:4px 8px; color:var(--muted); font-size:var(--small-font); border-bottom:1px solid var(--border); }
43
43
  .codex-code-header button { font-size:var(--small-font); min-height:28px; padding:3px 8px; }
44
44
  .codex-code pre { margin:0; padding:10px 12px; overflow:auto; white-space:pre; font-family:var(--mono); font-size:var(--small-font); line-height:1.6; }
45
+ .codex-segment-nav { display:flex; flex-wrap:wrap; align-items:center; gap:6px; padding:6px 8px; border-bottom:1px solid var(--border); }
46
+ .codex-segment-nav button { min-height:32px; font-size:var(--small-font); padding:4px 8px; }
47
+ .codex-segment-nav span { color:var(--muted); font-size:var(--small-font); margin-right:auto; }
48
+ .codex-code [data-segment-content] pre { max-height:360px; }
49
+ .codex-code-prose pre { white-space:pre-wrap; overflow-wrap:anywhere; font-family:inherit; font-size:var(--body-font); }
45
50
  .codex-diff-add { color:var(--ok); } .codex-diff-remove { color:var(--danger); } .codex-diff-header { color:var(--accent); }
46
51
  .codex-review { max-width:900px; margin:16px auto 0; border-top:1px solid var(--border); padding-top:12px; }
47
52
  .codex-review h3 { font-size:var(--small-font); margin:14px 0 6px; }
@@ -58,7 +63,11 @@ export function codexPanelHtml() {
58
63
  .hcc-interaction-form label { display:grid; gap:5px; overflow-wrap:anywhere; }
59
64
  .hcc-interaction-form label:has(input[type=checkbox]) { display:flex; align-items:flex-start; }
60
65
  .hcc-interaction-form input:not([type=checkbox]), .hcc-interaction-form select { width:100%; min-height:36px; min-width:0; background:var(--input-bg); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:6px; }
61
- .codex-approval-actions { display:flex; gap:8px; margin-top:12px; }
66
+ .hcc-mcp-field { min-width:0; border:1px solid var(--border); border-radius:6px; padding:10px; }
67
+ .hcc-mcp-field legend,.hcc-mcp-field p { overflow-wrap:anywhere; }
68
+ .hcc-mcp-field small { color:var(--muted); }
69
+ .hcc-mcp-field .sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); }
70
+ .codex-approval-actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:12px; }
62
71
  .codex-jump { flex:none; text-align:center; padding:6px; border-top:1px solid var(--border); }
63
72
  .codex-composer { flex:none; padding:12px 16px; border-top:1px solid var(--border); background:var(--panel); max-height:40%; overflow:auto; }
64
73
  .codex-composer-inner { max-width:900px; margin:auto; }
@@ -71,6 +80,8 @@ export function codexPanelHtml() {
71
80
  .codex-header, .codex-scroll, .codex-composer { padding-left:12px; padding-right:12px; }
72
81
  .codex-card { padding:10px; } .codex-card-agent { padding:2px; }
73
82
  .codex-composer-actions button { min-height:36px; }
83
+ .codex-segment-nav span { flex-basis:100%; }
84
+ .codex-segment-nav button { min-height:44px; flex:1; }
74
85
  .codex-approval dl { grid-template-columns:1fr; gap:3px; } .codex-approval dd { margin-bottom:6px; }
75
86
  }
76
87
  </style>
@@ -102,8 +113,11 @@ export function codexPanelScript() {
102
113
  const views = new Map();
103
114
  const approving = new Set();
104
115
  const PAGE_SIZE = 80;
116
+ const SEGMENT_CHARS = 12000, SEGMENT_LINES = 200;
105
117
  let copies = [], timelineHtml = '', timelineSignature = '', timelineCopies = [], entries = [];
118
+ let blocks = [], timelineBlocks = [], blockScope = '', blockOrder = 0;
106
119
  let state = null, key = '', pending = null, submitting = false;
120
+ let stateRevision = 0, subjectRevision = 0, readSequence = 0;
107
121
  const text = (en, zh) => window.hccUi.language === 'zh' ? zh : en;
108
122
  const bridge = () => window.hccHandoff;
109
123
  const escape = (value) => bridge().esc(String(value ?? ''));
@@ -118,7 +132,7 @@ export function codexPanelScript() {
118
132
  function turnId() { return state?.turnId || activeThread()?.activeTurnId || ''; }
119
133
  function view() {
120
134
  if (!views.has(key)) {
121
- views.set(key, { top: 0, follow: true, unread: false, signature: '', details: new Map(), startKey: '', start: 0, end: 0, total: 0 });
135
+ views.set(key, { top: 0, follow: true, unread: false, signature: '', details: new Map(), textPages: new Map(), codeScroll: new Map(), startKey: '', start: 0, end: 0, total: 0 });
122
136
  if (views.size > 20) views.delete(views.keys().next().value);
123
137
  }
124
138
  return views.get(key);
@@ -145,16 +159,57 @@ export function codexPanelScript() {
145
159
  };
146
160
  return labels[status] || String(status || '');
147
161
  }
148
- function codeBlock(value, language = '', diff = false) {
149
- const content = String(value ?? ''), index = copies.push(content) - 1;
150
- const body = diff ? content.split('\n').map(line => {
162
+ function segmentRanges(content) {
163
+ const ranges = [];
164
+ let start = 0;
165
+ while (start < content.length) {
166
+ let end = Math.min(content.length, start + SEGMENT_CHARS), cursor = start;
167
+ for (let lines = 0; lines < SEGMENT_LINES; lines += 1) {
168
+ const newline = content.indexOf('\n', cursor);
169
+ if (newline === -1 || newline >= end) break;
170
+ cursor = newline + 1;
171
+ if (lines === SEGMENT_LINES - 1) end = cursor;
172
+ }
173
+ // A page boundary must not split an emoji or another surrogate pair.
174
+ if (end < content.length && /[\uD800-\uDBFF]/.test(content[end - 1]) && /[\uDC00-\uDFFF]/.test(content[end])) end -= 1;
175
+ ranges.push([start, end]); start = end;
176
+ }
177
+ return ranges;
178
+ }
179
+ function codeBody(content, diff) {
180
+ return diff ? content.split('\n').map(line => {
151
181
  const cls = line.startsWith('@@') || line.startsWith('diff ') || line.startsWith('+++') || line.startsWith('---')
152
182
  ? 'codex-diff-header' : line.startsWith('+') ? 'codex-diff-add' : line.startsWith('-') ? 'codex-diff-remove' : '';
153
183
  return '<span class="' + cls + '">' + escape(line) + '</span>';
154
184
  }).join('\n') : escape(content);
155
- return '<div class="codex-code"><div class="codex-code-header"><span>' + escape(language || text('Text', '文本'))
156
- + '</span><button type="button" data-copy="' + index + '">' + escape(text('Copy', '复制'))
157
- + '</button></div><pre><code>' + body + '</code></pre></div>';
185
+ }
186
+ function segmentBody(block, index) {
187
+ const [start, end] = block.ranges[block.page];
188
+ const button = (action, label, disabled) => '<button type="button" data-segment="' + index + '" data-segment-action="' + action + '"'
189
+ + (disabled ? ' disabled' : '') + '>' + escape(label) + '</button>';
190
+ return '<nav class="codex-segment-nav" aria-label="' + escape(text('Text sections', '内容分段')) + '"><span role="status" tabindex="-1">'
191
+ + escape(text('Section ', '第 ') + (block.page + 1) + text(' of ', ' / ') + block.ranges.length + text(' · Full text available', ' 段 · 可查阅全文'))
192
+ + '</span>' + button('first', text('First', '首段'), block.page === 0)
193
+ + button('previous', text('Previous', '上一段'), block.page === 0)
194
+ + button('next', text('Next', '下一段'), block.page === block.ranges.length - 1)
195
+ + button('last', text('Last', '末段'), block.page === block.ranges.length - 1)
196
+ + '</nav><pre tabindex="0" data-code-page="' + block.page + '"><code>' + codeBody(block.content.slice(start, end), block.diff) + '</code></pre>';
197
+ }
198
+ function codeBlock(value, language = '', diff = false, plain = false, identityScope = '') {
199
+ const content = String(value ?? ''), index = copies.push(content) - 1;
200
+ const ranges = segmentRanges(content), identity = identityScope || blockScope + ':' + blockOrder++;
201
+ let body, blockIndex = -1;
202
+ if (ranges.length > 1) {
203
+ blockIndex = blocks.length;
204
+ const page = Math.min(view().textPages.get(identity) || 0, ranges.length - 1);
205
+ const block = { content, ranges, diff, page, identity };
206
+ blocks.push(block);
207
+ body = '<div data-segment-content>' + segmentBody(block, blockIndex) + '</div>';
208
+ } else body = '<pre data-code-page="0"><code>' + codeBody(content, diff) + '</code></pre>';
209
+ return '<div class="codex-code' + (plain ? ' codex-code-prose' : '') + '" data-code-key="' + escape(identity) + '"' + (blockIndex < 0 ? '' : ' data-segment-block="' + blockIndex + '"')
210
+ + '><div class="codex-code-header"><span>' + escape(language || text('Text', '文本'))
211
+ + '</span><button type="button" data-copy="' + index + '">' + escape(ranges.length > 1 ? text('Copy full text', '复制全文') : text('Copy', '复制'))
212
+ + '</button></div>' + body + '</div>';
158
213
  }
159
214
  function inline(value, allowBold = true) {
160
215
  const tick = String.fromCharCode(96), source = String(value), fragments = [];
@@ -225,7 +280,9 @@ export function codexPanelScript() {
225
280
  return '<div class="codex-markdown">' + parts.join('') + '</div>';
226
281
  }
227
282
  function prose(value) {
228
- const fence = String.fromCharCode(96).repeat(3), lines = String(value ?? '').split('\n');
283
+ const content = String(value ?? '');
284
+ if (segmentRanges(content).length > 1) return codeBlock(content, text('Long message · Plain-text sections', '长消息 · 纯文本分段'), false, true);
285
+ const fence = String.fromCharCode(96).repeat(3), lines = content.split('\n');
229
286
  let html = '', plain = [], code = [], language = '', inCode = false;
230
287
  const flush = () => { if (plain.length) html += markdown(plain.join('\n')); plain = []; };
231
288
  for (const line of lines) {
@@ -250,6 +307,7 @@ export function codexPanelScript() {
250
307
  }
251
308
  function itemCard(item, turn, index) {
252
309
  const type = item.type || '', id = turn.id + ':' + (item.id || index), content = itemText(item);
310
+ blockScope = 'item:' + id; blockOrder = 0;
253
311
  const error = item.status === 'failed' || item.error || (item.exitCode != null && item.exitCode !== 0);
254
312
  const status = '<span class="codex-status' + (error ? ' codex-status-failed' : '') + '">' + escape(error ? text('Failed', '失败') : statusLabel(item.status)) + '</span>';
255
313
  if (type === 'userMessage' || type === 'agentMessage') {
@@ -297,14 +355,64 @@ export function codexPanelScript() {
297
355
  node.addEventListener('toggle', () => { if (key === currentKey && node.isConnected !== false) view().details.set(node.dataset.detailsKey, node.open); });
298
356
  });
299
357
  container.querySelectorAll('button[data-copy]').forEach(button => {
300
- const content = copies[Number(button.dataset.copy)];
301
358
  button.addEventListener('click', async () => {
359
+ if (key !== currentKey || button.isConnected === false) return;
360
+ // A hidden section may change while the visible HTML stays identical.
361
+ const content = copies[Number(button.dataset.copy)];
302
362
  try {
303
363
  await navigator.clipboard.writeText(content);
304
364
  button.textContent = text('Copied', '已复制');
305
365
  } catch (_) { button.textContent = text('Select text to copy', '请选择文本复制'); }
306
366
  });
307
367
  });
368
+ bindSegmentActions(container);
369
+ bindCodeScroll(container);
370
+ }
371
+ function bindCodeScroll(container) {
372
+ const currentKey = key;
373
+ container.querySelectorAll('pre[data-code-page]').forEach(pre => {
374
+ const identity = pre.closest('[data-code-key]')?.dataset.codeKey;
375
+ if (!identity) return;
376
+ const page = Number(pre.dataset.codePage), saved = view().codeScroll.get(identity);
377
+ if (saved?.page === page) { pre.scrollTop = saved.top; pre.scrollLeft = saved.left; }
378
+ // Scroll events from restoration are asynchronous. Ignore unchanged
379
+ // coordinates so restoring a block never opts out of follow mode.
380
+ let top = pre.scrollTop, left = pre.scrollLeft;
381
+ pre.addEventListener('scroll', () => {
382
+ if (key !== currentKey || pre.isConnected === false || (top === pre.scrollTop && left === pre.scrollLeft)) return;
383
+ top = pre.scrollTop; left = pre.scrollLeft;
384
+ const reading = view();
385
+ reading.codeScroll.set(identity, { page, top, left });
386
+ if (reading.codeScroll.size > 256) reading.codeScroll.delete(reading.codeScroll.keys().next().value);
387
+ if (top || left) { reading.follow = false; reading.top = scroller.scrollTop; updateJump(); }
388
+ });
389
+ });
390
+ }
391
+ function bindSegmentActions(container) {
392
+ const currentKey = key;
393
+ container.querySelectorAll('button[data-segment]').forEach(button => {
394
+ const index = Number(button.dataset.segment);
395
+ button.addEventListener('click', () => {
396
+ const block = blocks[index];
397
+ if (key !== currentKey || button.isConnected === false || !block) return;
398
+ const action = button.dataset.segmentAction;
399
+ block.page = Math.max(0, Math.min(block.ranges.length - 1,
400
+ action === 'first' ? 0 : action === 'last' ? block.ranges.length - 1 : block.page + (action === 'next' ? 1 : -1)));
401
+ view().textPages.set(block.identity, block.page);
402
+ view().codeScroll.delete(block.identity);
403
+ if (view().textPages.size > 256) view().textPages.delete(view().textPages.keys().next().value);
404
+ const wrapper = button.closest('[data-segment-block]'), body = wrapper.querySelector('[data-segment-content]');
405
+ const top = wrapper.getBoundingClientRect().top, oldTop = scroller.scrollTop;
406
+ view().follow = false;
407
+ body.innerHTML = segmentBody(block, index);
408
+ bindSegmentActions(body);
409
+ bindCodeScroll(body);
410
+ const focus = body.querySelector('button[data-segment-action="' + action + '"]:not(:disabled)') || body.querySelector('[role="status"]');
411
+ focus?.focus({ preventScroll: true });
412
+ scroller.scrollTop = oldTop + wrapper.getBoundingClientRect().top - top;
413
+ view().top = scroller.scrollTop; updateJump();
414
+ });
415
+ });
308
416
  }
309
417
  function renderTimeline(turns, reading) {
310
418
  // Page by items rather than turns: a single tool-heavy turn can contain hundreds of cards.
@@ -327,7 +435,7 @@ export function codexPanelScript() {
327
435
  if (!reading.follow && reading.signature && reading.signature !== updateSignature) reading.unread = true;
328
436
  reading.signature = updateSignature;
329
437
  if (signature !== timelineSignature) {
330
- copies = [];
438
+ copies = []; blocks = [];
331
439
  let previousTurn = null;
332
440
  let html = visible.map(({ turn, turnIndex, item, index }) => {
333
441
  let heading = '';
@@ -344,8 +452,8 @@ export function codexPanelScript() {
344
452
  document.getElementById('codexTimeline').innerHTML = html;
345
453
  timelineHtml = html; bindReadActions(document.getElementById('codexTimeline'));
346
454
  }
347
- timelineSignature = signature; timelineCopies = copies.slice();
348
- } else copies = timelineCopies.slice();
455
+ timelineSignature = signature; timelineCopies = copies.slice(); timelineBlocks = blocks.slice();
456
+ } else { copies = timelineCopies.slice(); blocks = timelineBlocks.slice(); }
349
457
  document.getElementById('codexHistory').hidden = entries.length <= PAGE_SIZE;
350
458
  document.getElementById('codexHistory').setAttribute('aria-label', text('Conversation history', '会话历史'));
351
459
  document.getElementById('codexEarlier').textContent = text('Earlier', '更早记录');
@@ -358,9 +466,10 @@ export function codexPanelScript() {
358
466
  function sync() {
359
467
  const next = draftKey();
360
468
  if (next !== key) {
469
+ subjectRevision += 1;
361
470
  if (key && state) view().top = scroller.scrollTop || 0;
362
471
  key = next; pending = null; draft.value = ''; state = null;
363
- timelineHtml = ''; timelineSignature = ''; timelineCopies = []; copies = []; entries = [];
472
+ timelineHtml = ''; timelineSignature = ''; timelineCopies = []; copies = []; entries = []; blocks = []; timelineBlocks = [];
364
473
  document.getElementById('codexHistory').hidden = true;
365
474
  for (const id of ['codexStatus', 'codexTimeline', 'codexDiff', 'codexPlan', 'codexApprovals', 'codexSubmission']) document.getElementById(id).textContent = '';
366
475
  document.getElementById('codexReview').open = view().details.get('review') || false;
@@ -397,7 +506,10 @@ export function codexPanelScript() {
397
506
  { method: 'POST', body: JSON.stringify({ ...body, action_token: target.token, epoch: target.epoch }) });
398
507
  }
399
508
  function render(value, resetScroll = false) {
400
- sync(); state = value; sync();
509
+ sync();
510
+ // Local paging and preference redraws reuse state; incoming snapshots do not.
511
+ if (value !== state) stateRevision += 1;
512
+ state = value; sync();
401
513
  const thread = activeThread();
402
514
  document.getElementById('codexStatus').textContent = [text('Executor', '执行器') + ': ' + statusLabel(state.status),
403
515
  text('Thread', '会话') + ': ' + (thread?.id || ''), turnId() ? text('Turn', '当前轮') + ': ' + turnId() : text('Idle', '空闲'),
@@ -410,12 +522,13 @@ export function codexPanelScript() {
410
522
  renderTimeline(turns, reading);
411
523
  const last = turns[turns.length - 1];
412
524
  const plan = Array.isArray(last?.plan) ? last.plan : [];
525
+ blockScope = 'plan:' + last?.id; blockOrder = 0;
413
526
  document.getElementById('codexPlan').innerHTML = plan.length ? '<h3>' + escape(text('Plan', '执行计划')) + '</h3>'
414
527
  + (last.explanation ? prose(last.explanation) : '') + '<ol class="codex-plan">' + plan.map(step =>
415
528
  '<li data-status="' + escape(step.status || '') + '"><span>' + escape(statusLabel(step.status)) + '</span><span>'
416
529
  + escape(step.step || '') + '</span></li>').join('') + '</ol>' : '';
417
530
  document.getElementById('codexDiff').innerHTML = last?.diff ? '<h3>' + escape(text('Latest turn · Executor-reported changes', '最新一轮 · 执行器报告的变更'))
418
- + '</h3>' + codeBlock(last.diff, text('Diff', '差异'), true) : '';
531
+ + '</h3>' + codeBlock(last.diff, text('Diff', '差异'), true, false, 'review:diff:' + last.id) : '';
419
532
  const review = document.getElementById('codexReview');
420
533
  review.hidden = !last?.diff && !plan.length;
421
534
  bindReadActions(document.getElementById('codexPlan'));
@@ -425,22 +538,28 @@ export function codexPanelScript() {
425
538
  const container = document.getElementById('codexApprovals');
426
539
  window.hccInteractions.remember(container);
427
540
  container.innerHTML = approvals.map((approval, index) => {
541
+ blockScope = 'approval:' + executorId + ':' + approval.requestId; blockOrder = 0;
428
542
  const params = approval.params || {}, fields = [
429
- [text('Operation', '操作'), approval.kind === 'userInput' ? text('Answer questions', '回答问题') : approval.kind === 'permissions' ? text('Grant permissions', '授予权限') : approval.method?.includes('fileChange') ? text('Change files', '修改文件') : text('Run command', '执行命令')],
543
+ [text('Operation', '操作'), approval.kind === 'mcp' ? text('MCP request', 'MCP 请求') : approval.kind === 'userInput' ? text('Answer questions', '回答问题') : approval.kind === 'permissions' ? text('Grant permissions', '授予权限') : approval.method?.includes('fileChange') ? text('Change files', '修改文件') : text('Run command', '执行命令')],
430
544
  [text('Command', '命令'), params.command], [text('Directory', '目录'), params.cwd],
431
- [text('Reason', '原因'), params.reason], [text('Path', '路径'), params.path],
545
+ [text('MCP server', 'MCP 服务'), params.serverName], [text('Reason', '原因'), params.reason || params.message], [text('Path', '路径'), params.path],
432
546
  [text('Permission root', '权限目录'), params.grantRoot]
433
547
  ].filter(([, value]) => value != null && value !== '');
434
- return '<article class="codex-card codex-approval"><h3>' + escape(approval.kind === 'userInput' ? text('Your input is required', '等待您的答复') : text('Approval required', '等待人工审批'))
435
- + '</h3><dl>' + fields.map(([label, value]) => '<dt>' + escape(label) + '</dt><dd>'
436
- + escape(typeof value === 'string' ? value : JSON.stringify(value)) + '</dd>').join('') + '</dl>'
548
+ return '<article class="codex-card codex-approval"><h3>' + escape(window.hccInteractions.requiresInput(approval) ? text('Your input is required', '等待您的答复') : text('Approval required', '等待人工审批'))
549
+ + '</h3><dl>' + fields.map(([label, value], fieldIndex) => {
550
+ const fullValue = typeof value === 'string' ? value : JSON.stringify(value);
551
+ return '<dt>' + escape(label) + '</dt><dd>'
552
+ + (segmentRanges(fullValue).length > 1 ? codeBlock(fullValue, text('Full value', '完整字段'), false, true, blockScope + ':field:' + fieldIndex) : escape(fullValue))
553
+ + '</dd>';
554
+ }).join('') + '</dl>'
437
555
  + (approval.truncated ? '<p class="codex-prose">' + escape(text('Parameters are truncated. Read the full operation before deciding.', '参数已截断,请核对完整操作后再决定。')) + '</p>' : '')
438
556
  + details('approval:' + approval.requestId, '<strong>' + escape(text('Raw parameters', '原始参数')) + '</strong>',
439
- codeBlock(JSON.stringify(params, null, 2), 'JSON'), false)
557
+ codeBlock(JSON.stringify(window.hccInteractions.previewParams(approval), null, 2), 'JSON'), false)
440
558
  + '<div class="hcc-interaction-form">' + window.hccInteractions.form(approval, approvalKey(target, executorId, approval.requestId), escape, text) + '</div>'
441
- + '<div class="codex-approval-actions"><button data-approval="' + index + '" data-decision="accept" data-truncated="' + !!approval.truncated + '" data-request-key="' + escape(approvalKey(target, executorId, approval.requestId)) + '" type="button" class="primary">'
442
- + escape(approval.kind === 'userInput' ? text('Send answers', '提交答复') : approval.kind === 'permissions' ? text('Grant selected permissions', '授予所选权限') : text('Approve once', '批准本次')) + '</button><button data-approval="' + index + '" data-decision="' + (approval.kind === 'userInput' ? 'cancel' : 'decline') + '" data-request-key="' + escape(approvalKey(target, executorId, approval.requestId)) + '" type="button">'
443
- + escape(approval.kind === 'userInput' ? text('Cancel questions', '取消问答') : text('Decline', '拒绝')) + '</button></div></article>';
559
+ + '<div class="codex-approval-actions"><button data-approval="' + index + '" data-decision="accept" data-truncated="' + window.hccInteractions.approvalBlocked(approval) + '" data-request-key="' + escape(approvalKey(target, executorId, approval.requestId)) + '" type="button" class="primary">'
560
+ + escape(window.hccInteractions.acceptLabel(approval, text)) + '</button><button data-approval="' + index + '" data-decision="' + (approval.kind === 'userInput' ? 'cancel' : 'decline') + '" data-request-key="' + escape(approvalKey(target, executorId, approval.requestId)) + '" type="button">'
561
+ + escape(approval.kind === 'userInput' ? text('Cancel questions', '取消问答') : text('Decline', '拒绝')) + '</button>'
562
+ + (approval.kind === 'mcp' ? '<button data-approval="' + index + '" data-decision="cancel" data-request-key="' + escape(approvalKey(target, executorId, approval.requestId)) + '" type="button">' + escape(text('Cancel request', '取消请求')) + '</button>' : '') + '</div></article>';
444
563
  }).join('');
445
564
  window.hccInteractions.restore(container);
446
565
  bindReadActions(container);
@@ -452,11 +571,18 @@ export function codexPanelScript() {
452
571
  const requestKey = approvalKey(target, executorId, approval.requestId);
453
572
  approving.add(requestKey); sync();
454
573
  const requestTarget = { ...target, token: bridge().actionToken, epoch: bridge().epoch };
455
- try { await action('approve', { executorId, threadId: approval.threadId,
574
+ let authorization;
575
+ try {
576
+ const payload = window.hccInteractions.payload(approval, ['userInput','permissions','mcp'].includes(approval.kind) ? button.closest('article') : null, button.dataset.decision);
577
+ authorization = window.hccInteractions.prepareUrl(approval, button.dataset.decision, text);
578
+ await action('approve', { executorId, threadId: approval.threadId,
456
579
  turnId: approval.turnId, requestId: approval.requestId,
457
- ...window.hccInteractions.payload(approval, ['userInput','permissions'].includes(approval.kind) ? button.closest('article') : null, button.dataset.decision) }, requestTarget);
458
- window.hccInteractions.forget(requestKey); }
459
- catch (error) { if (matches(target)) message(error.detail || error.message); }
580
+ ...payload }, requestTarget);
581
+ window.hccInteractions.forget(requestKey);
582
+ const opened = authorization?.complete();
583
+ if (opened && matches(target)) message(opened);
584
+ }
585
+ catch (error) { authorization?.cancel(); if (matches(target)) message(error.detail || error.message); }
460
586
  finally { approving.delete(requestKey); if (matches(target)) sync(); }
461
587
  });
462
588
  });
@@ -533,12 +659,15 @@ export function codexPanelScript() {
533
659
  });
534
660
  document.getElementById('codexRecover').addEventListener('click', async () => {
535
661
  const b = bridge(), target = subject();
662
+ const subjectVersion = subjectRevision, sequence = ++readSequence;
663
+ const current = () => matches(target) && subjectVersion === subjectRevision && sequence === readSequence;
536
664
  try {
537
665
  await b.api(route(target, 'read'));
538
- if (!matches(target)) return;
666
+ if (!current()) return;
667
+ const version = stateRevision;
539
668
  const result = await b.api(route(target, 'state'));
540
- if (matches(target)) render(result.state);
541
- } catch (error) { if (matches(target)) message(error.detail || error.message); }
669
+ if (current() && version === stateRevision) render(result.state);
670
+ } catch (error) { if (current()) message(error.detail || error.message); }
542
671
  });
543
672
  document.getElementById('codexClear').addEventListener('click', () => {
544
673
  if (!confirm(text('Clear this draft and its unconfirmed submission marker? Check thread history first.', '清除草稿及未确认提交标记?请先核对会话历史。'))) return;