@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.
Files changed (146) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/docs/codex-account.zh-CN.md +38 -0
  3. package/docs/codex-protocol.md +59 -0
  4. package/docs/commands.md +9 -0
  5. package/docs/commands.zh-CN.md +7 -0
  6. package/docs/macos-process-identity.md +2 -0
  7. package/docs/native.md +179 -14
  8. package/docs/native.zh-CN.md +136 -6
  9. package/docs/web-handoff.zh-CN.md +118 -8
  10. package/lib/cli/app.mjs +47 -4
  11. package/lib/cli/commands/coordination.mjs +14 -4
  12. package/lib/cli/commands/dsh.mjs +1 -1
  13. package/lib/cli/commands/gc.mjs +22 -15
  14. package/lib/cli/commands/hook.mjs +19 -3
  15. package/lib/cli/commands/install.mjs +80 -10
  16. package/lib/cli/commands/misc.mjs +36 -9
  17. package/lib/cli/commands/native.mjs +4 -38
  18. package/lib/cli/commands/peer.mjs +3 -0
  19. package/lib/cli/commands/run.mjs +13 -6
  20. package/lib/cli/commands/tmux.mjs +1 -2
  21. package/lib/cli-runtime.mjs +19 -2
  22. package/lib/core/coordination/results.mjs +27 -10
  23. package/lib/core/sessions/hook-root-identity.mjs +47 -0
  24. package/lib/core/sessions/launch.mjs +7 -1
  25. package/lib/db/connection.mjs +140 -42
  26. package/lib/db/schema.mjs +59 -0
  27. package/lib/diagnostics/codex-protocol.mjs +105 -0
  28. package/lib/guidance.mjs +21 -36
  29. package/lib/integrations/codex-account.mjs +143 -0
  30. package/lib/integrations/codex-interactions.mjs +13 -11
  31. package/lib/integrations/dsh-collaboration.mjs +6 -12
  32. package/lib/integrations/dsh-cordis.mjs +17 -13
  33. package/lib/integrations/dsh.mjs +16 -11
  34. package/lib/integrations/mcp-elicitation.mjs +116 -0
  35. package/lib/integrations/mcp-url-elicitation.mjs +25 -0
  36. package/lib/integrations/native/claude.mjs +24 -6
  37. package/lib/integrations/native/codex.mjs +38 -7
  38. package/lib/integrations/native/dsh-acp.mjs +67 -24
  39. package/lib/integrations/native/index.mjs +4 -1
  40. package/lib/integrations/native/interactions.mjs +2 -1
  41. package/lib/integrations/native/jsonrpc.mjs +37 -5
  42. package/lib/integrations/native/telemetry.mjs +30 -0
  43. package/lib/process/identity.mjs +4 -0
  44. package/lib/process/pinned-cwd.mjs +302 -0
  45. package/lib/process/pinned-guidance.mjs +138 -0
  46. package/lib/process/pinned-project-state.mjs +105 -0
  47. package/lib/process/selected-cwd-identity.mjs +95 -0
  48. package/lib/runtime/buffer-evidence.mjs +2 -1
  49. package/lib/runtime/client.mjs +9 -0
  50. package/lib/runtime/native/client.mjs +4 -1
  51. package/lib/runtime/native/history.mjs +140 -0
  52. package/lib/runtime/native/launcher.mjs +106 -0
  53. package/lib/runtime/native/service.mjs +64 -8
  54. package/lib/runtime/native/store.mjs +83 -58
  55. package/lib/runtime/paths.mjs +17 -2
  56. package/lib/runtime/private-file.mjs +76 -0
  57. package/lib/runtime/private-state.mjs +546 -0
  58. package/lib/runtime/project-context.mjs +2 -1
  59. package/lib/runtime/project-path.mjs +92 -20
  60. package/lib/runtime/project-trust.mjs +79 -0
  61. package/lib/runtime/projects.mjs +9 -3
  62. package/lib/runtime/state-migration.mjs +697 -0
  63. package/lib/runtime/state.mjs +69 -27
  64. package/lib/shared/automatic-result-key.mjs +6 -0
  65. package/lib/shared/json-file.mjs +55 -6
  66. package/lib/shared/redact.mjs +2 -1
  67. package/lib/ui/help.mjs +6 -4
  68. package/lib/web/agent-defaults.mjs +110 -0
  69. package/lib/web/auto-attach.mjs +3 -2
  70. package/lib/web/browser/contracts.d.ts +10 -0
  71. package/lib/web/browser/core.mjs +1997 -0
  72. package/lib/web/browser/project-data.mjs +158 -0
  73. package/lib/web/browser/session-sync.mjs +82 -0
  74. package/lib/web/browser-assets.mjs +19 -0
  75. package/lib/web/buffer-gc-runtime.mjs +2 -2
  76. package/lib/web/codex-app-server.mjs +111 -37
  77. package/lib/web/codex-sessions.mjs +61 -12
  78. package/lib/web/context-files.mjs +82 -0
  79. package/lib/web/cookie-auth.mjs +51 -3
  80. package/lib/web/external-sessions.mjs +76 -9
  81. package/lib/web/http-routes.mjs +238 -25
  82. package/lib/web/http.mjs +50 -4
  83. package/lib/web/liveness-reaper.mjs +2 -2
  84. package/lib/web/native-sessions.mjs +323 -14
  85. package/lib/web/project-contexts.mjs +236 -44
  86. package/lib/web/project-file-write.mjs +249 -0
  87. package/lib/web/project-files.mjs +268 -0
  88. package/lib/web/pty-sessions.mjs +11 -3
  89. package/lib/web/runtime-main.mjs +132 -80
  90. package/lib/web/session-serialize.mjs +87 -8
  91. package/lib/web/session-state-sync.mjs +98 -0
  92. package/lib/web/shutdown-diagnostics.mjs +38 -0
  93. package/lib/web/shutdown-response.mjs +21 -0
  94. package/lib/web/shutdown-state.mjs +91 -0
  95. package/lib/web/startup.mjs +103 -35
  96. package/lib/web/task-results.mjs +19 -19
  97. package/lib/web/tls.mjs +84 -33
  98. package/lib/web/tmux-sessions.mjs +73 -9
  99. package/lib/web/tmux-stream.mjs +36 -8
  100. package/lib/web/ui-agent-defaults.mjs +149 -0
  101. package/lib/web/ui-agent-start.mjs +330 -0
  102. package/lib/web/ui-codex-account.mjs +81 -0
  103. package/lib/web/ui-codex.mjs +105 -85
  104. package/lib/web/ui-command-palette.mjs +21 -5
  105. package/lib/web/ui-file-writes.mjs +220 -0
  106. package/lib/web/ui-files.mjs +231 -0
  107. package/lib/web/ui-handoff.mjs +10 -2
  108. package/lib/web/ui-history.mjs +175 -105
  109. package/lib/web/ui-i18n.mjs +280 -8
  110. package/lib/web/ui-interactions.mjs +107 -6
  111. package/lib/web/ui-native-timeline.mjs +326 -0
  112. package/lib/web/ui-native.mjs +93 -43
  113. package/lib/web/ui-review.mjs +5 -3
  114. package/lib/web/ui-safe-markdown.mjs +79 -0
  115. package/lib/web/ui-session-tools.mjs +224 -0
  116. package/lib/web/ui-template.mjs +29 -1968
  117. package/lib/web/ui-terminal-find.mjs +5 -3
  118. package/lib/web/ui-workbench.mjs +5 -3
  119. package/lib/web/ui-workspace.mjs +3 -0
  120. package/package.json +8 -2
  121. package/scripts/benchmark-session-sync.mjs +53 -0
  122. package/scripts/codex-protocol.mjs +88 -0
  123. package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
  124. package/scripts/dsh-package-provenance.mjs +118 -0
  125. package/scripts/mcp-form-live-fixture.mjs +81 -0
  126. package/scripts/native-bounded-live-acceptance.mjs +193 -0
  127. package/scripts/native-fixture-root.mjs +49 -0
  128. package/scripts/native-installed-acceptance.mjs +611 -0
  129. package/scripts/native-stability-checks.mjs +40 -0
  130. package/scripts/regression-cookie-expiry.mjs +70 -0
  131. package/scripts/regression-http-transport.mjs +9 -0
  132. package/scripts/regression-terminal-marker.mjs +95 -0
  133. package/scripts/regression.mjs +481 -219
  134. package/scripts/shim-reentry-probe.mjs +105 -0
  135. package/scripts/web-acceptance-diagnostics.mjs +90 -0
  136. package/scripts/web-codex-account-acceptance.mjs +178 -0
  137. package/scripts/web-file-preview-checks.mjs +302 -0
  138. package/scripts/web-mcp-form-acceptance.mjs +134 -0
  139. package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
  140. package/scripts/web-native-handoff-checks.mjs +128 -0
  141. package/scripts/web-native-interaction-acceptance.mjs +219 -18
  142. package/scripts/web-native-interaction-diagnostics.mjs +80 -0
  143. package/scripts/web-native-model-acceptance.mjs +22 -7
  144. package/scripts/web-session-tools-checks.mjs +112 -0
  145. package/scripts/web-workbench-acceptance.mjs +435 -0
  146. 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 { globalStateDir } from '../runtime/paths.mjs';
