@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
@@ -1,13 +1,15 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import process from 'node:process';
4
+ import { performance } from 'node:perf_hooks';
4
5
  import { compareProcessIdentity, inspectProcessIdentity } from '../process/identity.mjs';
5
6
  import { CliError } from '../shared/errors.mjs';
6
7
  import { withFileLock } from '../shared/file-lock.mjs';
7
8
  import { writeJsonSafe } from '../shared/json-file.mjs';
9
+ import { readPrivateTextFile } from './private-file.mjs';
10
+ import { resolveProjectDatabase, secureExistingProjectStateDirectory } from './project-path.mjs';
8
11
  import {
9
- globalRuntimePath,
10
- runtimePath
12
+ globalRuntimePath
11
13
  } from './paths.mjs';
12
14
  import { runtimeHttpRequest } from '../web/runtime.mjs';
13
15
  import { API_VERSION } from '../web/api-version.mjs';
@@ -16,6 +18,18 @@ const DEFAULT_PRODUCT_NAME = 'hello-cc';
16
18
  const DEFAULT_CLI_NAME = 'hcc';
17
19
  export const RUNTIME_POINTER_UNKNOWN_GRACE_MS = 120_000;
18
20
 
21
+ function validatedProjectRuntimeFile(root, { create = false } = {}) {
22
+ const state = create
23
+ ? resolveProjectDatabase({
24
+ root, db: path.join(path.resolve(root), '.hello-cc', 'runtime.json'), createStateDir: true
25
+ })
26
+ : secureExistingProjectStateDirectory(root);
27
+ // Validate this target, not an unused default mesh.db (custom/external DBs
28
+ // remain supported). Keep the returned state route instead of resolving a
29
+ // mutable root alias again after validation.
30
+ return path.join(state.stateDir, 'runtime.json');
31
+ }
32
+
19
33
  function completeProcessIdentity(value) {
20
34
  return compareProcessIdentity(value, value) === 'live';
21
35
  }
