@logicseek/hello-cc 1.0.2 → 1.0.4

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 (40) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/docs/dsh.md +3 -3
  3. package/docs/dsh.zh-CN.md +3 -3
  4. package/lib/cli/app.mjs +1 -1
  5. package/lib/cli/commands/hook.mjs +3 -1
  6. package/lib/cli/commands/lifecycle.mjs +14 -3
  7. package/lib/cli/commands/query.mjs +15 -10
  8. package/lib/cli/commands/run.mjs +7 -6
  9. package/lib/db/connection.mjs +82 -9
  10. package/lib/discover.mjs +4 -4
  11. package/lib/integrations/hooks.mjs +132 -40
  12. package/lib/integrations/peers/identity.mjs +42 -7
  13. package/lib/process/identity.mjs +36 -15
  14. package/lib/runtime/client.mjs +9 -1
  15. package/lib/runtime/native/service.mjs +107 -24
  16. package/lib/runtime/native/store.mjs +63 -6
  17. package/lib/runtime/project-path.mjs +90 -7
  18. package/lib/shared/file-lock.mjs +62 -17
  19. package/lib/shared/socket-lock-worker.mjs +15 -6
  20. package/lib/web/codex-app-server.mjs +19 -1
  21. package/lib/web/codex-sessions.mjs +2 -0
  22. package/lib/web/cookie-auth.mjs +1 -1
  23. package/lib/web/http-routes.mjs +11 -7
  24. package/lib/web/http.mjs +20 -2
  25. package/lib/web/native-sessions.mjs +13 -6
  26. package/lib/web/peer-actions.mjs +27 -27
  27. package/lib/web/runtime-main.mjs +24 -7
  28. package/lib/web/runtime.mjs +18 -4
  29. package/lib/web/session-serialize.mjs +1 -1
  30. package/lib/web/startup.mjs +84 -10
  31. package/lib/web/task-results.mjs +8 -5
  32. package/lib/web/terminal-assets.mjs +18 -0
  33. package/lib/web/tmux-client-snapshot.mjs +85 -0
  34. package/lib/web/tmux-stream.mjs +3 -4
  35. package/lib/web/ui-codex.mjs +137 -20
  36. package/lib/web/ui-interactions.mjs +37 -3
  37. package/lib/web/ui-native.mjs +21 -9
  38. package/lib/web/ui-template.mjs +8 -2
  39. package/package.json +1 -1
  40. package/scripts/regression.mjs +212 -47
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { selectCurrentTask } from '../core/coordination/automation.mjs';
3
3
  import { listTaskResults, recordTaskResult, summarizeTaskResults } from '../core/coordination/results.mjs';
4
4
  import { CliError } from '../shared/errors.mjs';
5
+ import { redactSecrets } from '../shared/redact.mjs';
5
6
 
