@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.
- package/CHANGELOG.md +111 -23
- package/README.md +10 -1
- package/README.zh-CN.md +8 -1
- package/docs/commands.md +28 -4
- package/docs/commands.zh-CN.md +20 -4
- package/docs/dsh.md +2 -2
- package/docs/dsh.zh-CN.md +3 -3
- package/docs/native.md +84 -10
- package/docs/native.zh-CN.md +66 -5
- package/docs/web-handoff.zh-CN.md +11 -3
- package/lib/cli/app.mjs +42 -1
- package/lib/cli/commands/coordination.mjs +7 -2
- package/lib/cli/commands/dsh.mjs +1 -1
- package/lib/cli/commands/gc.mjs +9 -4
- package/lib/cli/commands/hook.mjs +6 -1
- package/lib/cli/commands/install.mjs +48 -6
- package/lib/cli/commands/lifecycle.mjs +14 -3
- package/lib/cli/commands/native.mjs +16 -9
- package/lib/cli/commands/query.mjs +15 -10
- package/lib/cli/commands/run.mjs +7 -6
- package/lib/core/coordination/results.mjs +27 -10
- package/lib/db/connection.mjs +135 -10
- package/lib/db/schema.mjs +59 -0
- package/lib/discover.mjs +4 -4
- package/lib/guidance.mjs +3 -2
- package/lib/integrations/codex-interactions.mjs +13 -11
- package/lib/integrations/dsh-collaboration.mjs +6 -12
- package/lib/integrations/dsh-cordis.mjs +17 -13
- package/lib/integrations/dsh.mjs +12 -9
- package/lib/integrations/hooks.mjs +132 -40
- package/lib/integrations/mcp-elicitation.mjs +116 -0
- package/lib/integrations/mcp-url-elicitation.mjs +25 -0
- package/lib/integrations/native/interactions.mjs +2 -1
- package/lib/integrations/peers/identity.mjs +42 -7
- package/lib/process/identity.mjs +15 -14
- package/lib/runtime/buffer-evidence.mjs +2 -1
- package/lib/runtime/native/service.mjs +11 -5
- package/lib/runtime/native/store.mjs +70 -18
- package/lib/runtime/paths.mjs +17 -2
- package/lib/runtime/private-file.mjs +76 -0
- package/lib/runtime/private-state.mjs +546 -0
- package/lib/runtime/project-context.mjs +2 -1
- package/lib/runtime/project-path.mjs +148 -12
- package/lib/runtime/project-trust.mjs +79 -0
- package/lib/runtime/projects.mjs +6 -3
- package/lib/runtime/state-migration.mjs +697 -0
- package/lib/runtime/state.mjs +22 -11
- package/lib/shared/automatic-result-key.mjs +6 -0
- package/lib/shared/json-file.mjs +55 -6
- package/lib/ui/help.mjs +6 -4
- package/lib/web/codex-app-server.mjs +26 -5
- package/lib/web/codex-sessions.mjs +5 -3
- package/lib/web/cookie-auth.mjs +1 -1
- package/lib/web/external-sessions.mjs +54 -5
- package/lib/web/http-routes.mjs +13 -9
- package/lib/web/http.mjs +20 -2
- package/lib/web/native-sessions.mjs +14 -7
- package/lib/web/peer-actions.mjs +27 -27
- package/lib/web/project-contexts.mjs +2 -3
- package/lib/web/runtime-main.mjs +22 -5
- package/lib/web/runtime.mjs +18 -4
- package/lib/web/session-serialize.mjs +1 -1
- package/lib/web/startup.mjs +115 -22
- package/lib/web/task-results.mjs +20 -17
- package/lib/web/terminal-assets.mjs +18 -0
- package/lib/web/tls.mjs +84 -33
- package/lib/web/tmux-sessions.mjs +2 -2
- package/lib/web/tmux-stream.mjs +3 -4
- package/lib/web/ui-codex.mjs +161 -32
- package/lib/web/ui-interactions.mjs +137 -4
- package/lib/web/ui-native.mjs +31 -13
- package/lib/web/ui-template.mjs +10 -3
- package/package.json +1 -1
- package/scripts/mcp-form-live-fixture.mjs +81 -0
- package/scripts/native-installed-acceptance.mjs +413 -0
- package/scripts/regression.mjs +46 -29
- package/scripts/web-mcp-form-acceptance.mjs +119 -0
- package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
- 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 {
|
|
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
|
-
|
|
99
|
-
const
|
|
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
|
|
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(
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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 =
|
|
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
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
package/lib/web/tmux-stream.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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) {
|
package/lib/web/ui-codex.mjs
CHANGED
|
@@ -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
|
-
.
|
|
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
|
|
149
|
-
const
|
|
150
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
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();
|
|
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
|
|
435
|
-
+ '</h3><dl>' + fields.map(([label, value]) =>
|
|
436
|
-
|
|
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(
|
|
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="' +
|
|
442
|
-
+ escape(
|
|
443
|
-
+ escape(approval.kind === 'userInput' ? text('Cancel questions', '取消问答') : text('Decline', '拒绝')) + '</button
|
|
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
|
-
|
|
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
|
-
...
|
|
458
|
-
window.hccInteractions.forget(requestKey);
|
|
459
|
-
|
|
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 (!
|
|
666
|
+
if (!current()) return;
|
|
667
|
+
const version = stateRevision;
|
|
539
668
|
const result = await b.api(route(target, 'state'));
|
|
540
|
-
if (
|
|
541
|
-
} catch (error) { if (
|
|
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;
|