@@ -77,11 +91,15 @@ export function reclaimRuntimePointerFiles(files, {
77
91
  reclaimUnknown = true,
78
92
  dryRun = false,
79
93
  onReclaim = null,
94
+ expectedIdentity = null,
80
95
  withLock = withFileLock
81
96
  } = {}) {
82
97
  if (typeof reclaimUnknown !== 'boolean') {
83
98
  throw new TypeError('reclaimUnknown must be a boolean');
84
99
  }
100
+ if (expectedIdentity !== null && !completeProcessIdentity(expectedIdentity)) {
101
+ throw new TypeError('expectedIdentity must be a complete process identity');
102
+ }
85
103
  let blocked = false;
86
104
  let reclaimed = 0;
87
105
  const outcomes = [];
@@ -107,6 +125,16 @@ export function reclaimRuntimePointerFiles(files, {
107
125
  const raw = fs.readFileSync(file, 'utf8');
108
126
  let runtime = null;
109
127
  try { runtime = JSON.parse(raw); } catch {}
128
+ if (expectedIdentity !== null) {
129
+ const currentIdentity = runtimeIdentityEvidence(runtime).identity;
130
+ if (Number(runtime?.pid) !== expectedIdentity.pid ||
131
+ compareProcessIdentity(expectedIdentity, currentIdentity) !== 'live' ||
132
+ currentIdentity.commandHash !== expectedIdentity.commandHash) {
133
+ blocked = true;
134
+ record({ state: 'unknown', action: 'blocked', reason: 'owner_changed' });
135
+ return;
136
+ }
137
+ }
110
138
  const timing = runtimePointerAge(stat, nowMs(), unknownGraceMs);
111
139
  const evidence = classifyRuntimePointer(runtime, {
112
140
  inspect,
@@ -200,9 +228,10 @@ function runtimeMetadataMatchesPointer(runtime, metadata) {
200
228
 
201
229
  export function readGlobalRuntimeFile() {
202
230
  const file = globalRuntimePath();
203
- if (!fs.existsSync(file)) return null;
231
+ const raw = readPrivateTextFile(file);
232
+ if (raw === null) return null;
204
233
  try {
205
- return JSON.parse(fs.readFileSync(file, 'utf8'));
234
+ return JSON.parse(raw);
206
235
  } catch {
207
236
  // Do not delete on a parse failure: a torn/partial write must not orphan a
208
237
  // healthy runtime pointer. Leave it for health probing / restart to reclaim.
@@ -217,7 +246,7 @@ export function writeGlobalRuntime(runtime) {
217
246
  }
218
247
 
219
248
  export function writeRuntime(ctx, runtime) {
220
- const file = runtimePath(ctx);
249
+ const file = validatedProjectRuntimeFile(ctx.root, { create: true });
221
250
  withFileLock(file, (lockedFile) => writeJsonSafe(lockedFile, runtime, { mode: 0o600 }));
222
251
  return file;
223
252
  }
@@ -235,10 +264,11 @@ export function readRuntime(ctx, opts = {}) {
235
264
  source: 'env'
236
265
  };
237
266
  }
238
- const file = runtimePath(ctx);
239
- if (fs.existsSync(file)) {
267
+ const file = validatedProjectRuntimeFile(ctx.root);
268
+ const raw = readPrivateTextFile(file);
269
+ if (raw !== null) {
240
270
  try {
241
- const runtime = JSON.parse(fs.readFileSync(file, 'utf8'));
271
+ const runtime = JSON.parse(raw);
242
272
  if (!runtime.base_url) throw new Error('missing base_url');
243
273
  if (!runtimePointerMatchesCurrentProcess(runtime, { inspect: inspectRuntimeProcessIdentity })) {
244
274
  throw new Error('runtime process identity does not match');
@@ -264,9 +294,9 @@ export function readRuntime(ctx, opts = {}) {
264
294
  }
265
295
 
266
296
  export function readRuntimeFile(ctx) {
267
- const file = runtimePath(ctx);
268
- if (!fs.existsSync(file)) return null;
269
- return JSON.parse(fs.readFileSync(file, 'utf8'));
297
+ const file = validatedProjectRuntimeFile(ctx.root);
298
+ const raw = readPrivateTextFile(file);
299
+ return raw === null ? null : JSON.parse(raw);
270
300
  }
271
301
 
272
302
  export async function probeRuntime(runtime) {
@@ -306,30 +336,42 @@ export async function readHealthyGlobalRuntime() {
306
336
  }
307
337
  }
308
338
 
309
- export function clearRuntime(ctx, pid = process.pid) {
310
- const file = runtimePath(ctx);
311
- try {
339
+ export function clearRuntime(ctx, pid = process.pid, {
340
+ nonblocking = false, clearGlobal = true, expectedIdentity = null,
341
+ deadline = Infinity, monotonicNow = () => performance.now()
342
+ } = {}) {
343
+ if (expectedIdentity !== null && !completeProcessIdentity(expectedIdentity)) {
344
+ throw new TypeError('expectedIdentity must be a complete process identity');
345
+ }
346
+ function clearOwnedFile(file) {
347
+ if (monotonicNow() >= deadline) return;
312
348
  withFileLock(file, (lockedFile) => {
313
- if (!fs.existsSync(lockedFile)) return;
349
+ if (monotonicNow() >= deadline) return;
350
+ ctx.rootIdentity?.assertUnchanged();
351
+ const raw = readPrivateTextFile(lockedFile);
352
+ if (raw === null) return;
314
353
  try {
315
- const runtime = JSON.parse(fs.readFileSync(lockedFile, 'utf8'));
354
+ const runtime = JSON.parse(raw);
355
+ if (expectedIdentity !== null) {
356
+ const currentIdentity = runtimeIdentityEvidence(runtime).identity;
357
+ if (runtime.pid !== expectedIdentity.pid ||
358
+ compareProcessIdentity(expectedIdentity, currentIdentity) !== 'live' ||
359
+ currentIdentity.commandHash !== expectedIdentity.commandHash) return;
360
+ }
316
361
  if (runtime.pid === pid) fs.rmSync(lockedFile, { force: true });
317
362
  } catch {
318
363
  // Leave an unparseable pointer in place: only remove when we can confirm
319
364
  // it belongs to this pid, so a torn write never deletes another runtime's file.
320
365
  }
321
- });
366
+ }, { nonblocking });
367
+ }
368
+ try {
369
+ if (monotonicNow() >= deadline) return;
370
+ ctx.rootIdentity?.assertUnchanged();
371
+ clearOwnedFile(validatedProjectRuntimeFile(ctx.root));
322
372
  } catch {}
323
- const globalFile = globalRuntimePath();
373
+ if (!clearGlobal || monotonicNow() >= deadline) return;
324
374
  try {
325
- withFileLock(globalFile, (lockedFile) => {
326
- if (!fs.existsSync(lockedFile)) return;
327
- try {
328
- const runtime = JSON.parse(fs.readFileSync(lockedFile, 'utf8'));
329
- if (runtime.pid === pid) fs.rmSync(lockedFile, { force: true });
330
- } catch {
331
- // Same as above: do not blind-delete a torn global runtime pointer.
332
- }
333
- });
375
+ clearOwnedFile(globalRuntimePath());
334
376
  } catch {}
335
377
  }
@@ -0,0 +1,6 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export function automaticResultKey({ executorId, threadId, turnId, kind, itemId = null }) {
4
+ const identity = [executorId, threadId, turnId, kind, itemId];
5
+ return `automatic:${kind}:${createHash('sha256').update(JSON.stringify(identity)).digest('hex')}`;
6
+ }
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from 'node:crypto';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
 
@@ -8,11 +9,59 @@ export function readJsonSafe(filePath) {
8
9
 
9
10
  export function writeJsonSafe(filePath, data, opts = {}) {
10
11
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
11
- const tmpPath = filePath + '.tmp.' + process.pid;
12
- const writeOpts = opts.mode !== undefined ? { mode: opts.mode } : undefined;
13
- fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n', writeOpts);
14
- fs.renameSync(tmpPath, filePath);
15
- if (opts.mode !== undefined) {
16
- try { fs.chmodSync(filePath, opts.mode); } catch {}
12
+ const tmpPath = `${filePath}.tmp.${process.pid}.${randomBytes(16).toString('hex')}`;
13
+ const contents = JSON.stringify(data, null, 2) + '\n';
14
+ let fd = null;
15
+ let opened = null;
16
+ let renamed = false;
17
+ try {
18
+ fd = fs.openSync(tmpPath, fs.constants.O_WRONLY | fs.constants.O_CREAT |
19
+ fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600);
20
+ opened = fs.fstatSync(fd);
21
+ if (!opened.isFile() || opened.nlink !== 1 ||
22
+ (typeof process.geteuid === 'function' && opened.uid !== process.geteuid())) {
23
+ throw new Error(`unsafe JSON temporary file: ${tmpPath}`);
24
+ }
25
+ if (process.platform !== 'win32') fs.fchmodSync(fd, 0o600);
26
+ const privateStat = fs.fstatSync(fd);
27
+ if (!privateStat.isFile() || privateStat.nlink !== 1 ||
28
+ privateStat.dev !== opened.dev || privateStat.ino !== opened.ino ||
29
+ (typeof process.geteuid === 'function' && privateStat.uid !== process.geteuid()) ||
30
+ (process.platform !== 'win32' && (privateStat.mode & 0o777) !== 0o600)) {
31
+ throw new Error(`unsafe JSON temporary file: ${tmpPath}`);
32
+ }
33
+ fs.writeFileSync(fd, contents, 'utf8');
34
+ if (opts.mode !== undefined && opts.mode !== 0o600 && process.platform !== 'win32') {
35
+ fs.fchmodSync(fd, opts.mode);
36
+ }
37
+ const ready = fs.fstatSync(fd);
38
+ if (!ready.isFile() || ready.nlink !== 1 ||
39
+ ready.dev !== opened.dev || ready.ino !== opened.ino ||
40
+ (typeof process.geteuid === 'function' && ready.uid !== process.geteuid()) ||
41
+ (process.platform !== 'win32' && (ready.mode & 0o777) !== (opts.mode ?? 0o600))) {
42
+ throw new Error(`unsafe JSON temporary file: ${tmpPath}`);
43
+ }
44
+ fs.fsyncSync(fd);
45
+ fs.closeSync(fd);
46
+ fd = null;
47
+ const current = fs.lstatSync(tmpPath);
48
+ if (!current.isFile() || current.nlink !== 1 ||
49
+ current.dev !== opened.dev || current.ino !== opened.ino ||
50
+ (typeof process.geteuid === 'function' && current.uid !== process.geteuid()) ||
51
+ (process.platform !== 'win32' && (current.mode & 0o777) !== (opts.mode ?? 0o600))) {
52
+ throw new Error(`JSON temporary file changed before rename: ${tmpPath}`);
53
+ }
54
+ fs.renameSync(tmpPath, filePath);
55
+ renamed = true;
56
+ } finally {
57
+ if (fd !== null) fs.closeSync(fd);
58
+ if (!renamed && opened) {
59
+ try {
60
+ const current = fs.lstatSync(tmpPath);
61
+ if (current.dev === opened.dev && current.ino === opened.ino) fs.unlinkSync(tmpPath);
62
+ } catch {
63
+ // Preserve the write failure. Any remaining temp file is private.
64
+ }
65
+ }
17
66
  }
18
67
  }
@@ -6,6 +6,7 @@ function isSecretKey(key) {
6
6
  return normalized === 'authorization' ||
7
7
  normalized === 'cookie' ||
8
8
  normalized === 'setcookie' ||
9
+ /^hccsid(?:v2https?\d+)?$/.test(normalized) ||
9
10
  normalized.includes('secret') ||
10
11
  normalized.endsWith('password') ||
11
12
  normalized.endsWith('credential') ||
@@ -21,7 +22,7 @@ function redactString(value) {
21
22
  .replace(/\bBearer\s+[^\s,;]+/gi, `Bearer ${REDACTED}`)
22
23
  .replace(/(--token(?:=|\s+))(?:"[^"]*"|'[^']*'|[^\s]+)/gi, `$1${REDACTED}`)
23
24
  .replace(/([?&](?:token|access_token|api_key)=)[^&#\s]*/gi, `$1${REDACTED}`)
24
- .replace(/(hcc_sid=)[^;\s]*/gi, `$1${REDACTED}`);
25
+ .replace(/(hcc_sid(?:_v2_https?_\d+)?=)[^;\s]*/gi, `$1${REDACTED}`);
25
26
  }
26
27
 
27
28
  export function redactCliArgs(args) {
package/lib/ui/help.mjs CHANGED
@@ -21,7 +21,8 @@ Commands:
21
21
  down Stop the running hello-cc web runtime
22
22
  update Update the global npm install of hello-cc
23
23
  uninstall Remove hooks, shims, and optional project data
24
- init Initialize .hello-cc/mesh.db and guidance
24
+ init Initialize private managed mesh.db and guidance
25
+ migrate-state --offline --yes Copy stopped legacy state to private per-user storage
25
26
  register --peer ID Register or update a peer session identity
26
27
  join --peer ID Register this shell and print eval-able env
27
28
  env --peer ID Print eval-able HCC_PEER/HCC_ROOT/HCC_DB exports
@@ -405,9 +406,10 @@ Usage:
405
406
  Stops the current project runtime and removes user-level hello-cc integrations:
406
407
  Claude/Codex hooks, claude/codex shims, and the shell PATH entry for shims.
407
408
 
408
- With --purge --yes, also removes current project data:
409
- .hello-cc/
410
- hello-cc blocks from CLAUDE.md and AGENTS.md
409
+ With --purge --yes, also removes the selected managed project state directory
410
+ (project-local for stable roots, otherwise under ~/.hello-cc/projects/) and
411
+ hello-cc blocks from CLAUDE.md and AGENTS.md. An old directory retained by
412
+ offline migration is not removed.
411
413
  `);
412
414
  },
413
415
 
@@ -0,0 +1,110 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { CliError } from '../shared/errors.mjs';
4
+ import { tx } from '../db/schema.mjs';
5
+
6
+ const META_KEY = 'web_agent_defaults_v1';
7
+ const PROVIDERS = ['codex', 'claude', 'dsh'];
8
+
9
+ function invalid(message) { return new CliError('BAD_REQUEST', message); }
10
+ function plainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
11
+ function exactKeys(value, keys) {
12
+ return plainObject(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
13
+ }
14
+ function initialDefaults() {
15
+ return { revision: 0, defaultProvider: 'codex', providers: Object.fromEntries(PROVIDERS.map(provider => [provider, { model: null, cwd: '.' }])) };
16
+ }
17
+ function normalizeModel(value) {
18
+ if (value === null) return null;
19
+ if (typeof value !== 'string' || value.length > 256 || /[\x00-\x1f\x7f]/.test(value)) throw invalid('Model must be null or a name of at most 256 characters without control characters');
20
+ return value.trim() || null;
21
+ }
22
+ function relativeCwd(value) {
23
+ if (typeof value !== 'string' || !value.trim() || value.length > 4096 || /[\\:\x00-\x1f\x7f]/.test(value) ||
24
+ path.posix.isAbsolute(value.trim()) || path.win32.isAbsolute(value.trim()) || value.trim().split('/').includes('..')) {
25
+ throw invalid('Default working directory must be a project-relative directory, using . for the project root');
26
+ }
27
+ return path.posix.normalize(value.trim()).replace(/\/$/, '') || '.';
28
+ }
29
+ function assertRoot(ctx) { ctx.rootIdentity?.assertUnchanged(); }
30
+ function existingCwd(ctx, relative) {
31
+ assertRoot(ctx);
32
+ let root, target;
33
+ try {
34
+ root = fs.realpathSync(ctx.root); target = fs.realpathSync(path.resolve(root, relative));
35
+ if (!fs.statSync(target).isDirectory()) throw new Error('not a directory');
36
+ } catch { throw invalid('Default working directory must already exist inside the selected project'); }
37
+ assertRoot(ctx);
38
+ const fromRoot = path.relative(root, target);
39
+ if (fromRoot === '..' || fromRoot.startsWith('..' + path.sep) || path.isAbsolute(fromRoot)) {
40
+ throw new CliError('PROJECT_PATH_FORBIDDEN', 'Default working directory must stay inside the selected project');
41
+ }
42
+ const canonical = fromRoot.split(path.sep).join('/') || '.';
43
+ if (relativeCwd(canonical) !== canonical) throw invalid('The resolved default directory cannot be represented as a stable project-relative setting');
44
+ return canonical;
45
+ }
46
+ function validateDocument(value) {
47
+ if (!exactKeys(value, ['revision', 'defaultProvider', 'providers']) ||
48
+ !Number.isSafeInteger(value.revision) || value.revision < 0 || Object.is(value.revision, -0) ||
49
+ !PROVIDERS.includes(value.defaultProvider) || !exactKeys(value.providers, PROVIDERS)) {
50
+ throw invalid('Agent defaults require a safe revision, a native provider and complete codex, claude and dsh settings');
51
+ }
52
+ const providers = {};
53
+ for (const provider of PROVIDERS) {
54
+ const setting = value.providers[provider];
55
+ if (!exactKeys(setting, ['model', 'cwd'])) throw invalid('Each provider setting accepts only model and cwd');
56
+ providers[provider] = { model: normalizeModel(setting.model), cwd: relativeCwd(setting.cwd) };
57
+ }
58
+ return { revision: value.revision, defaultProvider: value.defaultProvider, providers };
59
+ }
60
+ function storedDefaults(db) {
61
+ const row = db.prepare('SELECT value FROM meta WHERE key=?').get(META_KEY);
62
+ if (!row) return initialDefaults();
63
+ try {
64
+ if (typeof row.value !== 'string' || row.value.length > 16384) throw new Error('invalid settings');
65
+ const value = validateDocument(JSON.parse(row.value));
66
+ if (value.revision === 0) throw new Error('invalid saved revision');
67
+ return value;
68
+ } catch {
69
+ throw new CliError('AGENT_DEFAULTS_INVALID', 'Saved Agent defaults are invalid; inspect the project configuration before saving or creating a worker');
70
+ }
71
+ }
72
+
73
+ /** HCC project settings only. Provider accounts, processes and config files are never touched. */
74
+ export function createAgentDefaults({ connectWebProject }) {
75
+ function readAgentDefaults(ctx) {
76
+ assertRoot(ctx);
77
+ const db = connectWebProject(ctx);
78
+ try { const value = storedDefaults(db); assertRoot(ctx); return value; }
79
+ finally { db.close(); }
80
+ }
81
+ function saveAgentDefaults(ctx, input) {
82
+ const requested = validateDocument(input);
83
+ if (requested.revision === Number.MAX_SAFE_INTEGER) throw invalid('Agent defaults revision is exhausted');
84
+ assertRoot(ctx);
85
+ const db = connectWebProject(ctx);
86
+ try {
87
+ return tx(db, () => {
88
+ assertRoot(ctx);
89
+ const current = storedDefaults(db);
90
+ if (requested.revision !== current.revision) throw new CliError('AGENT_DEFAULTS_CONFLICT', 'Agent defaults changed in another view; reload them before saving your draft');
91
+ const providers = {};
92
+ for (const provider of PROVIDERS) providers[provider] = { model: requested.providers[provider].model,
93
+ cwd: existingCwd(ctx, requested.providers[provider].cwd) };
94
+ const saved = { revision: current.revision + 1, defaultProvider: requested.defaultProvider, providers };
95
+ assertRoot(ctx);
96
+ db.prepare('INSERT INTO meta(key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value')
97
+ .run(META_KEY, JSON.stringify(saved));
98
+ assertRoot(ctx);
99
+ return saved;
100
+ });
101
+ } finally { db.close(); }
102
+ }
103
+ function resolveNativeDefaults(ctx, input) {
104
+ const saved = readAgentDefaults(ctx), kind = input.kind === undefined ? saved.defaultProvider : input.kind;
105
+ if (!PROVIDERS.includes(kind)) throw invalid('Use native provider codex, claude or dsh');
106
+ const setting = saved.providers[kind], model = input.model === undefined ? setting.model : normalizeModel(input.model);
107
+ return { ...input, kind, cwd: input.cwd === undefined ? setting.cwd : input.cwd, model: model === null ? undefined : model };
108
+ }
109
+ return { readAgentDefaults, saveAgentDefaults, resolveNativeDefaults };
110
+ }
@@ -11,7 +11,7 @@ import { adoptionPaused } from './adoption-state.mjs';
11
11
 
12
12
  export function createAutoAttach(deps) {
13
13
  const {
14
- ctx, sessions,
14
+ ctx, sessions, isStopping = () => false,
15
15
  connectWebProject, now, addEvent,
16
16
  reconcileRunningPeerBindings,
17
17
  ACTIVE_PEER_TTL, redactedLogText, sessionsForProject,
@@ -66,6 +66,7 @@ export function createAutoAttach(deps) {
66
66
  }
67
67
 
68
68
  function reconcileRunningBindings(projectCtx = ctx, panes = null) {
69
+ if (isStopping()) return null;
69
70
  const db = connectWebProject(projectCtx);
70
71
  try {
71
72
  const tmuxPanes = Array.isArray(panes) ? panes : listTmuxPanesOnce();
@@ -86,7 +87,7 @@ export function createAutoAttach(deps) {
86
87
  let autoAttachScanInFlight = false;
87
88
 
88
89
  function scanAndAttachDetectedPeers() {
89
- if (autoAttachScanInFlight) return;
90
+ if (isStopping() || autoAttachScanInFlight) return;
90
91
  autoAttachScanInFlight = true;
91
92
  let db = null;
92
93
  try {
@@ -0,0 +1,10 @@
1
+ /** Server authority; browser drafts/selection/scroll positions are separate. */
2
+ export type Provider = 'codex' | 'claude' | 'dsh' | 'shell';
3
+ export interface Capabilities { send?: boolean; steer?: boolean; interrupt?: boolean; approval?: boolean; commands?: boolean; [name: string]: boolean | undefined }
4
+ export interface Approval { id: string; type: string; turnId?: string; requestId?: string; status?: string }
5
+ export interface Delivery { message_id: string; submission_id?: string; turn_id?: string; state: string; error?: string }
6
+ export interface Item { id: string; type: string; text?: string; status?: string }
7
+ export interface Turn { id: string; status: string; items: Item[] }
8
+ export interface Session { id: string; root: string; type: 'native' | 'app-server' | 'tmux' | 'pty'; kind: Provider; status: string; executor_id?: string; capabilities?: Capabilities }
9
+ export interface PaneView { project: string; session: string | null; draft: string; scrollTop: number; selectedTab: 'conversation' | 'trace' }
10
+ export interface StateSyncFrame { type: 'state_sync'; protocol: 1; mode: 'snapshot' | 'patch'; channel: 'codex' | 'native'; root: string; sessionId: string; executorId: string; generation: string; revision: number; baseRevision?: number; state?: unknown; operations?: unknown[] }