6
7
  export function createTaskResults({ connectWebProject, resolveSessionPeerId, now, addEvent }) {
7
8
  const turnScopes = new WeakMap();
@@ -31,7 +32,7 @@ export function createTaskResults({ connectWebProject, resolveSessionPeerId, now
31
32
  } finally { db.close(); }
32
33
  }
33
34
  function observeCodexResults(session, state) {
34
- if (!session.binding?.provider_session_id || state.executorId !== session.adapter?.snapshot().executorId) return;
35
+ if (!session.binding?.provider_session_id || state.executorId !== (session.executorId || session.adapter?.snapshot().executorId)) return;
35
36
  const thread = state.threads?.find(t => t.id === session.binding.provider_session_id);
36
37
  if (!thread?.turns?.length) return;
37
38
  const db = connectWebProject(session.ctx);
@@ -54,14 +55,16 @@ export function createTaskResults({ connectWebProject, resolveSessionPeerId, now
54
55
  const exitCode = item.exitCode;
55
56
  const status = exitCode === 0 && item.status === 'completed' ? 'passed'
56
57
  : item.status === 'failed' || item.status === 'declined' || (Number.isSafeInteger(exitCode) && exitCode !== 0) ? 'failed' : 'pending';
58
+ const command = redactSecrets(String(item.command || ''));
59
+ const output = redactSecrets(String(item.aggregatedOutput || ''));
57
60
  recordTaskResult(db, { ...common, itemId: item.id, kind: 'command', status, exitCode,
58
61
  resultKey: `${state.executorId}:${turn.id}:${item.id}:command`,
59
- title: String(item.command || 'Command execution').slice(0, 300),
60
- command: String(item.command || '').slice(0, 64000),
61
- details: String(item.aggregatedOutput || '').slice(0, 64000), evidence: [] }, { now, addEvent });
62
+ title: (command || 'Command execution').slice(0, 300),
63
+ command: command.slice(0, 64000),
64
+ details: output.slice(0, 64000), evidence: [] }, { now, addEvent });
62
65
  }
63
66
  if (turn.diff && ['completed', 'failed', 'interrupted'].includes(turn.status)) {
64
- const diff = String(turn.diff).slice(0, 64000);
67
+ const diff = redactSecrets(String(turn.diff)).slice(0, 64000);
65
68
  recordTaskResult(db, { ...common, kind: 'diff', status: 'pending', title: 'Turn changes awaiting review', diff,
66
69
  resultKey: `${state.executorId}:${turn.id}:diff:${createHash('sha256').update(diff).digest('hex')}` }, { now, addEvent });
67
70
  }
@@ -0,0 +1,18 @@
1
+ import path from 'node:path';
2
+ import { createRequire } from 'node:module';
3
+
4
+ const require = createRequire(import.meta.url);
5
+ const packages = Object.freeze({
6
+ 'xterm.js': '@xterm/xterm',
7
+ 'xterm.css': '@xterm/xterm',
8
+ 'addon-fit.js': '@xterm/addon-fit',
9
+ 'addon-search.js': '@xterm/addon-search'
10
+ });
11
+
12
+ // npm may place dependencies above the HCC package directory. Resolve from
13
+ // this module's installation rather than assuming a nested node_modules.
14
+ export function terminalAssetPath(name) {
15
+ if (!Object.hasOwn(packages, name)) throw new TypeError('Unknown terminal asset');
16
+ const entry = require.resolve(packages[name]);
17
+ return name === 'xterm.css' ? path.resolve(path.dirname(entry), '..', 'css', name) : entry;
18
+ }
@@ -0,0 +1,85 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { performance } from 'node:perf_hooks';
3
+
4
+ const UNKNOWN = Object.freeze({ state: 'unknown', count: null });
5
+ const MAX_AGE_MS = 5_000;
6
+ const REFRESH_AFTER_MS = 2_000;
7
+
8
+ function parseSnapshot(stdout) {
9
+ const rows = new Map();
10
+ for (const line of String(stdout).split(/\r?\n/).filter(Boolean)) {
11
+ const match = /^(%\d+)\|(\$\d+)\|(\d+)$/.exec(line);
12
+ if (!match || !Number.isSafeInteger(Number(match[3]))) throw new Error('Invalid tmux client snapshot');
13
+ const [, pane, session, countText] = match;
14
+ const count = Number(countText);
15
+ const previous = rows.get(pane);
16
+ // Linked windows may repeat a pane within or across sessions. A display
17
+ // hint must not guess which session's local clients control that pane.
18
+ if (rows.has(pane) && (!previous || previous.session !== session || previous.count !== count)) {
19
+ rows.set(pane, null);
20
+ } else if (!rows.has(pane)) {
21
+ rows.set(pane, { session, count });
22
+ }
23
+ }
24
+ return rows;
25
+ }
26
+
27
+ // Display-only observations. Destructive actions must continue to query live
28
+ // clients through strictTmuxClientObservation and verify their ownership.
29
+ // One asynchronous read covers every pane; serialization never waits for tmux.
30
+ export function createTmuxClientSnapshot({ run = execFile, now = () => performance.now() } = {}) {
31
+ let rows = new Map();
32
+ let observedAt = -Infinity;
33
+ let nextRefreshAt = -Infinity;
34
+ let pending = null;
35
+ let closed = false;
36
+
37
+ function refresh(at) {
38
+ if (closed || pending || at < nextRefreshAt) return;
39
+ const operation = { controller: new globalThis.AbortController() };
40
+ pending = operation;
41
+ nextRefreshAt = at + REFRESH_AFTER_MS;
42
+ const complete = (error, stdout) => {
43
+ if (pending !== operation) return;
44
+ pending = null;
45
+ if (closed || operation.controller.signal.aborted) return;
46
+ nextRefreshAt = now() + REFRESH_AFTER_MS;
47
+ try {
48
+ if (error) throw error;
49
+ rows = parseSnapshot(stdout);
50
+ observedAt = at;
51
+ } catch {
52
+ rows = new Map();
53
+ observedAt = -Infinity;
54
+ }
55
+ };
56
+ try {
57
+ run('tmux', ['list-panes', '-a', '-F', '#{pane_id}|#{session_id}|#{session_attached}'], {
58
+ encoding: 'utf8', timeout: 1_000, maxBuffer: 1024 * 1024,
59
+ signal: operation.controller.signal
60
+ }, complete);
61
+ } catch (error) {
62
+ complete(error);
63
+ }
64
+ }
65
+
66
+ function observe(session) {
67
+ if (closed || session?.type !== 'tmux' || !/^%\d+$/.test(session.pane || '')) return UNKNOWN;
68
+ const at = now();
69
+ refresh(at);
70
+ const row = rows.get(session.pane);
71
+ if (!row || at - observedAt >= MAX_AGE_MS) return UNKNOWN;
72
+ return { state: 'known', count: row.count };
73
+ }
74
+
75
+ function close() {
76
+ closed = true;
77
+ rows = new Map();
78
+ observedAt = -Infinity;
79
+ const operation = pending;
80
+ pending = null;
81
+ operation?.controller.abort();
82
+ }
83
+
84
+ return { observe, close };
85
+ }
@@ -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; }
@@ -71,6 +76,8 @@ export function codexPanelHtml() {
71
76
  .codex-header, .codex-scroll, .codex-composer { padding-left:12px; padding-right:12px; }
72
77
  .codex-card { padding:10px; } .codex-card-agent { padding:2px; }
73
78
  .codex-composer-actions button { min-height:36px; }
79
+ .codex-segment-nav span { flex-basis:100%; }
80
+ .codex-segment-nav button { min-height:44px; flex:1; }
74
81
  .codex-approval dl { grid-template-columns:1fr; gap:3px; } .codex-approval dd { margin-bottom:6px; }
75
82
  }
76
83
  </style>
