@logicseek/hello-cc 1.0.5 → 1.1.0-rc.3
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 +72 -0
- package/docs/codex-account.zh-CN.md +38 -0
- package/docs/codex-protocol.md +59 -0
- package/docs/commands.md +9 -0
- package/docs/commands.zh-CN.md +7 -0
- package/docs/macos-process-identity.md +2 -0
- package/docs/native.md +179 -14
- package/docs/native.zh-CN.md +136 -6
- package/docs/web-handoff.zh-CN.md +118 -8
- package/lib/cli/app.mjs +47 -4
- package/lib/cli/commands/coordination.mjs +14 -4
- package/lib/cli/commands/dsh.mjs +1 -1
- package/lib/cli/commands/gc.mjs +22 -15
- package/lib/cli/commands/hook.mjs +19 -3
- package/lib/cli/commands/install.mjs +80 -10
- package/lib/cli/commands/misc.mjs +36 -9
- package/lib/cli/commands/native.mjs +4 -38
- package/lib/cli/commands/peer.mjs +3 -0
- package/lib/cli/commands/run.mjs +13 -6
- package/lib/cli/commands/tmux.mjs +1 -2
- package/lib/cli-runtime.mjs +19 -2
- package/lib/core/coordination/results.mjs +27 -10
- package/lib/core/sessions/hook-root-identity.mjs +47 -0
- package/lib/core/sessions/launch.mjs +7 -1
- package/lib/db/connection.mjs +140 -42
- package/lib/db/schema.mjs +59 -0
- package/lib/diagnostics/codex-protocol.mjs +105 -0
- package/lib/guidance.mjs +21 -36
- package/lib/integrations/codex-account.mjs +143 -0
- 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 +16 -11
- package/lib/integrations/mcp-elicitation.mjs +116 -0
- package/lib/integrations/mcp-url-elicitation.mjs +25 -0
- package/lib/integrations/native/claude.mjs +24 -6
- package/lib/integrations/native/codex.mjs +38 -7
- package/lib/integrations/native/dsh-acp.mjs +67 -24
- package/lib/integrations/native/index.mjs +4 -1
- package/lib/integrations/native/interactions.mjs +2 -1
- package/lib/integrations/native/jsonrpc.mjs +37 -5
- package/lib/integrations/native/telemetry.mjs +30 -0
- package/lib/process/identity.mjs +4 -0
- package/lib/process/pinned-cwd.mjs +302 -0
- package/lib/process/pinned-guidance.mjs +138 -0
- package/lib/process/pinned-project-state.mjs +105 -0
- package/lib/process/selected-cwd-identity.mjs +95 -0
- package/lib/runtime/buffer-evidence.mjs +2 -1
- package/lib/runtime/client.mjs +9 -0
- package/lib/runtime/native/client.mjs +4 -1
- package/lib/runtime/native/history.mjs +140 -0
- package/lib/runtime/native/launcher.mjs +106 -0
- package/lib/runtime/native/service.mjs +64 -8
- package/lib/runtime/native/store.mjs +83 -58
- 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 +92 -20
- package/lib/runtime/project-trust.mjs +79 -0
- package/lib/runtime/projects.mjs +9 -3
- package/lib/runtime/state-migration.mjs +697 -0
- package/lib/runtime/state.mjs +69 -27
- package/lib/shared/automatic-result-key.mjs +6 -0
- package/lib/shared/json-file.mjs +55 -6
- package/lib/shared/redact.mjs +2 -1
- package/lib/ui/help.mjs +6 -4
- package/lib/web/agent-defaults.mjs +110 -0
- package/lib/web/auto-attach.mjs +3 -2
- package/lib/web/browser/contracts.d.ts +10 -0
- package/lib/web/browser/core.mjs +1997 -0
- package/lib/web/browser/project-data.mjs +158 -0
- package/lib/web/browser/session-sync.mjs +82 -0
- package/lib/web/browser-assets.mjs +19 -0
- package/lib/web/buffer-gc-runtime.mjs +2 -2
- package/lib/web/codex-app-server.mjs +111 -37
- package/lib/web/codex-sessions.mjs +61 -12
- package/lib/web/context-files.mjs +82 -0
- package/lib/web/cookie-auth.mjs +51 -3
- package/lib/web/external-sessions.mjs +76 -9
- package/lib/web/http-routes.mjs +238 -25
- package/lib/web/http.mjs +50 -4
- package/lib/web/liveness-reaper.mjs +2 -2
- package/lib/web/native-sessions.mjs +323 -14
- package/lib/web/project-contexts.mjs +236 -44
- package/lib/web/project-file-write.mjs +249 -0
- package/lib/web/project-files.mjs +268 -0
- package/lib/web/pty-sessions.mjs +11 -3
- package/lib/web/runtime-main.mjs +132 -80
- package/lib/web/session-serialize.mjs +87 -8
- package/lib/web/session-state-sync.mjs +98 -0
- package/lib/web/shutdown-diagnostics.mjs +38 -0
- package/lib/web/shutdown-response.mjs +21 -0
- package/lib/web/shutdown-state.mjs +91 -0
- package/lib/web/startup.mjs +103 -35
- package/lib/web/task-results.mjs +19 -19
- package/lib/web/tls.mjs +84 -33
- package/lib/web/tmux-sessions.mjs +73 -9
- package/lib/web/tmux-stream.mjs +36 -8
- package/lib/web/ui-agent-defaults.mjs +149 -0
- package/lib/web/ui-agent-start.mjs +330 -0
- package/lib/web/ui-codex-account.mjs +81 -0
- package/lib/web/ui-codex.mjs +105 -85
- package/lib/web/ui-command-palette.mjs +21 -5
- package/lib/web/ui-file-writes.mjs +220 -0
- package/lib/web/ui-files.mjs +231 -0
- package/lib/web/ui-handoff.mjs +10 -2
- package/lib/web/ui-history.mjs +175 -105
- package/lib/web/ui-i18n.mjs +280 -8
- package/lib/web/ui-interactions.mjs +107 -6
- package/lib/web/ui-native-timeline.mjs +326 -0
- package/lib/web/ui-native.mjs +93 -43
- package/lib/web/ui-review.mjs +5 -3
- package/lib/web/ui-safe-markdown.mjs +79 -0
- package/lib/web/ui-session-tools.mjs +224 -0
- package/lib/web/ui-template.mjs +29 -1968
- package/lib/web/ui-terminal-find.mjs +5 -3
- package/lib/web/ui-workbench.mjs +5 -3
- package/lib/web/ui-workspace.mjs +3 -0
- package/package.json +8 -2
- package/scripts/benchmark-session-sync.mjs +53 -0
- package/scripts/codex-protocol.mjs +88 -0
- package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
- package/scripts/dsh-package-provenance.mjs +118 -0
- package/scripts/mcp-form-live-fixture.mjs +81 -0
- package/scripts/native-bounded-live-acceptance.mjs +193 -0
- package/scripts/native-fixture-root.mjs +49 -0
- package/scripts/native-installed-acceptance.mjs +611 -0
- package/scripts/native-stability-checks.mjs +40 -0
- package/scripts/regression-cookie-expiry.mjs +70 -0
- package/scripts/regression-http-transport.mjs +9 -0
- package/scripts/regression-terminal-marker.mjs +95 -0
- package/scripts/regression.mjs +481 -219
- package/scripts/shim-reentry-probe.mjs +105 -0
- package/scripts/web-acceptance-diagnostics.mjs +90 -0
- package/scripts/web-codex-account-acceptance.mjs +178 -0
- package/scripts/web-file-preview-checks.mjs +302 -0
- package/scripts/web-mcp-form-acceptance.mjs +134 -0
- package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
- package/scripts/web-native-handoff-checks.mjs +128 -0
- package/scripts/web-native-interaction-acceptance.mjs +219 -18
- package/scripts/web-native-interaction-diagnostics.mjs +80 -0
- package/scripts/web-native-model-acceptance.mjs +22 -7
- package/scripts/web-session-tools-checks.mjs +112 -0
- package/scripts/web-workbench-acceptance.mjs +435 -0
- package/scripts/web-workbench-installed-acceptance.mjs +122 -0
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
|
|
|
@@ -12,6 +12,8 @@ import { CliError } from '../shared/errors.mjs';
|
|
|
12
12
|
import { tx } from '../db/schema.mjs';
|
|
13
13
|
import { required } from '../cli-args.mjs';
|
|
14
14
|
import { readClockGraceUntil } from '../shared/clock-grace.mjs';
|
|
15
|
+
import { preparePinnedCwdLaunch } from '../process/pinned-cwd.mjs';
|
|
16
|
+
import { captureSelectedCwdSnapshot } from '../process/selected-cwd-identity.mjs';
|
|
15
17
|
import { peerEvidenceAllowsReap } from '../core/peers/evidence.mjs';
|
|
16
18
|
import { isDshHookBinding } from '../core/peers/bindings.mjs';
|
|
17
19
|
import { conditionalTmuxKill, conditionalTmuxRename, prepareTmuxRestartBinding, rollbackTmuxRestartBinding, validateTmuxDestructiveEvidence } from '../core/peers/tmux-safety.mjs';
|
|
@@ -22,9 +24,65 @@ import { detectBranch } from '../project-context.mjs';
|
|
|
22
24
|
import { adoptionPaused } from './adoption-state.mjs';
|
|
23
25
|
import { ensureTmuxAvailable, runTmux, tmuxCapturePane, tmuxEnvironmentArgs, tmuxHasSession, tmuxKillSession, tmuxLaunchFingerprint, tmuxListSessionNames, tmuxManagedSessionName, tmuxManagedSessionNameMatches, tmuxPaneInfo, tmuxProviderState, tmuxSendLiteral, tmuxSessionEnvironmentValue, tmuxSessionHasClients } from '../tmux.mjs';
|
|
24
26
|
|
|
27
|
+
// tmux returns after creating a pane, not after its command has entered cwd.
|
|
28
|
+
// Keep the parent's captured directory FD alive until the bootstrap confirms
|
|
29
|
+
// the checked chdir, or tmux definitively reports that the pane exited.
|
|
30
|
+
export function monitorPinnedTmuxLaunch(binding, paneTarget, { paneInfo = tmuxPaneInfo, intervalMs = 100 } = {}) {
|
|
31
|
+
let timer;
|
|
32
|
+
let released = false;
|
|
33
|
+
function release() {
|
|
34
|
+
if (released) return;
|
|
35
|
+
released = true;
|
|
36
|
+
if (timer) clearInterval(timer);
|
|
37
|
+
binding.release();
|
|
38
|
+
}
|
|
39
|
+
function check() {
|
|
40
|
+
if (released) return true;
|
|
41
|
+
if (binding.acknowledged()) { release(); return true; }
|
|
42
|
+
try {
|
|
43
|
+
if (paneInfo(paneTarget).dead) { release(); return true; }
|
|
44
|
+
} catch {
|
|
45
|
+
// Even a missing pane/server does not prove its child process exited:
|
|
46
|
+
// a tmux crash can leave an orphan bootstrap alive. Retain the FD.
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
timer = setInterval(check, intervalMs);
|
|
51
|
+
timer.unref?.();
|
|
52
|
+
check();
|
|
53
|
+
return { check, pending: () => !released };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function runPinnedTmuxNewSession({ cwd, expectedIdentity, shell, command, env, sessionName, tmuxEnv },
|
|
57
|
+
{ run = runTmux, paneInfo = tmuxPaneInfo, bootstrapCwd } = {}) {
|
|
58
|
+
const binding = preparePinnedCwdLaunch(cwd, shell, ['-c', command], {
|
|
59
|
+
env, expectedIdentity, acknowledge: true, ...(bootstrapCwd ? { bootstrapCwd } : {})
|
|
60
|
+
});
|
|
61
|
+
let args;
|
|
62
|
+
try {
|
|
63
|
+
// Multiple shell-command arguments make tmux exec the command directly,
|
|
64
|
+
// without its default shell or shell startup hooks running before pinning.
|
|
65
|
+
args = ['new-session', '-d', '-s', sessionName, '-c', binding.cwd,
|
|
66
|
+
...tmuxEnvironmentArgs(tmuxEnv), '--',
|
|
67
|
+
...isolatedEnvCommandArgs(binding.env), binding.command, ...binding.args];
|
|
68
|
+
} catch (error) {
|
|
69
|
+
binding.release();
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
run(args);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
// A timed-out tmux command may have created the pane. Do not release its
|
|
76
|
+
// directory FD until a checked bootstrap ACK or definitive pane exit.
|
|
77
|
+
monitorPinnedTmuxLaunch(binding, `${sessionName}:0.0`, { paneInfo });
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
return monitorPinnedTmuxLaunch(binding, `${sessionName}:0.0`, { paneInfo });
|
|
81
|
+
}
|
|
82
|
+
|
|
25
83
|
export function createTmuxSessions(deps) {
|
|
26
84
|
const {
|
|
27
|
-
ctx, sessions,
|
|
85
|
+
ctx, sessions, isStopping = () => false,
|
|
28
86
|
broadcast, closeSessionClients, hasOpenClients,
|
|
29
87
|
sessionKey, sessionsForProject, resolveSessionPeerId,
|
|
30
88
|
startTmuxStream, stopTmuxStream, refreshTmuxSnapshot,
|
|
@@ -41,7 +99,7 @@ export function createTmuxSessions(deps) {
|
|
|
41
99
|
requestActorPeer, requestSource,
|
|
42
100
|
upsertPeer, upsertCanonicalPeerBinding,
|
|
43
101
|
findProviderSessionBinding, providerOwnerEvidenceFromDb,
|
|
44
|
-
|
|
102
|
+
detectBranch
|
|
45
103
|
} = deps;
|
|
46
104
|
const adoptionPaneInfo = deps.tmuxPaneInfo || tmuxPaneInfo;
|
|
47
105
|
const adoptionSessionNames = deps.tmuxListSessionNames || tmuxListSessionNames;
|
|
@@ -533,6 +591,7 @@ export function createTmuxSessions(deps) {
|
|
|
533
591
|
}
|
|
534
592
|
|
|
535
593
|
function attachTmuxSession(input) {
|
|
594
|
+
if (isStopping()) throw new CliError('RUNTIME_STOPPING', 'Web runtime is stopping');
|
|
536
595
|
const pctx = input.projectCtx || ctx;
|
|
537
596
|
const id = input.id;
|
|
538
597
|
if (!id) throw new CliError('BAD_REQUEST', 'id required');
|
|
@@ -653,6 +712,7 @@ export function createTmuxSessions(deps) {
|
|
|
653
712
|
// Detect pane death (retry 3 times before detaching — handles Ctrl+C transient states)
|
|
654
713
|
let deadCount = 0;
|
|
655
714
|
session.exitPoller = setInterval(() => {
|
|
715
|
+
if (isStopping()) return;
|
|
656
716
|
try {
|
|
657
717
|
const fresh = tmuxPaneInfo(session.pane);
|
|
658
718
|
if (fresh.dead) {
|
|
@@ -801,7 +861,7 @@ export function createTmuxSessions(deps) {
|
|
|
801
861
|
|
|
802
862
|
function writeSessionInput(session, data) {
|
|
803
863
|
if (session.type === 'external') {
|
|
804
|
-
fs.appendFileSync(session.inFile, data);
|
|
864
|
+
fs.appendFileSync(session.inFile, data, { mode: 0o600 });
|
|
805
865
|
} else if (session.type === 'tmux') {
|
|
806
866
|
tmuxSendLiteral(session.pane, data);
|
|
807
867
|
scheduleTmuxInputRefresh(session);
|
|
@@ -812,17 +872,17 @@ export function createTmuxSessions(deps) {
|
|
|
812
872
|
}
|
|
813
873
|
|
|
814
874
|
function scheduleTmuxInputRefresh(session) {
|
|
815
|
-
if (session.type !== 'tmux' || !session.pane) return;
|
|
875
|
+
if (isStopping() || session.type !== 'tmux' || !session.pane) return;
|
|
816
876
|
if (session.inputRefreshTimer) return;
|
|
817
877
|
session.inputRefreshTimer = setTimeout(() => {
|
|
818
878
|
session.inputRefreshTimer = null;
|
|
819
|
-
if (session.status === 'running') broadcast(session, { type: 'replace', data: refreshTmuxSnapshot(session) });
|
|
879
|
+
if (!isStopping() && session.status === 'running') broadcast(session, { type: 'replace', data: refreshTmuxSnapshot(session) });
|
|
820
880
|
}, 80);
|
|
821
881
|
}
|
|
822
882
|
|
|
823
883
|
function resizeSession(session, cols, rows) {
|
|
824
884
|
if (session.type === 'external') {
|
|
825
|
-
try { fs.appendFileSync(session.resizeFile, JSON.stringify({ cols, rows }) + '\n'); } catch {}
|
|
885
|
+
try { fs.appendFileSync(session.resizeFile, JSON.stringify({ cols, rows }) + '\n', { mode: 0o600 }); } catch {}
|
|
826
886
|
} else if (session.type === 'tmux') {
|
|
827
887
|
// Resize the tmux window to match the browser terminal so the captured
|
|
828
888
|
// mirror has identical geometry — a prerequisite for accurate cursor
|
|
@@ -844,6 +904,7 @@ export function createTmuxSessions(deps) {
|
|
|
844
904
|
}
|
|
845
905
|
|
|
846
906
|
function startTmuxManagedSession(input) {
|
|
907
|
+
if (isStopping()) throw new CliError('RUNTIME_STOPPING', 'Web runtime is stopping');
|
|
847
908
|
for (const existing of sessions.values()) {
|
|
848
909
|
if (providerSessionBindingMatches(existing.binding, input.binding)) input.authorizeMutation?.(existing);
|
|
849
910
|
}
|
|
@@ -859,6 +920,9 @@ export function createTmuxSessions(deps) {
|
|
|
859
920
|
const role = input.role || 'peer';
|
|
860
921
|
const command = input.command || defaultSessionCommand(kind);
|
|
861
922
|
const cwd = path.resolve(input.cwd || pctx.root);
|
|
923
|
+
const expectedCwdIdentity = input.cwdIdentity ||
|
|
924
|
+
(cwd === pctx.root ? pctx.rootIdentity || pctx.initialRootIdentity : null) ||
|
|
925
|
+
captureSelectedCwdSnapshot(cwd);
|
|
862
926
|
const sessionName = tmuxManagedSessionName(pctx, id);
|
|
863
927
|
let paneTarget = `${sessionName}:0.0`;
|
|
864
928
|
const callerEnv = input.env && typeof input.env === 'object' ? input.env : process.env;
|
|
@@ -867,7 +931,7 @@ export function createTmuxSessions(deps) {
|
|
|
867
931
|
HCC_ROOT: pctx.root,
|
|
868
932
|
HCC_DB: pctx.dbPath,
|
|
869
933
|
TERM: 'xterm-256color'
|
|
870
|
-
}, callerEnv);
|
|
934
|
+
}, callerEnv, { rootIdentity: pctx.rootIdentity || pctx.initialRootIdentity });
|
|
871
935
|
env[LAUNCH_FINGERPRINT_ENV] = launchFingerprint({ command, cwd, env });
|
|
872
936
|
let hasSession = tmuxHasSession(sessionName);
|
|
873
937
|
const relaunchableProvider = isRelaunchableProviderSession(kind, command, input.binding || {});
|
|
@@ -1040,14 +1104,14 @@ export function createTmuxSessions(deps) {
|
|
|
1040
1104
|
try {
|
|
1041
1105
|
if (!hasSession) {
|
|
1042
1106
|
const shell = callerEnv.SHELL || process.env.SHELL || 'bash';
|
|
1043
|
-
const launch = shellCommand([...isolatedEnvCommandArgs(env), shell, '-c', command]);
|
|
1044
1107
|
const tmuxEnv = {
|
|
1045
1108
|
HCC_ROOT: pctx.root,
|
|
1046
1109
|
HCC_DB: pctx.dbPath,
|
|
1047
1110
|
[LAUNCH_FINGERPRINT_ENV]: env[LAUNCH_FINGERPRINT_ENV]
|
|
1048
1111
|
};
|
|
1049
1112
|
if (relaunchableProvider) tmuxEnv[PROVIDER_STATE_ENV] = 'starting';
|
|
1050
|
-
|
|
1113
|
+
runPinnedTmuxNewSession({ cwd, expectedIdentity: expectedCwdIdentity,
|
|
1114
|
+
shell, command, env, sessionName, tmuxEnv });
|
|
1051
1115
|
createdTmuxSession = true;
|
|
1052
1116
|
}
|
|
1053
1117
|
|
package/lib/web/tmux-stream.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { performance } from 'node:perf_hooks';
|
|
8
9
|
import { runTmux, tmuxCapturePane, tmuxCursorInfo, tmuxCursorPayload } from '../terminal/tmux.mjs';
|
|
9
10
|
import { CliError } from '../shared/errors.mjs';
|
|
10
11
|
import { ensurePrivateProjectBufferDirectory } from '../runtime/project-path.mjs';
|
|
@@ -15,7 +16,8 @@ export function createTmuxStream({
|
|
|
15
16
|
refreshPeerIoHeartbeat,
|
|
16
17
|
withBufferDirectoryLease,
|
|
17
18
|
shellQuoteArg,
|
|
18
|
-
ctx
|
|
19
|
+
ctx,
|
|
20
|
+
isStopping = () => false
|
|
19
21
|
}) {
|
|
20
22
|
function cursorEscape(payload) {
|
|
21
23
|
if (!payload) return '';
|
|
@@ -24,6 +26,7 @@ export function createTmuxStream({
|
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
function tmuxSnapshot(session) {
|
|
29
|
+
if (isStopping()) return session.buffer || '';
|
|
27
30
|
const captured = tmuxCapturePane(session.pane);
|
|
28
31
|
return captured + cursorEscape(tmuxCursorPayload(captured, tmuxCursorInfo(session.pane)));
|
|
29
32
|
}
|
|
@@ -39,16 +42,18 @@ export function createTmuxStream({
|
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
function scheduleTmuxReplace(session) {
|
|
42
|
-
if (session.type !== 'tmux' || !session.pane) return;
|
|
45
|
+
if (isStopping() || session.type !== 'tmux' || !session.pane) return;
|
|
43
46
|
if (session.replaceTimer) clearTimeout(session.replaceTimer);
|
|
44
47
|
session.replaceTimer = setTimeout(() => {
|
|
45
48
|
session.replaceTimer = null;
|
|
49
|
+
if (isStopping()) return;
|
|
46
50
|
session.lastBroadcastTime = Date.now();
|
|
47
51
|
broadcast(session, { type: 'replace', data: refreshTmuxSnapshot(session) });
|
|
48
52
|
}, 80);
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
function startTmuxReplacePoller(session, warning = null) {
|
|
56
|
+
if (isStopping()) return;
|
|
52
57
|
if (session.replacePoller) clearInterval(session.replacePoller);
|
|
53
58
|
if (warning) {
|
|
54
59
|
session.warning = {
|
|
@@ -58,7 +63,7 @@ export function createTmuxStream({
|
|
|
58
63
|
}
|
|
59
64
|
session.lastBroadcastTime = Date.now();
|
|
60
65
|
session.replacePoller = setInterval(() => {
|
|
61
|
-
if (session.status !== 'running') return;
|
|
66
|
+
if (isStopping() || session.status !== 'running') return;
|
|
62
67
|
if (Date.now() - (session.lastBroadcastTime || 0) > 4000) {
|
|
63
68
|
session.lastBroadcastTime = Date.now();
|
|
64
69
|
broadcast(session, { type: 'replace', data: refreshTmuxSnapshot(session) });
|
|
@@ -67,6 +72,7 @@ export function createTmuxStream({
|
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
function startTmuxStream(session) {
|
|
75
|
+
if (isStopping()) throw new CliError('RUNTIME_STOPPING', 'Web runtime is stopping');
|
|
70
76
|
const safePane = String(session.pane).replace(/[^A-Za-z0-9_-]/g, '');
|
|
71
77
|
const safeId = String(session.id).replace(/[^A-Za-z0-9_.-]/g, '_');
|
|
72
78
|
const streamDirectory = ensurePrivateProjectBufferDirectory((session.ctx || ctx).root);
|
|
@@ -106,6 +112,7 @@ export function createTmuxStream({
|
|
|
106
112
|
return 'poll';
|
|
107
113
|
}
|
|
108
114
|
session.streamPoller = setInterval(() => {
|
|
115
|
+
if (isStopping()) return;
|
|
109
116
|
try {
|
|
110
117
|
if (session.streamFd === null || session.streamFd === undefined) return;
|
|
111
118
|
const chunks = [];
|
|
@@ -141,21 +148,42 @@ export function createTmuxStream({
|
|
|
141
148
|
return 'stream';
|
|
142
149
|
}
|
|
143
150
|
|
|
144
|
-
function stopTmuxStream(session) {
|
|
151
|
+
function stopTmuxStream(session, { shutdownDeadline = null } = {}) {
|
|
152
|
+
const shuttingDown = Number.isFinite(shutdownDeadline);
|
|
153
|
+
const remaining = () => shuttingDown ? shutdownDeadline - performance.now() : Infinity;
|
|
145
154
|
if (session.streamPoller) { clearInterval(session.streamPoller); session.streamPoller = null; }
|
|
146
155
|
if (session.replacePoller) { clearInterval(session.replacePoller); session.replacePoller = null; }
|
|
147
156
|
if (session.replaceTimer) { clearTimeout(session.replaceTimer); session.replaceTimer = null; }
|
|
148
157
|
if (session.inputRefreshTimer) { clearTimeout(session.inputRefreshTimer); session.inputRefreshTimer = null; }
|
|
149
|
-
|
|
158
|
+
// Shutdown shares one budget across all sessions. Detach keeps its normal
|
|
159
|
+
// behavior, but advisory stream cleanup must not consume down's 5s wait
|
|
160
|
+
// once per pane. Never pass zero to spawnSync (it disables the timeout).
|
|
161
|
+
if (remaining() >= 1) {
|
|
162
|
+
try { runTmux(['pipe-pane', '-t', session.pane], shuttingDown
|
|
163
|
+
? { timeout: Math.max(1, Math.min(250, Math.floor(remaining()))) } : {}); } catch {}
|
|
164
|
+
}
|
|
165
|
+
let originalPipe = null;
|
|
150
166
|
if (session.streamFd !== null && session.streamFd !== undefined) {
|
|
167
|
+
if (shuttingDown) {
|
|
168
|
+
try { originalPipe = fs.fstatSync(session.streamFd); } catch {}
|
|
169
|
+
}
|
|
151
170
|
try { fs.closeSync(session.streamFd); } catch {}
|
|
152
171
|
session.streamFd = null;
|
|
153
172
|
}
|
|
154
173
|
if (session.pipeFile) {
|
|
155
174
|
try {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
175
|
+
if (remaining() >= 1 && (!shuttingDown || originalPipe?.isFIFO())) {
|
|
176
|
+
withBufferDirectoryLease(path.dirname(session.pipeFile), () => {
|
|
177
|
+
if (shuttingDown) {
|
|
178
|
+
if (remaining() < 1) return;
|
|
179
|
+
// Pointers may already be cleared. A successor can reuse this
|
|
180
|
+
// pathname, so only unlink the FIFO owned by our open descriptor.
|
|
181
|
+
const current = fs.lstatSync(session.pipeFile);
|
|
182
|
+
if (!current.isFIFO() || current.dev !== originalPipe.dev || current.ino !== originalPipe.ino) return;
|
|
183
|
+
}
|
|
184
|
+
fs.unlinkSync(session.pipeFile);
|
|
185
|
+
}, shuttingDown ? { nonblocking: true, createParent: false } : {});
|
|
186
|
+
}
|
|
159
187
|
} catch {}
|
|
160
188
|
session.pipeFile = null;
|
|
161
189
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
export const agentDefaultsCss = `
|
|
2
|
+
.settings-dialog { width:min(700px,calc(100vw - 28px)); max-width:none; max-height:90dvh; overflow:auto; }
|
|
3
|
+
.agent-defaults { margin-top:22px; padding-top:18px; border-top:1px solid var(--border); display:grid; gap:12px; }
|
|
4
|
+
.agent-defaults h4,.agent-defaults p { margin:0; }
|
|
5
|
+
.agent-defaults fieldset { min-width:0; border:1px solid var(--border); border-radius:8px; padding:12px; display:grid; gap:10px; }
|
|
6
|
+
.agent-defaults legend { padding:0 6px; }
|
|
7
|
+
.agent-defaults label { min-width:0; }
|
|
8
|
+
.agent-defaults input,.agent-defaults select { width:100%; min-width:0; }
|
|
9
|
+
.agent-defaults .btns { flex-wrap:wrap; }
|
|
10
|
+
#agentDefaultsProject { overflow-wrap:anywhere; }
|
|
11
|
+
.agent-defaults [hidden] { display:none; }
|
|
12
|
+
`;
|
|
13
|
+
|
|
14
|
+
export function agentDefaultsHtml() {
|
|
15
|
+
const providerFields=[['Codex','Codex'],['Claude','Claude'],['Dsh','DeepSeek Harness']].map(([id,label]) =>
|
|
16
|
+
'<fieldset><legend>'+label+'</legend><label><span data-i18n="agent.model">Model (optional)</span><input id="agentDefaults'+id+'Model" type="text" maxlength="256" autocomplete="off" data-i18n-placeholder="agent.modelPlaceholder" placeholder="Provider default"></label>'
|
|
17
|
+
+'<label><span data-i18n="agentDefaults.cwd">Project relative working directory</span><input id="agentDefaults'+id+'Cwd" type="text" required autocomplete="off" placeholder="."></label></fieldset>').join('');
|
|
18
|
+
return `<form id="agentDefaultsForm" class="agent-defaults">
|
|
19
|
+
<h4 data-i18n="agentDefaults.title">Project Agent defaults</h4><p id="agentDefaultsProject" class="dialog-help"></p>
|
|
20
|
+
<p class="dialog-help" data-i18n="agentDefaults.help">Saved for this project and used for new background Agents. Existing Agents, history restores, accounts and API keys are unchanged.</p>
|
|
21
|
+
<label><span data-i18n="agentDefaults.provider">Default Agent</span><select id="agentDefaultsProvider"><option value="codex">Codex</option><option value="claude">Claude</option><option value="dsh">DeepSeek Harness</option></select></label>
|
|
22
|
+
${providerFields}
|
|
23
|
+
<p class="dialog-help" data-i18n="agentDefaults.fieldsHelp">Use . for the project directory. An empty model uses the provider default.</p>
|
|
24
|
+
<p id="agentDefaultsStatus" class="dialog-help" role="status" aria-live="polite"></p>
|
|
25
|
+
<div class="btns"><button id="agentDefaultsReload" type="button" data-i18n="agentDefaults.reload">Reload saved values</button><button id="agentDefaultsReset" type="button" data-i18n="agentDefaults.reset">Restore initial values</button><button id="agentDefaultsSave" type="submit" class="primary" data-i18n="agentDefaults.save">Save project defaults</button></div>
|
|
26
|
+
</form>`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function installAgentDefaults(browser = globalThis) {
|
|
30
|
+
const {window,document}=browser, bridge=window.hccHandoff, node=id=>document.getElementById(id);
|
|
31
|
+
const providers=[['codex','Codex'],['claude','Claude'],['dsh','Dsh']], records=new Map();
|
|
32
|
+
const fields=['agentDefaultsProvider',...providers.flatMap(([,label])=>['agentDefaults'+label+'Model','agentDefaults'+label+'Cwd'])];
|
|
33
|
+
let record=null, visit=0;
|
|
34
|
+
const empty=()=>({defaultProvider:'codex',providers:Object.fromEntries(providers.map(([provider])=>[provider,{model:null,cwd:'.'}]))});
|
|
35
|
+
const copy=value=>JSON.parse(JSON.stringify(value));
|
|
36
|
+
const values=value=>({defaultProvider:value.defaultProvider,providers:copy(value.providers)});
|
|
37
|
+
const signature=value=>JSON.stringify(values(value));
|
|
38
|
+
const route=root=>'/api/agent-defaults?root='+encodeURIComponent(root);
|
|
39
|
+
const visible=target=>record===target && target.root===bridge.projectRoot && !node('settingsDialog').hidden;
|
|
40
|
+
const tr=key=>bridge.tr(key);
|
|
41
|
+
function renderStatus() {
|
|
42
|
+
if (!record) return;
|
|
43
|
+
const message=tr(record.status || 'agentDefaults.ready');
|
|
44
|
+
node('agentDefaultsStatus').textContent=message+(record.error ? ' '+record.error : '');
|
|
45
|
+
}
|
|
46
|
+
function sync() {
|
|
47
|
+
if (!node('settingsDialog').hidden && (!record || record.root!==bridge.projectRoot)) { open(); return; }
|
|
48
|
+
if (!record) return;
|
|
49
|
+
const blocked=!record.loaded || record.loading || record.root!==bridge.projectRoot;
|
|
50
|
+
for (const id of fields) node(id).disabled=blocked;
|
|
51
|
+
node('agentDefaultsSave').disabled=blocked || record.saving || record.conflict || record.uncertain || !record.dirty;
|
|
52
|
+
node('agentDefaultsReset').disabled=blocked || record.saving;
|
|
53
|
+
node('agentDefaultsReload').disabled=record.loading || record.saving || !record.root;
|
|
54
|
+
node('agentDefaultsForm').setAttribute('aria-busy',String(record.loading || record.saving));
|
|
55
|
+
renderStatus();
|
|
56
|
+
}
|
|
57
|
+
function render() {
|
|
58
|
+
if (!record) return;
|
|
59
|
+
node('agentDefaultsProject').textContent=record.root;
|
|
60
|
+
node('agentDefaultsProvider').value=record.value.defaultProvider;
|
|
61
|
+
for (const [provider,label] of providers) {
|
|
62
|
+
node('agentDefaults'+label+'Model').value=record.value.providers[provider].model || '';
|
|
63
|
+
node('agentDefaults'+label+'Cwd').value=record.value.providers[provider].cwd;
|
|
64
|
+
}
|
|
65
|
+
sync();
|
|
66
|
+
}
|
|
67
|
+
function changed() {
|
|
68
|
+
if (!record || !record.loaded || record.loading || record.root!==bridge.projectRoot) return;
|
|
69
|
+
record.value={defaultProvider:node('agentDefaultsProvider').value,providers:Object.fromEntries(providers.map(([provider,label])=>[provider,{
|
|
70
|
+
model:node('agentDefaults'+label+'Model').value.trim() || null,cwd:node('agentDefaults'+label+'Cwd').value.trim()
|
|
71
|
+
}]))};
|
|
72
|
+
record.dirty=signature(record.value)!==record.saved;
|
|
73
|
+
if (!record.conflict && !record.uncertain && !record.saving) { record.status=record.dirty ? 'agentDefaults.unsaved' : 'agentDefaults.ready'; record.error=''; }
|
|
74
|
+
sync();
|
|
75
|
+
}
|
|
76
|
+
async function read(root=bridge.projectRoot) {
|
|
77
|
+
if (!root) throw new Error(tr('agentDefaults.projectLoading'));
|
|
78
|
+
return bridge.api(route(root));
|
|
79
|
+
}
|
|
80
|
+
async function reload() {
|
|
81
|
+
if (!record || record.loading || record.saving || !record.root) return;
|
|
82
|
+
const target=record, version=visit;
|
|
83
|
+
target.loadVersion=version;
|
|
84
|
+
target.loading=true; target.status='agentDefaults.loading'; target.error=''; sync();
|
|
85
|
+
try {
|
|
86
|
+
const result=await read(target.root);
|
|
87
|
+
if (!visible(target) || version!==visit) return;
|
|
88
|
+
target.value=values(result); target.revision=result.revision; target.saved=signature(target.value);
|
|
89
|
+
target.loaded=true; target.dirty=false; target.conflict=false; target.uncertain=false; target.status='agentDefaults.ready';
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (visible(target) && version===visit) { target.status='agentDefaults.loadFailed'; target.error=error.detail || error.message; }
|
|
92
|
+
} finally {
|
|
93
|
+
if (target.loadVersion===version) target.loading=false;
|
|
94
|
+
if (visible(target) && version===visit) render();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function open() {
|
|
98
|
+
visit++;
|
|
99
|
+
const root=bridge.projectRoot;
|
|
100
|
+
if (!records.has(root)) records.set(root,{root,value:empty(),loaded:false,loading:false,saving:false,dirty:false,revision:null,status:'agentDefaults.ready',error:''});
|
|
101
|
+
record=records.get(root);
|
|
102
|
+
record.loading=false;
|
|
103
|
+
while (records.size>20) {
|
|
104
|
+
const removable=[...records.entries()].find(([key,value])=>key!==root && !value.saving && !value.dirty);
|
|
105
|
+
if (!removable) break;
|
|
106
|
+
records.delete(removable[0]);
|
|
107
|
+
}
|
|
108
|
+
if (!root) { record.status='agentDefaults.projectLoading'; render(); return; }
|
|
109
|
+
render();
|
|
110
|
+
if (!record.dirty && !record.saving && !record.conflict && !record.uncertain) void reload();
|
|
111
|
+
}
|
|
112
|
+
function closed() { visit++; }
|
|
113
|
+
function reset() { closed(); record=null; }
|
|
114
|
+
async function save(event) {
|
|
115
|
+
event?.preventDefault(); changed();
|
|
116
|
+
const target=record;
|
|
117
|
+
if (!target || !visible(target) || !target.loaded || target.loading || target.saving || target.conflict || target.uncertain || !target.dirty) return;
|
|
118
|
+
const submitted=copy(target.value), sentSignature=signature(submitted), revision=target.revision;
|
|
119
|
+
target.saving=true; target.status='agentDefaults.saving'; target.error=''; sync();
|
|
120
|
+
try {
|
|
121
|
+
const result=await bridge.api(route(target.root),{method:'PUT',body:JSON.stringify({revision,...submitted})});
|
|
122
|
+
target.revision=result.revision; target.saved=signature(result);
|
|
123
|
+
// The server receipt belongs to the submitted snapshot. Edits typed while
|
|
124
|
+
// it was saving remain a draft on top of the new revision.
|
|
125
|
+
if (signature(target.value)===sentSignature) target.value=values(result);
|
|
126
|
+
target.dirty=signature(target.value)!==target.saved;
|
|
127
|
+
target.status=target.dirty ? 'agentDefaults.savedWithEdits' : 'agentDefaults.saved';
|
|
128
|
+
} catch (error) {
|
|
129
|
+
target.error=error.detail || error.message;
|
|
130
|
+
if (error.status===409 || error.code==='AGENT_DEFAULTS_CONFLICT') { target.conflict=true; target.status='agentDefaults.conflict'; }
|
|
131
|
+
else if (!error.status || ['REQUEST_TIMEOUT','REQUEST_SUPERSEDED'].includes(error.code)) { target.uncertain=true; target.status='agentDefaults.uncertain'; }
|
|
132
|
+
else target.status='agentDefaults.saveFailed';
|
|
133
|
+
} finally {
|
|
134
|
+
target.saving=false;
|
|
135
|
+
if (visible(target)) render();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
node('agentDefaultsForm').addEventListener('submit',save);
|
|
139
|
+
for (const id of fields) { node(id).addEventListener('input',changed); node(id).addEventListener('change',changed); }
|
|
140
|
+
node('agentDefaultsReload').addEventListener('click',reload);
|
|
141
|
+
node('agentDefaultsReset').addEventListener('click',()=>{
|
|
142
|
+
if (!record || !record.loaded || record.loading || record.saving || record.root!==bridge.projectRoot) return;
|
|
143
|
+
record.value=empty(); record.dirty=signature(record.value)!==record.saved;
|
|
144
|
+
if (!record.conflict && !record.uncertain) { record.status='agentDefaults.resetDraft'; record.error=''; }
|
|
145
|
+
render();
|
|
146
|
+
});
|
|
147
|
+
window.addEventListener('hcc:preferences',()=>{ if (record && visible(record)) sync(); });
|
|
148
|
+
window.hccAgentDefaults={open,closed,reset,sync,read};
|
|
149
|
+
}
|