8
+ import { ensurePrivateGlobalSubdirectory } from '../runtime/private-state.mjs';
9
+ import { readPrivateTextFile } from '../runtime/private-file.mjs';
10
+ import { unsafeDirectoryAcl } from '../runtime/project-trust.mjs';
9
11
 
10
12
  const CERT_RENEW_BEFORE_MS = 7 * 24 * 60 * 60 * 1000;
11
13
  const GENERATION_CLEANUP_GRACE_MS = 60 * 60 * 1000;
12
14
  const GENERATION_NAME_RE = /^generation-[a-z0-9-]+$/i;
13
15
  const OWNED_GENERATION_NAME_RE = /^generation-[a-z0-9]+-(\d+)-[a-f0-9]+$/i;
16
+ const TLS_KEY_MAX_BYTES = 16384;
17
+ const TLS_CERT_MAX_BYTES = 65536;
18
+ const TLS_POINTER_MAX_BYTES = 16384;
19
+ const TLS_MARKER_MAX_BYTES = 128;
14
20
 
15
21
  // hello-cc can serve the web console over HTTPS with an auto-generated
16
22
  // self-signed certificate. This protects against passive LAN sniffing of the
@@ -93,10 +99,48 @@ export function tlsCredentialsAreReusable({ key, cert, sanEntries, nowMs = Date.
93
99
  }
94
100
  }