@@ -102,8 +109,11 @@ export function codexPanelScript() {
102
109
  const views = new Map();
103
110
  const approving = new Set();
104
111
  const PAGE_SIZE = 80;
112
+ const SEGMENT_CHARS = 12000, SEGMENT_LINES = 200;
105
113
  let copies = [], timelineHtml = '', timelineSignature = '', timelineCopies = [], entries = [];
114
+ let blocks = [], timelineBlocks = [], blockScope = '', blockOrder = 0;
106
115
  let state = null, key = '', pending = null, submitting = false;
116
+ let stateRevision = 0, subjectRevision = 0, readSequence = 0;
107
117
  const text = (en, zh) => window.hccUi.language === 'zh' ? zh : en;
108
118
  const bridge = () => window.hccHandoff;
109
119
  const escape = (value) => bridge().esc(String(value ?? ''));
@@ -118,7 +128,7 @@ export function codexPanelScript() {
118
128
  function turnId() { return state?.turnId || activeThread()?.activeTurnId || ''; }
119
129
  function view() {
120
130
  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 });
131
+ 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
132
  if (views.size > 20) views.delete(views.keys().next().value);
123
133
  }
124
134
  return views.get(key);
@@ -145,16 +155,57 @@ export function codexPanelScript() {
145
155
  };
146
156
  return labels[status] || String(status || '');
147
157
  }
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 => {
158
+ function segmentRanges(content) {
159
+ const ranges = [];
160
+ let start = 0;
161
+ while (start < content.length) {
162
+ let end = Math.min(content.length, start + SEGMENT_CHARS), cursor = start;
163
+ for (let lines = 0; lines < SEGMENT_LINES; lines += 1) {
164
+ const newline = content.indexOf('\n', cursor);
165
+ if (newline === -1 || newline >= end) break;
166
+ cursor = newline + 1;
167
+ if (lines === SEGMENT_LINES - 1) end = cursor;
168
+ }
169
+ // A page boundary must not split an emoji or another surrogate pair.
170
+ if (end < content.length && /[\uD800-\uDBFF]/.test(content[end - 1]) && /[\uDC00-\uDFFF]/.test(content[end])) end -= 1;
171
+ ranges.push([start, end]); start = end;
172
+ }
173
+ return ranges;
174
+ }
175
+ function codeBody(content, diff) {
176
+ return diff ? content.split('\n').map(line => {
151
177
  const cls = line.startsWith('@@') || line.startsWith('diff ') || line.startsWith('+++') || line.startsWith('---')
152
178
  ? 'codex-diff-header' : line.startsWith('+') ? 'codex-diff-add' : line.startsWith('-') ? 'codex-diff-remove' : '';
153
179
  return '<span class="' + cls + '">' + escape(line) + '</span>';
154
180
  }).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>';
181
+ }
182
+ function segmentBody(block, index) {
183
+ const [start, end] = block.ranges[block.page];
184
+ const button = (action, label, disabled) => '<button type="button" data-segment="' + index + '" data-segment-action="' + action + '"'
185
+ + (disabled ? ' disabled' : '') + '>' + escape(label) + '</button>';
186
+ return '<nav class="codex-segment-nav" aria-label="' + escape(text('Text sections', '内容分段')) + '"><span role="status" tabindex="-1">'
187
+ + escape(text('Section ', '第 ') + (block.page + 1) + text(' of ', ' / ') + block.ranges.length + text(' · Full text available', ' 段 · 可查阅全文'))
188
+ + '</span>' + button('first', text('First', '首段'), block.page === 0)
189
+ + button('previous', text('Previous', '上一段'), block.page === 0)
190
+ + button('next', text('Next', '下一段'), block.page === block.ranges.length - 1)
191
+ + button('last', text('Last', '末段'), block.page === block.ranges.length - 1)
192
+ + '</nav><pre tabindex="0" data-code-page="' + block.page + '"><code>' + codeBody(block.content.slice(start, end), block.diff) + '</code></pre>';
193
+ }
194
+ function codeBlock(value, language = '', diff = false, plain = false, identityScope = '') {
195
+ const content = String(value ?? ''), index = copies.push(content) - 1;
196
+ const ranges = segmentRanges(content), identity = identityScope || blockScope + ':' + blockOrder++;
197
+ let body, blockIndex = -1;
198
+ if (ranges.length > 1) {
199
+ blockIndex = blocks.length;
200
+ const page = Math.min(view().textPages.get(identity) || 0, ranges.length - 1);
201
+ const block = { content, ranges, diff, page, identity };
202
+ blocks.push(block);
203
+ body = '<div data-segment-content>' + segmentBody(block, blockIndex) + '</div>';
204
+ } else body = '<pre data-code-page="0"><code>' + codeBody(content, diff) + '</code></pre>';
205
+ return '<div class="codex-code' + (plain ? ' codex-code-prose' : '') + '" data-code-key="' + escape(identity) + '"' + (blockIndex < 0 ? '' : ' data-segment-block="' + blockIndex + '"')
206
+ + '><div class="codex-code-header"><span>' + escape(language || text('Text', '文本'))
207
+ + '</span><button type="button" data-copy="' + index + '">' + escape(ranges.length > 1 ? text('Copy full text', '复制全文') : text('Copy', '复制'))
208
+ + '</button></div>' + body + '</div>';
158
209
  }
159
210
  function inline(value, allowBold = true) {
160
211
  const tick = String.fromCharCode(96), source = String(value), fragments = [];
@@ -225,7 +276,9 @@ export function codexPanelScript() {
225
276
  return '<div class="codex-markdown">' + parts.join('') + '</div>';
226
277
  }
227
278
  function prose(value) {
228
- const fence = String.fromCharCode(96).repeat(3), lines = String(value ?? '').split('\n');
279
+ const content = String(value ?? '');
280
+ if (segmentRanges(content).length > 1) return codeBlock(content, text('Long message · Plain-text sections', '长消息 · 纯文本分段'), false, true);
281
+ const fence = String.fromCharCode(96).repeat(3), lines = content.split('\n');
229
282
  let html = '', plain = [], code = [], language = '', inCode = false;
230
283
  const flush = () => { if (plain.length) html += markdown(plain.join('\n')); plain = []; };
231
284
  for (const line of lines) {
@@ -250,6 +303,7 @@ export function codexPanelScript() {
250
303
  }
251
304
  function itemCard(item, turn, index) {
252
305
  const type = item.type || '', id = turn.id + ':' + (item.id || index), content = itemText(item);
306
+ blockScope = 'item:' + id; blockOrder = 0;
253
307
  const error = item.status === 'failed' || item.error || (item.exitCode != null && item.exitCode !== 0);
254
308
  const status = '<span class="codex-status' + (error ? ' codex-status-failed' : '') + '">' + escape(error ? text('Failed', '失败') : statusLabel(item.status)) + '</span>';
255
309
  if (type === 'userMessage' || type === 'agentMessage') {
@@ -297,14 +351,64 @@ export function codexPanelScript() {
297
351
  node.addEventListener('toggle', () => { if (key === currentKey && node.isConnected !== false) view().details.set(node.dataset.detailsKey, node.open); });
298
352
  });
299
353
  container.querySelectorAll('button[data-copy]').forEach(button => {
300
- const content = copies[Number(button.dataset.copy)];
301
354
  button.addEventListener('click', async () => {
355
+ if (key !== currentKey || button.isConnected === false) return;
356
+ // A hidden section may change while the visible HTML stays identical.
357
+ const content = copies[Number(button.dataset.copy)];
302
358
  try {
303
359
  await navigator.clipboard.writeText(content);
304
360
  button.textContent = text('Copied', '已复制');
305
361
  } catch (_) { button.textContent = text('Select text to copy', '请选择文本复制'); }
306
362
  });
307
363
  });
364
+ bindSegmentActions(container);
365
+ bindCodeScroll(container);
366
+ }
367
+ function bindCodeScroll(container) {
368
+ const currentKey = key;
369
+ container.querySelectorAll('pre[data-code-page]').forEach(pre => {
370
+ const identity = pre.closest('[data-code-key]')?.dataset.codeKey;
371
+ if (!identity) return;
372
+ const page = Number(pre.dataset.codePage), saved = view().codeScroll.get(identity);
373
+ if (saved?.page === page) { pre.scrollTop = saved.top; pre.scrollLeft = saved.left; }
374
+ // Scroll events from restoration are asynchronous. Ignore unchanged
375
+ // coordinates so restoring a block never opts out of follow mode.
376
+ let top = pre.scrollTop, left = pre.scrollLeft;
377
+ pre.addEventListener('scroll', () => {
378
+ if (key !== currentKey || pre.isConnected === false || (top === pre.scrollTop && left === pre.scrollLeft)) return;
379
+ top = pre.scrollTop; left = pre.scrollLeft;
380
+ const reading = view();
381
+ reading.codeScroll.set(identity, { page, top, left });
382
+ if (reading.codeScroll.size > 256) reading.codeScroll.delete(reading.codeScroll.keys().next().value);
383
+ if (top || left) { reading.follow = false; reading.top = scroller.scrollTop; updateJump(); }
384
+ });
385
+ });
386
+ }
387
+ function bindSegmentActions(container) {
388
+ const currentKey = key;
389
+ container.querySelectorAll('button[data-segment]').forEach(button => {
390
+ const index = Number(button.dataset.segment);
391
+ button.addEventListener('click', () => {
392
+ const block = blocks[index];
393
+ if (key !== currentKey || button.isConnected === false || !block) return;
394
+ const action = button.dataset.segmentAction;
395
+ block.page = Math.max(0, Math.min(block.ranges.length - 1,
396
+ action === 'first' ? 0 : action === 'last' ? block.ranges.length - 1 : block.page + (action === 'next' ? 1 : -1)));
397
+ view().textPages.set(block.identity, block.page);
398
+ view().codeScroll.delete(block.identity);
399
+ if (view().textPages.size > 256) view().textPages.delete(view().textPages.keys().next().value);
400
+ const wrapper = button.closest('[data-segment-block]'), body = wrapper.querySelector('[data-segment-content]');
401
+ const top = wrapper.getBoundingClientRect().top, oldTop = scroller.scrollTop;
402
+ view().follow = false;
403
+ body.innerHTML = segmentBody(block, index);
404
+ bindSegmentActions(body);
405
+ bindCodeScroll(body);
406
+ const focus = body.querySelector('button[data-segment-action="' + action + '"]:not(:disabled)') || body.querySelector('[role="status"]');
407
+ focus?.focus({ preventScroll: true });
408
+ scroller.scrollTop = oldTop + wrapper.getBoundingClientRect().top - top;
409
+ view().top = scroller.scrollTop; updateJump();
410
+ });
411
+ });
308
412
  }
309
413
  function renderTimeline(turns, reading) {
310
414
  // Page by items rather than turns: a single tool-heavy turn can contain hundreds of cards.
@@ -327,7 +431,7 @@ export function codexPanelScript() {
327
431
  if (!reading.follow && reading.signature && reading.signature !== updateSignature) reading.unread = true;
328
432
  reading.signature = updateSignature;
329
433
  if (signature !== timelineSignature) {
330
- copies = [];
434
+ copies = []; blocks = [];
331
435
  let previousTurn = null;
332
436
  let html = visible.map(({ turn, turnIndex, item, index }) => {
333
437
  let heading = '';
@@ -344,8 +448,8 @@ export function codexPanelScript() {
344
448
  document.getElementById('codexTimeline').innerHTML = html;
345
449
  timelineHtml = html; bindReadActions(document.getElementById('codexTimeline'));
346
450
  }
347
- timelineSignature = signature; timelineCopies = copies.slice();
348
- } else copies = timelineCopies.slice();
451
+ timelineSignature = signature; timelineCopies = copies.slice(); timelineBlocks = blocks.slice();
452
+ } else { copies = timelineCopies.slice(); blocks = timelineBlocks.slice(); }
349
453
  document.getElementById('codexHistory').hidden = entries.length <= PAGE_SIZE;
350
454
  document.getElementById('codexHistory').setAttribute('aria-label', text('Conversation history', '会话历史'));
351
455
  document.getElementById('codexEarlier').textContent = text('Earlier', '更早记录');
@@ -358,9 +462,10 @@ export function codexPanelScript() {
358
462
  function sync() {
359
463
  const next = draftKey();
360
464
  if (next !== key) {
465
+ subjectRevision += 1;
361
466
  if (key && state) view().top = scroller.scrollTop || 0;
362
467
  key = next; pending = null; draft.value = ''; state = null;
363
- timelineHtml = ''; timelineSignature = ''; timelineCopies = []; copies = []; entries = [];
468
+ timelineHtml = ''; timelineSignature = ''; timelineCopies = []; copies = []; entries = []; blocks = []; timelineBlocks = [];
364
469
  document.getElementById('codexHistory').hidden = true;
365
470
  for (const id of ['codexStatus', 'codexTimeline', 'codexDiff', 'codexPlan', 'codexApprovals', 'codexSubmission']) document.getElementById(id).textContent = '';
366
471
  document.getElementById('codexReview').open = view().details.get('review') || false;
@@ -397,7 +502,10 @@ export function codexPanelScript() {
397
502
  { method: 'POST', body: JSON.stringify({ ...body, action_token: target.token, epoch: target.epoch }) });
398
503
  }
399
504
  function render(value, resetScroll = false) {
400
- sync(); state = value; sync();
505
+ sync();
506
+ // Local paging and preference redraws reuse state; incoming snapshots do not.
507
+ if (value !== state) stateRevision += 1;
508
+ state = value; sync();
401
509
  const thread = activeThread();
402
510
  document.getElementById('codexStatus').textContent = [text('Executor', '执行器') + ': ' + statusLabel(state.status),
403
511
  text('Thread', '会话') + ': ' + (thread?.id || ''), turnId() ? text('Turn', '当前轮') + ': ' + turnId() : text('Idle', '空闲'),
@@ -410,12 +518,13 @@ export function codexPanelScript() {
410
518
  renderTimeline(turns, reading);
411
519
  const last = turns[turns.length - 1];
412
520
  const plan = Array.isArray(last?.plan) ? last.plan : [];
521
+ blockScope = 'plan:' + last?.id; blockOrder = 0;
413
522
  document.getElementById('codexPlan').innerHTML = plan.length ? '<h3>' + escape(text('Plan', '执行计划')) + '</h3>'
414
523
  + (last.explanation ? prose(last.explanation) : '') + '<ol class="codex-plan">' + plan.map(step =>
415
524
  '<li data-status="' + escape(step.status || '') + '"><span>' + escape(statusLabel(step.status)) + '</span><span>'
416
525
  + escape(step.step || '') + '</span></li>').join('') + '</ol>' : '';
417
526
  document.getElementById('codexDiff').innerHTML = last?.diff ? '<h3>' + escape(text('Latest turn · Executor-reported changes', '最新一轮 · 执行器报告的变更'))
418
- + '</h3>' + codeBlock(last.diff, text('Diff', '差异'), true) : '';
527
+ + '</h3>' + codeBlock(last.diff, text('Diff', '差异'), true, false, 'review:diff:' + last.id) : '';
419
528
  const review = document.getElementById('codexReview');
420
529
  review.hidden = !last?.diff && !plan.length;
421
530
  bindReadActions(document.getElementById('codexPlan'));
@@ -425,6 +534,7 @@ export function codexPanelScript() {
425
534
  const container = document.getElementById('codexApprovals');
426
535
  window.hccInteractions.remember(container);
427
536
  container.innerHTML = approvals.map((approval, index) => {
537
+ blockScope = 'approval:' + executorId + ':' + approval.requestId; blockOrder = 0;
428
538
  const params = approval.params || {}, fields = [
429
539
  [text('Operation', '操作'), approval.kind === 'userInput' ? text('Answer questions', '回答问题') : approval.kind === 'permissions' ? text('Grant permissions', '授予权限') : approval.method?.includes('fileChange') ? text('Change files', '修改文件') : text('Run command', '执行命令')],
430
540
  [text('Command', '命令'), params.command], [text('Directory', '目录'), params.cwd],
@@ -432,8 +542,12 @@ export function codexPanelScript() {
432
542
  [text('Permission root', '权限目录'), params.grantRoot]
433
543
  ].filter(([, value]) => value != null && value !== '');
434
544
  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>'
545
+ + '</h3><dl>' + fields.map(([label, value], fieldIndex) => {
546
+ const fullValue = typeof value === 'string' ? value : JSON.stringify(value);
547
+ return '<dt>' + escape(label) + '</dt><dd>'
548
+ + (segmentRanges(fullValue).length > 1 ? codeBlock(fullValue, text('Full value', '完整字段'), false, true, blockScope + ':field:' + fieldIndex) : escape(fullValue))
549
+ + '</dd>';
550
+ }).join('') + '</dl>'
437
551
  + (approval.truncated ? '<p class="codex-prose">' + escape(text('Parameters are truncated. Read the full operation before deciding.', '参数已截断,请核对完整操作后再决定。')) + '</p>' : '')
438
552
  + details('approval:' + approval.requestId, '<strong>' + escape(text('Raw parameters', '原始参数')) + '</strong>',
439
553
  codeBlock(JSON.stringify(params, null, 2), 'JSON'), false)
@@ -533,12 +647,15 @@ export function codexPanelScript() {
533
647
  });
534
648
  document.getElementById('codexRecover').addEventListener('click', async () => {
535
649
  const b = bridge(), target = subject();
650
+ const subjectVersion = subjectRevision, sequence = ++readSequence;
651
+ const current = () => matches(target) && subjectVersion === subjectRevision && sequence === readSequence;
536
652
  try {
537
653
  await b.api(route(target, 'read'));
538
- if (!matches(target)) return;
654
+ if (!current()) return;
655
+ const version = stateRevision;
539
656
  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); }
657
+ if (current() && version === stateRevision) render(result.state);
658
+ } catch (error) { if (current()) message(error.detail || error.message); }
542
659
  });
543
660
  document.getElementById('codexClear').addEventListener('click', () => {
544
661
  if (!confirm(text('Clear this draft and its unconfirmed submission marker? Check thread history first.', '清除草稿及未确认提交标记?请先核对会话历史。'))) return;
@@ -2,7 +2,7 @@
2
2
  export function interactionPanelScript() {
3
3
  return String.raw`(() => {
4
4
  if (window.hccInteractions) return;
5
- const drafts = new Map();
5
+ const drafts = new Map(), expanded = new Map();
6
6
  let focus = null;
7
7
  function remember(container) {
8
8
  container.querySelectorAll('[data-interaction-field]').forEach(control => {
@@ -10,6 +10,8 @@ export function interactionPanelScript() {
10
10
  drafts.set(control.dataset.interactionField, { value: control.value, checked: control.checked });
11
11
  if (control === document.activeElement) focus = { key: control.dataset.interactionField, start: control.selectionStart, end: control.selectionEnd };
12
12
  });
13
+ container.querySelectorAll('[data-interaction-details]').forEach(detail => expanded.set(detail.dataset.interactionDetails, detail.open));
14
+ while (expanded.size > 256) expanded.delete(expanded.keys().next().value);
13
15
  while (drafts.size > 256) drafts.delete(drafts.keys().next().value);
14
16
  }
15
17
  function restore(container) {
@@ -22,6 +24,38 @@ export function interactionPanelScript() {
22
24
  });
23
25
  for (const key of drafts.keys()) if (!live.has(key)) drafts.delete(key);
24
26
  if (focus && !live.has(focus.key)) focus = null;
27
+ const detailKeys = new Set();
28
+ container.querySelectorAll('[data-interaction-details]').forEach(detail => {
29
+ const key = detail.dataset.interactionDetails; detailKeys.add(key);
30
+ if (expanded.has(key)) detail.open = expanded.get(key);
31
+ });
32
+ for (const key of expanded.keys()) if (!detailKeys.has(key)) expanded.delete(key);
33
+ }
34
+ function approvalBlocked(request) {
35
+ const tool = request.params?.toolCall;
36
+ return Boolean(request.truncated || (request.method === 'session/request_permission' &&
37
+ (!tool || tool.contextPending || tool.contextTruncated || !Object.hasOwn(tool, 'rawInput') ||
38
+ !request.params.options?.some(option => option.kind === 'allow_once'))));
39
+ }
40
+ function preview(request, key, esc, text) {
41
+ const params = request.params || {}, tool = params.toolCall || {}, input = params.input || tool.rawInput || {};
42
+ const rows = [], row = (label, value) => { if (typeof value === 'string' && value) rows.push('<dt>' + esc(label) + '</dt><dd>' + esc(value) + '</dd>'); };
43
+ const name = params.tool || tool.title;
44
+ row(text('Tool', '工具'), name);
45
+ row(text('Command', '命令'), params.command || input.command);
46
+ row(text('File', '文件'), input.file_path || input.path || params.path || params.grantRoot);
47
+ row(text('Working directory', '工作目录'), params.cwd || input.cwd);
48
+ row(text('Reason', '原因'), params.reason || params.message);
49
+ const content = typeof input.content === 'string' ? input.content : null;
50
+ const previewLimit = 1024;
51
+ const contentPreview = content === null ? '' : '<p>' + esc(text('Content preview', '内容预览')) + '</p><pre class="hcc-interaction-content">' + esc(content.slice(0, previewLimit)) + '</pre>'
52
+ + (content.length > previewLimit ? '<p>' + esc(text('Preview shortened; expand the full operation details to review all content.', '预览已缩短,展开完整操作详情可核对全部内容。')) + '</p>' : '');
53
+ const known = rows.length || ['permissions', 'userInput'].includes(request.kind);
54
+ const missingContext = request.method === 'session/request_permission' && approvalBlocked(request);
55
+ return (rows.length ? '<dl class="hcc-interaction-summary">' + rows.join('') + '</dl>' : '') + contentPreview
56
+ + (missingContext ? '<p role="alert">' + esc(text('The operation input or one-time approval option is unavailable. Only rejection is available.', '操作输入或单次批准选项不可用,目前只能拒绝。')) + '</p>' : '')
57
+ + (request.kind === 'approval' && !missingContext ? '<p>' + esc(text('Approval applies only to this operation. Later requests require another decision.', '仅批准本次操作,后续请求仍需单独确认。')) + '</p>' : '')
58
+ + '<details class="hcc-interaction-details" data-interaction-details="' + esc(key) + '"' + (known ? '' : ' open') + '><summary>' + esc(text('Full operation details', '完整操作详情')) + '</summary><pre>' + esc(JSON.stringify(params, null, 2)) + '</pre></details>';
25
59
  }
26
60
  function form(request, key, esc, text) {
27
61
  const field = suffix => ' data-interaction-field="' + esc(key + ':' + suffix) + '"';
@@ -72,7 +106,7 @@ export function interactionPanelScript() {
72
106
  }
73
107
  return { decision, permissions, scope: container.querySelector('[data-scope]').value };
74
108
  }
75
- function forget(key) { for (const field of drafts.keys()) if (field.startsWith(key + ':')) drafts.delete(field); }
76
- window.hccInteractions = { form, payload, remember, restore, forget };
109
+ function forget(key) { for (const field of drafts.keys()) if (field.startsWith(key + ':')) drafts.delete(field); expanded.delete(key); }
110
+ window.hccInteractions = { preview, approvalBlocked, form, payload, remember, restore, forget };
77
111
  })();`;
78
112
  }
@@ -15,6 +15,9 @@ export function nativePanelHtml() {
15
15
  .native-receipt { border:1px solid var(--border); border-radius:7px; padding:9px; font-size:var(--small-font); overflow-wrap:anywhere; }
16
16
  .native-composer { border-top:1px solid var(--border); max-height:45%; overflow:auto; }
17
17
  #nativeDraft { width:100%; min-height:70px; max-height:170px; resize:vertical; padding:10px; font:inherit; background:var(--input-bg); color:var(--text); border:1px solid var(--border); border-radius:7px; }
18
+ .hcc-interaction-content,.hcc-interaction-details pre { white-space:pre-wrap; overflow-wrap:anywhere; max-height:180px; overflow:auto; margin:6px 0; }
19
+ .hcc-interaction-details { margin:8px 0; }
20
+ .hcc-interaction-details summary { cursor:pointer; color:var(--muted); }
18
21
  .native-controls { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-top:8px; }
19
22
  .native-controls label { display:flex; gap:6px; align-items:center; font-size:var(--small-font); }
20
23
  #nativeNotice { color:var(--muted); font-size:var(--small-font); overflow-wrap:anywhere; }
@@ -67,7 +70,12 @@ export function nativePanelScript() {
67
70
  if (stored.pending?.id !== submissionId) return false;
68
71
  if (stored.text === stored.pending.text) stored.text = '';
69
72
  stored.pending = null; window.hccUi.safeSet(target.key,JSON.stringify(stored));
70
- if (matches(target)) { draft.value = stored.text || ''; pending = null; notice(tr('native.queued')); }
73
+ // A-B-A navigation can return before its fresh executor snapshot arrives.
74
+ // Reconcile the matching local submission even while state is still null.
75
+ if (key === target.key && pending?.id === submissionId) {
76
+ draft.value = stored.text || ''; pending = null;
77
+ notice(matches(target) ? tr('native.queued') : '');
78
+ }
71
79
  return true;
72
80
  }
73
81
  function render(value) {
@@ -76,7 +84,8 @@ export function nativePanelScript() {
76
84
  if (Number.isSafeInteger(state?.generation) && Number.isSafeInteger(value.generation) && value.generation < state.generation) return;
77
85
  const replaced = state && (state.generation !== value.generation || state.owner !== value.owner);
78
86
  if (replaced) { events.clear(); document.getElementById('nativeCloseConfirmed').checked = false; }
79
- state = value; observed++;
87
+ if (state !== value) observed++;
88
+ state = value;
80
89
  for (const entry of value.events || []) events.set(entry.id,entry);
81
90
  while (events.size > 100) events.delete(events.keys().next().value);
82
91
  document.getElementById('nativeStatus').textContent = tr('native.sameExecutor') + ' · ' + (state.provider || '') + ' · ' + tr('native.state.' + state.status, state.status || '');
@@ -94,15 +103,16 @@ export function nativePanelScript() {
94
103
  window.hccInteractions.remember(container);
95
104
  container.innerHTML = approvals.map((request, index) => '<article class="codex-card codex-approval"><h3>'
96
105
  + esc(request.kind === 'userInput' ? text('Your input is required', '等待您的答复') : text('Approval required', '等待人工审批'))
97
- + '</h3><pre>' + esc(JSON.stringify(request.params, null, 2)) + '</pre>'
106
+ + '</h3>' + window.hccInteractions.preview(request, requestKey(request), esc, text)
98
107
  + (request.truncated ? '<p>' + esc(text('Truncated; cancel or decline only.', '参数已截断,只能取消或拒绝。')) + '</p>' : '')
99
108
  + '<div class="hcc-interaction-form">' + window.hccInteractions.form(request, requestKey(request), esc, text) + '</div><div class="native-controls">'
100
- + '<button data-response="' + index + '" data-decision="accept" data-truncated="' + !!request.truncated + '" data-request-key="' + esc(requestKey(request)) + '">' + esc(request.kind === 'userInput' ? text('Send answers', '提交答复') : request.kind === 'permissions' ? text('Grant selected permissions', '授予所选权限') : text('Approve once', '批准本次')) + '</button>'
109
+ + '<button data-response="' + index + '" data-decision="accept" data-truncated="' + window.hccInteractions.approvalBlocked(request) + '" data-request-key="' + esc(requestKey(request)) + '">' + esc(request.kind === 'userInput' ? text('Send answers', '提交答复') : request.kind === 'permissions' ? text('Grant selected permissions', '授予所选权限') : text('Approve once', '批准本次')) + '</button>'
101
110
  + '<button data-response="' + index + '" data-decision="' + (request.kind === 'userInput' ? 'cancel' : 'decline') + '" data-request-key="' + esc(requestKey(request)) + '">' + esc(text('Decline / cancel', '拒绝 / 取消')) + '</button></div></article>').join('');
102
111
  window.hccInteractions.restore(container);
103
112
  container.querySelectorAll('button[data-response]').forEach(button => button.addEventListener('click', async () => {
104
113
  if (!matches(target) || !bridge().canControl || responding.has(button.dataset.requestKey)) return;
105
114
  const request = approvals[Number(button.dataset.response)], key = button.dataset.requestKey;
115
+ const version = observed;
106
116
  responding.add(key); sync();
107
117
  try {
108
118
  const payload = window.hccInteractions.payload(request, ['permissions','userInput'].includes(request.kind) ? button.closest('article') : null, button.dataset.decision);
@@ -110,8 +120,10 @@ export function nativePanelScript() {
110
120
  executorId:request.executorId, sessionId:request.sessionId, turnId:request.turnId, requestId:request.requestId,
111
121
  ...payload, action_token:bridge().actionToken, epoch:bridge().epoch })});
112
122
  window.hccInteractions.forget(key);
113
- if (matches(target)) { if (result.state) render(result.state); else void read(); }
114
- } catch(error) { if(matches(target)) notice(error.detail || error.message); }
123
+ if (matches(target) && version === observed) { if (result.state) render(result.state); else void read(); }
124
+ } catch(error) {
125
+ if (matches(target) && (state?.pendingApprovals || []).some(current => current.requestId === request.requestId && current.executorId === request.executorId)) notice(error.detail || error.message);
126
+ }
115
127
  finally { responding.delete(key); sync(); }
116
128
  }));
117
129
  if (pending && (state.deliveries || []).some(delivery => delivery.submission_id === pending.id && delivery.message_id)) acknowledge(subject(),pending.id);
@@ -128,15 +140,15 @@ export function nativePanelScript() {
128
140
  draft.addEventListener('input',()=>{ save(); sync(); });
129
141
  document.getElementById('nativeSend').addEventListener('click',async()=>{
130
142
  sync(); if (document.getElementById('nativeSend').disabled) return;
131
- const target = subject(), text = draft.value, id = 's_' + crypto.randomUUID();
143
+ const target = subject(), text = draft.value, id = 's_' + crypto.randomUUID(), version = observed;
132
144
  pending = {id,text,generation:target.generation}; save(); inflight.add(target.key); sync(); notice(tr('native.sending'));
133
145
  try {
134
146
  const result = await bridge().api(route(target,'send'),{method:'POST',body:JSON.stringify({text,submissionId:id,action_token:target.token,epoch:target.epoch})});
135
147
  const receipt = result.receipt || result;
136
148
  if (receipt.submission_id !== id || !Number.isSafeInteger(receipt.message_id)) throw new Error(tr('native.invalidReceipt'));
137
149
  acknowledge(target,id);
138
- if (matches(target)) { if (result.state) render(result.state); else void read(); }
139
- } catch(error) { if (matches(target)) notice((error.detail || error.message) + ' ' + tr('native.uncertain')); }
150
+ if (matches(target) && version === observed) { if (result.state) render(result.state); else void read(); }
151
+ } catch(error) { if (matches(target) && pending?.id === id) notice((error.detail || error.message) + ' ' + tr('native.uncertain')); }
140
152
  finally { inflight.delete(target.key); sync(); }
141
153
  });
142
154
  document.getElementById('nativeInterrupt').addEventListener('click',async()=>{