95
101
 
102
+ // The tls/ parent is validated before this module runs, but a generation may
103
+ // have been planted while an older installation still left that parent public.
104
+ // Do not follow or repair a foreign, redirected, or cross-UID-writable child.
105
+ function trustedTlsDirectory(directory, { tighten = false } = {}) {
106
+ let fd;
107
+ try {
108
+ const before = fs.lstatSync(directory, { bigint: true });
109
+ if (before.isSymbolicLink() || !before.isDirectory() ||
110
+ before.uid !== BigInt(process.getuid())) return null;
111
+ fd = fs.openSync(directory, fs.constants.O_RDONLY |
112
+ fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
113
+ let opened = fs.fstatSync(fd, { bigint: true });
114
+ if (!opened.isDirectory() || opened.dev !== before.dev || opened.ino !== before.ino ||
115
+ opened.uid !== before.uid || (opened.mode & 0o022n) !== 0n ||
116
+ unsafeDirectoryAcl(directory, opened)) return null;
117
+ if (tighten && (opened.mode & 0o777n) !== 0o700n) {
118
+ fs.fchmodSync(fd, 0o700);
119
+ opened = fs.fstatSync(fd, { bigint: true });
120
+ if ((opened.mode & 0o777n) !== 0o700n) return null;
121
+ }
122
+ const after = fs.lstatSync(directory, { bigint: true });
123
+ if (after.isSymbolicLink() || after.dev !== opened.dev || after.ino !== opened.ino ||
124
+ after.uid !== opened.uid) return null;
125
+ return opened;
126
+ } catch {
127
+ return null;
128
+ } finally {
129
+ if (fd !== undefined) fs.closeSync(fd);
130
+ }
131
+ }
132
+
133
+ function readPrivateMarker(file) {
134
+ try { return readPrivateTextFile(file, { maxBytes: TLS_MARKER_MAX_BYTES }); }
135
+ catch { return null; }
136
+ }
137
+
96
138
  function readReusableCredentials(keyPath, certPath, sanEntries) {
97
139
  try {
98
- const key = fs.readFileSync(keyPath, 'utf8');
99
- const cert = fs.readFileSync(certPath, 'utf8');
140
+ if (!trustedTlsDirectory(path.dirname(keyPath), { tighten: true })) return null;
141
+ const key = readPrivateTextFile(keyPath, { maxBytes: TLS_KEY_MAX_BYTES });
142
+ const cert = readPrivateTextFile(certPath, { maxBytes: TLS_CERT_MAX_BYTES });
143
+ if (key === null || cert === null) return null;
100
144
  if (!tlsCredentialsAreReusable({ key, cert, sanEntries })) return null;
101
145
  return { key, cert, certPath };
102
146
  } catch {
@@ -165,7 +209,11 @@ function generateCredentials(dir, keyPath, certPath, sanEntries) {
165
209
 
166
210
  function readCurrentGeneration(dir) {
167
211
  try {
168
- const pointer = JSON.parse(fs.readFileSync(path.join(dir, 'current.json'), 'utf8'));
212
+ const text = readPrivateTextFile(path.join(dir, 'current.json'), {
213
+ maxBytes: TLS_POINTER_MAX_BYTES
214
+ });
215
+ if (text === null) return null;
216
+ const pointer = JSON.parse(text);
169
217
  const generation = String(pointer?.generation || '');
170
218
  return GENERATION_NAME_RE.test(generation) ? generation : null;
171
219
  } catch {
@@ -188,12 +236,12 @@ function generationCreationIsActive(generationDir, generation) {
188
236
  const nameMatch = generation.match(OWNED_GENERATION_NAME_RE);
189
237
  const namePid = nameMatch ? Number.parseInt(nameMatch[1], 10) : null;
190
238
  try {
191
- const ownerPid = Number.parseInt(fs.readFileSync(creatingPath, 'utf8').trim(), 10);
239
+ const ownerPid = Number.parseInt(readPrivateMarker(creatingPath)?.trim(), 10);
192
240
  if (Number.isInteger(ownerPid) && ownerPid > 0 && (!namePid || ownerPid === namePid)) {
193
241
  return processIsAlive(ownerPid);
194
242
  }
195
243
  } catch {}
196
- if (fs.existsSync(path.join(generationDir, '.published'))) return false;
244
+ if (readPrivateMarker(path.join(generationDir, '.published')) !== null) return false;
197
245
 
198
246
  // Backward compatibility for generations created before lifecycle markers
199
247
  // were added, including the mkdir-to-marker window in a concurrent creator.
@@ -214,19 +262,23 @@ function writeGenerationCreatingMarker(generationDir) {
214
262
  function markGenerationPublished(generationDir) {
215
263
  const creatingPath = path.join(generationDir, '.creating');
216
264
  const publishedPath = path.join(generationDir, '.published');
265
+ if (readPrivateMarker(creatingPath) !== null) {
266
+ try { fs.renameSync(creatingPath, publishedPath); return; } catch {}
267
+ }
268
+ if (readPrivateMarker(publishedPath) !== null) return;
269
+ let fd;
217
270
  try {
218
- fs.renameSync(creatingPath, publishedPath);
271
+ fd = fs.openSync(publishedPath, fs.constants.O_WRONLY | fs.constants.O_CREAT |
272
+ fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
273
+ fs.writeFileSync(fd, `${process.pid}\n`);
219
274
  } catch {
220
- try { fs.writeFileSync(publishedPath, `${process.pid}\n`, { mode: 0o600 }); } catch {}
221
- if (fs.existsSync(publishedPath)) {
222
- try { fs.rmSync(creatingPath, { force: true }); } catch {}
223
- }
275
+ // A bad preexisting marker must not block use of otherwise safe credentials.
276
+ } finally {
277
+ if (fd !== undefined) fs.closeSync(fd);
224
278
  }
225
- try { fs.chmodSync(publishedPath, 0o600); } catch {}
226
279
  }
227
280
 
228
281
  function currentGenerationCredentials(dir, sanEntries) {
229
- const pointerPath = path.join(dir, 'current.json');
230
282
  try {
231
283
  const generation = readCurrentGeneration(dir);
232
284
  if (!generation) return null;
@@ -235,10 +287,6 @@ function currentGenerationCredentials(dir, sanEntries) {
235
287
  const certPath = path.join(generationDir, 'self-signed.crt');
236
288
  const credentials = readReusableCredentials(keyPath, certPath, sanEntries);
237
289
  if (!credentials) return null;
238
- try { fs.chmodSync(generationDir, 0o700); } catch {}
239
- try { fs.chmodSync(keyPath, 0o600); } catch {}
240
- try { fs.chmodSync(certPath, 0o600); } catch {}
241
- try { fs.chmodSync(pointerPath, 0o600); } catch {}
242
290
  markGenerationPublished(generationDir);
243
291
  return credentials;
244
292
  } catch {
@@ -252,7 +300,6 @@ function publishGeneration(dir, generation) {
252
300
  try {
253
301
  fs.writeFileSync(tmpPointerPath, `${JSON.stringify({ generation })}\n`, { mode: 0o600 });
254
302
  fs.renameSync(tmpPointerPath, pointerPath);
255
- try { fs.chmodSync(pointerPath, 0o600); } catch {}
256
303
  } finally {
257
304
  try { fs.rmSync(tmpPointerPath, { force: true }); } catch {}
258
305
  }
@@ -266,11 +313,8 @@ function pruneOldGenerations(dir, t = Date.now()) {
266
313
  const generations = fs.readdirSync(dir, { withFileTypes: true })
267
314
  .filter((entry) => entry.isDirectory() && GENERATION_NAME_RE.test(entry.name))
268
315
  .map((entry) => {
269
- try {
270
- return { name: entry.name, mtimeMs: fs.statSync(path.join(dir, entry.name)).mtimeMs };
271
- } catch {
272
- return null;
273
- }
316
+ const stat = trustedTlsDirectory(path.join(dir, entry.name));
317
+ return stat ? { name: entry.name, mtimeMs: Number(stat.mtimeMs), dev: stat.dev, ino: stat.ino } : null;
274
318
  })
275
319
  .filter(Boolean)
276
320
  .sort((a, b) => b.mtimeMs - a.mtimeMs);
@@ -279,12 +323,17 @@ function pruneOldGenerations(dir, t = Date.now()) {
279
323
  for (const generation of generations) {
280
324
  if (generation.name === current) continue;
281
325
  const generationDir = path.join(dir, generation.name);
282
- const hasCreatingMarker = fs.existsSync(path.join(generationDir, '.creating'));
326
+ const hasCreatingMarker = readPrivateMarker(path.join(generationDir, '.creating')) !== null;
283
327
  if (generationCreationIsActive(generationDir, generation.name)) continue;
284
328
  let complete = false;
285
329
  try {
286
- complete = fs.statSync(path.join(generationDir, 'self-signed.key')).size > 0 &&
287
- fs.statSync(path.join(generationDir, 'self-signed.crt')).size > 0;
330
+ const key = readPrivateTextFile(path.join(generationDir, 'self-signed.key'), {
331
+ maxBytes: TLS_KEY_MAX_BYTES
332
+ });
333
+ const cert = readPrivateTextFile(path.join(generationDir, 'self-signed.crt'), {
334
+ maxBytes: TLS_CERT_MAX_BYTES
335
+ });
336
+ complete = Boolean(key?.length && cert?.length);
288
337
  } catch {}
289
338
  if (!keptPrevious && complete && !hasCreatingMarker) {
290
339
  keptPrevious = true;
@@ -293,9 +342,15 @@ function pruneOldGenerations(dir, t = Date.now()) {
293
342
  // A concurrently generated directory may not be published yet. Leave
294
343
  // recent candidates alone; a later startup will collect stale losers.
295
344
  if (t - generation.mtimeMs < GENERATION_CLEANUP_GRACE_MS) continue;
296
- // current.json can change while this best-effort scan is running.
297
- if (readCurrentGeneration(dir) === generation.name) continue;
345
+ // current.json can change while this best-effort scan is running. An
346
+ // unreadable or concurrently replaced pointer is not proof that this
347
+ // generation is stale, so leave cleanup for a later startup.
348
+ const latestCurrent = readCurrentGeneration(dir);
349
+ if (!latestCurrent) return;
350
+ if (latestCurrent === generation.name) continue;
298
351
  if (generationCreationIsActive(generationDir, generation.name)) continue;
352
+ const beforeDelete = trustedTlsDirectory(generationDir);
353
+ if (!beforeDelete || beforeDelete.dev !== generation.dev || beforeDelete.ino !== generation.ino) continue;
299
354
  try { fs.rmSync(generationDir, { recursive: true, force: true }); } catch {}
300
355
  }
301
356
  } catch {
@@ -338,10 +393,8 @@ function createGeneration(dir, sanEntries, source = null) {
338
393
  * browser trust decision is stable.
339
394
  */
340
395
  export function ensureSelfSignedCert(extraHosts = []) {
341
- const dir = path.join(globalStateDir(), 'tls');
396
+ const dir = ensurePrivateGlobalSubdirectory('tls');
342
397
  const sanEntries = lanIpSanEntries(Array.isArray(extraHosts) ? extraHosts : [extraHosts]);
343
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
344
- try { fs.chmodSync(dir, 0o700); } catch {}
345
398
 
346
399
  const existing = currentGenerationCredentials(dir, sanEntries);
347
400
  if (existing) {
@@ -356,8 +409,6 @@ export function ensureSelfSignedCert(extraHosts = []) {
356
409
  sanEntries
357
410
  );
358
411
  if (legacy) {
359
- try { fs.chmodSync(path.join(dir, 'self-signed.key'), 0o600); } catch {}
360
- try { fs.chmodSync(path.join(dir, 'self-signed.crt'), 0o600); } catch {}
361
412
  return createGeneration(dir, sanEntries, legacy);
362
413
  }
363
414
 
@@ -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
- shellCommand, detectBranch
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
- runTmux(['new-session', '-d', '-s', sessionName, '-c', cwd, ...tmuxEnvironmentArgs(tmuxEnv), launch]);
1113
+ runPinnedTmuxNewSession({ cwd, expectedIdentity: expectedCwdIdentity,
1114
+ shell, command, env, sessionName, tmuxEnv });
1051
1115
  createdTmuxSession = true;
1052
1116
  }
1053
1117
 
@@ -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
- try { runTmux(['pipe-pane', '-t', session.pane]); } catch {}
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
- withBufferDirectoryLease(path.dirname(session.pipeFile), () => {
157
- fs.unlinkSync(session.pipeFile);
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
+ }