@logicseek/hello-cc 1.0.2-dsh.3 → 1.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/README.md +10 -1
  3. package/README.zh-CN.md +8 -1
  4. package/docs/commands.md +28 -4
  5. package/docs/commands.zh-CN.md +20 -4
  6. package/docs/native.md +84 -10
  7. package/docs/native.zh-CN.md +66 -5
  8. package/docs/web-handoff.zh-CN.md +11 -3
  9. package/lib/cli/app.mjs +42 -1
  10. package/lib/cli/commands/coordination.mjs +7 -2
  11. package/lib/cli/commands/dsh.mjs +1 -1
  12. package/lib/cli/commands/gc.mjs +9 -4
  13. package/lib/cli/commands/hook.mjs +6 -1
  14. package/lib/cli/commands/install.mjs +48 -6
  15. package/lib/cli/commands/lifecycle.mjs +14 -3
  16. package/lib/cli/commands/native.mjs +16 -9
  17. package/lib/cli/commands/query.mjs +15 -10
  18. package/lib/cli/commands/run.mjs +7 -6
  19. package/lib/core/coordination/results.mjs +27 -10
  20. package/lib/db/connection.mjs +135 -10
  21. package/lib/db/schema.mjs +59 -0
  22. package/lib/discover.mjs +4 -4
  23. package/lib/guidance.mjs +3 -2
  24. package/lib/integrations/codex-interactions.mjs +13 -11
  25. package/lib/integrations/dsh-collaboration.mjs +6 -12
  26. package/lib/integrations/dsh-cordis.mjs +17 -13
  27. package/lib/integrations/dsh.mjs +12 -9
  28. package/lib/integrations/hooks.mjs +132 -40
  29. package/lib/integrations/mcp-elicitation.mjs +116 -0
  30. package/lib/integrations/mcp-url-elicitation.mjs +25 -0
  31. package/lib/integrations/native/interactions.mjs +2 -1
  32. package/lib/integrations/peers/identity.mjs +42 -7
  33. package/lib/process/identity.mjs +15 -14
  34. package/lib/runtime/buffer-evidence.mjs +2 -1
  35. package/lib/runtime/native/service.mjs +11 -5
  36. package/lib/runtime/native/store.mjs +70 -18
  37. package/lib/runtime/paths.mjs +17 -2
  38. package/lib/runtime/private-file.mjs +76 -0
  39. package/lib/runtime/private-state.mjs +546 -0
  40. package/lib/runtime/project-context.mjs +2 -1
  41. package/lib/runtime/project-path.mjs +148 -12
  42. package/lib/runtime/project-trust.mjs +79 -0
  43. package/lib/runtime/projects.mjs +6 -3
  44. package/lib/runtime/state-migration.mjs +697 -0
  45. package/lib/runtime/state.mjs +22 -11
  46. package/lib/shared/automatic-result-key.mjs +6 -0
  47. package/lib/shared/json-file.mjs +55 -6
  48. package/lib/ui/help.mjs +6 -4
  49. package/lib/web/codex-app-server.mjs +26 -5
  50. package/lib/web/codex-sessions.mjs +5 -3
  51. package/lib/web/cookie-auth.mjs +1 -1
  52. package/lib/web/external-sessions.mjs +54 -5
  53. package/lib/web/http-routes.mjs +13 -9
  54. package/lib/web/http.mjs +20 -2
  55. package/lib/web/native-sessions.mjs +14 -7
  56. package/lib/web/peer-actions.mjs +27 -27
  57. package/lib/web/project-contexts.mjs +2 -3
  58. package/lib/web/runtime-main.mjs +22 -5
  59. package/lib/web/runtime.mjs +18 -4
  60. package/lib/web/session-serialize.mjs +1 -1
  61. package/lib/web/startup.mjs +115 -22
  62. package/lib/web/task-results.mjs +20 -17
  63. package/lib/web/terminal-assets.mjs +18 -0
  64. package/lib/web/tls.mjs +84 -33
  65. package/lib/web/tmux-sessions.mjs +2 -2
  66. package/lib/web/tmux-stream.mjs +3 -4
  67. package/lib/web/ui-codex.mjs +161 -32
  68. package/lib/web/ui-interactions.mjs +137 -4
  69. package/lib/web/ui-native.mjs +31 -13
  70. package/lib/web/ui-template.mjs +10 -3
  71. package/package.json +1 -1
  72. package/scripts/mcp-form-live-fixture.mjs +81 -0
  73. package/scripts/native-installed-acceptance.mjs +413 -0
  74. package/scripts/regression.mjs +46 -29
  75. package/scripts/web-mcp-form-acceptance.mjs +119 -0
  76. package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
  77. package/scripts/web-native-interaction-acceptance.mjs +77 -10
package/lib/cli/app.mjs CHANGED
@@ -89,6 +89,9 @@ import { assignTeamWorkers, expandTeamWorkers, inferTeamItems } from '../core/co
89
89
  import { autoPeerBasis, autoPeerKind, autoPeerProviderSession, autoPeerResumeId, autoPeerSessionId, readAncestorCliInfo, resolveCurrentPeer, resumeIdFromArgs, shortHash } from '../integrations/peers/identity.mjs';
90
90
  import { inspectProviderProcess } from '../integrations/peers/processes.mjs';
91
91
  import { registerProjectActivity } from '../runtime/projects.mjs';
92
+ import { migrateLegacyProjectState } from '../runtime/state-migration.mjs';
93
+ import { projectDbPath } from '../runtime/paths.mjs';
94
+ import { ensureDshIntegration } from '../integrations/dsh.mjs';
92
95
 
93
96
  // Lazy-load lib modules (they may import node-pty which needs to be optional)
94
97
  const _libDir = path.resolve(fileURLToPath(import.meta.url), '..', '..');
@@ -303,7 +306,7 @@ const { cmdInit, cmdRegister, cmdEnv, cmdJoin } = createLifecycleCommands({
303
306
  connect, now, tx, addEvent, printResult, CliError, parseOpts, intOpt,
304
307
  registerProjectActivity, resolveCurrentPeer, resolveTargetPeer,
305
308
  upsertPeer, upsertCanonicalPeerBinding, storedPeerIdentity, detectBranch,
306
- writeGuidance, helpEnv, helpJoin, shellExports, path, process
309
+ readAncestorCliInfo, writeGuidance, helpEnv, helpJoin, shellExports, path, process
307
310
  });
308
311
 
309
312
  const { cmdTeam } = createTeamCommands({
@@ -580,6 +583,43 @@ function removeGuidanceBlocks(ctx) {
580
583
  return removeGuidanceBlocksForRoot(ctx.root);
581
584
  }
582
585
 
586
+ function cmdMigrateState(ctx, args) {
587
+ if (wantsHelp(args)) {
588
+ console.log(`${CLI_NAME} migrate-state --offline --yes\n\n` +
589
+ 'Stop all old hello-cc Web, Native, PTY, tmux, hook and external database writers first. ' +
590
+ 'This command verifies known process evidence, snapshots SQLite including WAL, and leaves the old directory untouched.');
591
+ return;
592
+ }
593
+ const opts = parseOpts(args, { booleans: ['offline', 'yes'] });
594
+ validateOpts('migrate-state', opts, ['offline', 'yes']);
595
+ if (opts.offline !== true || opts.yes !== true) {
596
+ throw new CliError('STATE_MIGRATION_OFFLINE_REQUIRED',
597
+ 'Stop every old project-state writer, then acknowledge downtime with migrate-state --offline --yes');
598
+ }
599
+ const result = migrateLegacyProjectState(ctx.root, {
600
+ confirmedOffline: true,
601
+ // The operator's explicit --offline assertion covers external writers
602
+ // that cannot be discovered reliably across supported platforms. The
603
+ // migrator still checks known runtime, PTY and SQLite peer evidence.
604
+ assertOffline: () => true
605
+ });
606
+ let dshRebuilt = false;
607
+ if (result.dsh) {
608
+ try {
609
+ ensureDshIntegration({ ...ctx, dbPath: projectDbPath(ctx.root) }, { mode: result.dsh.mode });
610
+ dshRebuilt = true;
611
+ } catch (error) {
612
+ throw new CliError('STATE_MIGRATION_DSH_REBUILD_REQUIRED',
613
+ `Project state migrated to ${result.stateDir}, but DSH configuration needs rebuilding. ` +
614
+ `Run ${result.dsh.rebuildCommand}. Original data remains at ${result.sourceDir}.`,
615
+ { cause: error });
616
+ }
617
+ }
618
+ printResult(ctx, { ...result, dsh_rebuilt: dshRebuilt },
619
+ () => `project state migrated: ${result.stateDir}\nlegacy source kept: ${result.sourceDir}` +
620
+ (dshRebuilt ? '\nDSH integration rebuilt for the new private path' : ''));
621
+ }
622
+
583
623
  // ─── hcc doctor ─────────────────────────────────────────────────────────────
584
624
  // Health self-check: SQLite integrity, schema version, WAL/DB size, row counts.
585
625
  async function dispatch(ctx, rest) {
@@ -592,6 +632,7 @@ async function dispatch(ctx, rest) {
592
632
  if (command === 'update') return cmdUpdate(ctx, args);
593
633
  if (command === 'uninstall') return cmdUninstall(ctx, args);
594
634
  if (command === 'init') return cmdInit(ctx, args);
635
+ if (command === 'migrate-state') return cmdMigrateState(ctx, args);
595
636
  if (command === 'register') return cmdRegister(ctx, args);
596
637
  if (command === 'join') return cmdJoin(ctx, args);
597
638
  if (command === 'env') return cmdEnv(ctx, args);
@@ -184,8 +184,13 @@ export function createCoordinationCommands(deps) {
184
184
  const kind = opts.kind || 'broadcast';
185
185
  const runtime = opts.inject ? readRuntime(ctx) : null;
186
186
  const db = connect(ctx);
187
- touchCurrentPeer(db, ctx, identity, null, 'shell');
188
- const id = sendMessage(db, sender, 'all', taskId, kind, body);
187
+ let id;
188
+ try {
189
+ touchCurrentPeer(db, ctx, identity, null, 'shell');
190
+ id = sendMessage(db, sender, 'all', taskId, kind, body);
191
+ } finally {
192
+ db.close();
193
+ }
189
194
  let injected = 0;
190
195
  let skipped = 0;
191
196
  if (opts.inject) {
@@ -14,7 +14,7 @@ function defaultHelp() {
14
14
  ' hcc dsh status [--dsh-bin PATH]',
15
15
  ' hcc dsh web [--mode hooks|cordis|off] [--dsh-bin PATH] [--dsh-home PATH] -- [dsh web arguments]',
16
16
  '',
17
- 'Setup writes project-local configuration under .hello-cc/dsh/. Modes: hooks (default), cordis (native tools), off.',
17
+ 'Setup writes managed configuration under the selected private state directory. Modes: hooks (default), cordis (native tools), off.',
18
18
  'Web starts the official dsh web runtime; its real sessions join hello-cc through the selected integration.',
19
19
  'Pass runtime arguments after --, for example: hcc dsh web -- --port 8080',
20
20
  'Show dsh web help: hcc dsh web -- --help',
@@ -26,6 +26,7 @@ import {
26
26
  import { runOptimisticEvidenceMutation } from '../../core/coordination/optimistic-evidence.mjs';
27
27
  import { peerEvidenceAllowsReap } from '../../core/peers/evidence.mjs';
28
28
  import { runtimePath, globalRuntimePath } from '../../runtime/paths.mjs';
29
+ import { resolveProjectDatabase } from '../../runtime/project-path.mjs';
29
30
  import { readRuntime, reclaimRuntimePointerFiles } from '../../runtime/state.mjs';
30
31
  import { runtimeRequest, runtimeBufferGcUnavailable } from '../../runtime/client.mjs';
31
32
  import { collectBufferEvidence } from '../../runtime/buffer-evidence.mjs';
@@ -263,12 +264,16 @@ function captureRequiredBufferDirectoryComponent(component, description) {
263
264
  function captureOptionalBufferDirectory(root, directoryName) {
264
265
  const requestedRoot = path.resolve(root);
265
266
  const canonicalRoot = fs.realpathSync.native(requestedRoot);
266
- const stateDirectory = path.join(canonicalRoot, '.hello-cc');
267
+ const stateDirectory = resolveProjectDatabase({ root: canonicalRoot, createStateDir: false }).stateDir;
267
268
  const directory = path.join(stateDirectory, directoryName);
268
269
  const components = [
269
- captureRequiredBufferDirectoryComponent(canonicalRoot, 'Project root'),
270
- captureRequiredBufferDirectoryComponent(stateDirectory, 'Project state directory')
270
+ captureRequiredBufferDirectoryComponent(canonicalRoot, 'Project root')
271
271
  ];
272
+ try {
273
+ components.push(captureRequiredBufferDirectoryComponent(stateDirectory, 'Project state directory'));
274
+ } catch (error) {
275
+ if (error?.code !== 'ENOENT') throw error;
276
+ }
272
277
 
273
278
  let stat;
274
279
  try {
@@ -624,7 +629,7 @@ function runGcWithHistorySnapshot(ctx, db, {
624
629
  }
625
630
 
626
631
  function bufferDirectory(ctx) {
627
- return path.resolve(path.join(ctx.root, '.hello-cc', BUFS_DIR_NAME));
632
+ return path.join(resolveProjectDatabase({ root: ctx.root, createStateDir: false }).stateDir, BUFS_DIR_NAME);
628
633
  }
629
634
 
630
635
  function bufferDirectoryPaths(directory) {
@@ -4,6 +4,7 @@
4
4
  import { parseOpts, validateOpts } from '../../cli-args.mjs';
5
5
  import { tx } from '../../db/schema.mjs';
6
6
  import { resolveProjectDatabase } from '../../runtime/project-path.mjs';
7
+ import { projectDbPath } from '../../runtime/paths.mjs';
7
8
 
8
9
  export function createHookCommand(deps) {
9
10
  const {
@@ -89,13 +90,17 @@ async function cmdHook(ctx, args) {
89
90
  const dshProject = isDsh && !nativeOwner ? resolveProjectDatabase({ root: hccRoot, createStateDir: false }) : null;
90
91
  const nativeProject = nativeOwner ? resolveProjectDatabase({ root: hccRoot,
91
92
  db: process.env.HCC_DB, createStateDir: false }) : null;
93
+ const ordinaryDb = (ctx.root && path.resolve(ctx.root) === hccRoot ? ctx.dbPath : null) ||
94
+ process.env.HCC_DB;
92
95
  if (nativeProject && !fs.existsSync(nativeProject.db)) {
93
96
  throw new CliError('NATIVE_HOOK_OWNERSHIP_MISMATCH', 'Native hook database must already be owned by its runtime');
94
97
  }
98
+ const ordinaryDefaultDb = !dshProject && !nativeProject && !ordinaryDb
99
+ ? projectDbPath(hccRoot) : null;
95
100
  const hookCtx = {
96
101
  ...ctx,
97
102
  root: nativeProject?.root || dshProject?.root || hccRoot,
98
- dbPath: nativeProject?.db || dshProject?.db || path.join(hccRoot, '.hello-cc', 'mesh.db')
103
+ dbPath: nativeProject?.db || dshProject?.db || ordinaryDefaultDb || path.resolve(ordinaryDb)
99
104
  };
100
105
  if (!nativeOwner) registerProjectActivity(hookCtx);
101
106
 
@@ -1,6 +1,10 @@
1
1
  // Install/setup/update/uninstall/shim commands extracted from bin/hcc.mjs.
2
2
 
3
3
  import { execFileSync, spawnSync } from 'node:child_process';
4
+ import { secureExistingProjectStateDirectory } from '../../runtime/project-path.mjs';
5
+ import { legacyProjectStateDir, projectStateDir } from '../../runtime/paths.mjs';
6
+ import { privateProjectStateDir, inspectPrivateProjectAuthority,
7
+ purgePrivateProjectState } from '../../runtime/private-state.mjs';
4
8
 
5
9
  export function createInstallCommands(deps) {
6
10
  const {
@@ -236,14 +240,33 @@ async function cmdUninstall(ctx, args) {
236
240
  if (opts.purge && !opts.yes) {
237
241
  throw new CliError('CONFIRM_REQUIRED', 'Refusing to purge project data without --yes');
238
242
  }
243
+ // Resolve before changing hooks, guidance, or runtime state. An unsafe root
244
+ // with unmigrated legacy data must not be treated as an empty private store.
245
+ let purgeStateDir = null;
246
+ let missingPrivateState = false;
247
+ if (opts.purge) {
248
+ try {
249
+ purgeStateDir = secureExistingProjectStateDirectory(ctx.root).stateDir;
250
+ } catch (error) {
251
+ if (projectStateDir(ctx.root) !== privateProjectStateDir(ctx.root)) throw error;
252
+ const recoverablePending = error?.code === 'STATE_MIGRATION_REQUIRED' &&
253
+ inspectPrivateProjectAuthority(ctx.root) === 'pending';
254
+ if (!['STATE_AUTHORITY_MISSING', 'STATE_PURGE_INCOMPLETE'].includes(error?.code) &&
255
+ !recoverablePending) throw error;
256
+ purgeStateDir = privateProjectStateDir(ctx.root);
257
+ missingPrivateState = true;
258
+ }
259
+ }
239
260
 
240
261
  const lines = [];
241
262
 
242
263
  let runtime = null;
243
- try {
244
- runtime = readRuntime(ctx);
245
- } catch (err) {
246
- if (!(err instanceof CliError && err.code === 'RUNTIME_NOT_RUNNING')) throw err;
264
+ if (!missingPrivateState) {
265
+ try {
266
+ runtime = readRuntime(ctx);
267
+ } catch (err) {
268
+ if (!(err instanceof CliError && err.code === 'RUNTIME_NOT_RUNNING')) throw err;
269
+ }
247
270
  }
248
271
  if (runtime) {
249
272
  try {
@@ -273,9 +296,28 @@ async function cmdUninstall(ctx, args) {
273
296
  if (opts.purge) {
274
297
  guidance = removeGuidanceBlocks(ctx);
275
298
  try {
276
- fs.rmSync(path.join(ctx.root, '.hello-cc'), { recursive: true, force: true });
299
+ if (path.resolve(purgeStateDir) === path.resolve(privateProjectStateDir(ctx.root))) {
300
+ // Revalidate and delete under the same transition lock used by init
301
+ // and migration. An interrupted deletion remains marked purging.
302
+ purgeStateDir = purgePrivateProjectState(ctx.root);
303
+ } else {
304
+ // Async shutdown and hook removal happened after the initial lookup.
305
+ // Refuse a stale legacy target if project-state routing changed.
306
+ const current = secureExistingProjectStateDirectory(ctx.root).stateDir;
307
+ if (path.resolve(current) !== path.resolve(purgeStateDir)) {
308
+ throw new CliError('PROJECT_PATH_FORBIDDEN', 'Project state route changed during uninstall');
309
+ }
310
+ fs.rmSync(purgeStateDir, { recursive: true, force: true });
311
+ }
277
312
  purged = true;
278
- lines.push(`project data removed: ${path.join(ctx.root, '.hello-cc')}`);
313
+ lines.push(`project data removed: ${purgeStateDir}`);
314
+ const legacyDir = legacyProjectStateDir(ctx.root);
315
+ if (path.resolve(legacyDir) !== path.resolve(purgeStateDir) && fs.existsSync(legacyDir)) {
316
+ lines.push(`legacy project data kept: ${legacyDir}`);
317
+ lines.push('private authority kept; future use requires an explicit offline migration of the legacy source');
318
+ } else if (path.resolve(legacyDir) !== path.resolve(purgeStateDir)) {
319
+ lines.push('private authority kept; a fresh init can now create new private state');
320
+ }
279
321
  } catch (err) {
280
322
  throw new CliError('PURGE_FAILED', `Could not remove .hello-cc: ${err.message}`);
281
323
  }
@@ -4,10 +4,21 @@ export function createLifecycleCommands(deps) {
4
4
  const {
5
5
  connect, now, addEvent, printResult, CliError, parseOpts, intOpt,
6
6
  registerProjectActivity, resolveCurrentPeer, resolveTargetPeer,
7
- upsertPeer, upsertCanonicalPeerBinding, detectBranch,
7
+ upsertPeer, upsertCanonicalPeerBinding, detectBranch, readAncestorCliInfo,
8
8
  writeGuidance, helpEnv, helpJoin, shellExports, path, process
9
9
  } = deps;
10
10
 
11
+ function registrationPid(opts) {
12
+ if (opts.pid !== undefined) return intOpt(opts, 'pid', process.ppid);
13
+ const ancestor = readAncestorCliInfo();
14
+ if (['claude', 'codex'].includes(ancestor?.kind) &&
15
+ (!opts.kind || opts.kind === ancestor.kind) &&
16
+ Number.isSafeInteger(ancestor.pid) && ancestor.pid > 1) {
17
+ return ancestor.pid;
18
+ }
19
+ return process.ppid;
20
+ }
21
+
11
22
  async function cmdInit(ctx, args) {
12
23
  registerProjectActivity(ctx);
13
24
  const opts = parseOpts(args, { booleans: ['no-guidance'] });
@@ -34,7 +45,7 @@ export function createLifecycleCommands(deps) {
34
45
  role: opts.role || '',
35
46
  worktree: path.resolve(opts.worktree || ctx.cwd),
36
47
  branch: opts.branch || detectBranch(ctx.cwd),
37
- pid: intOpt(opts, 'pid', process.ppid),
48
+ pid: registrationPid(opts),
38
49
  status: opts.status || 'idle',
39
50
  capabilities: Array.isArray(opts.cap) ? opts.cap.join(',') : (opts.cap || opts.capabilities || '')
40
51
  };
@@ -67,7 +78,7 @@ export function createLifecycleCommands(deps) {
67
78
  role: opts.role || 'peer',
68
79
  worktree: path.resolve(opts.worktree || ctx.cwd),
69
80
  branch: opts.branch || detectBranch(ctx.cwd),
70
- pid: intOpt(opts, 'pid', process.ppid),
81
+ pid: registrationPid(opts),
71
82
  status: opts.status || 'working',
72
83
  capabilities: Array.isArray(opts.cap) ? opts.cap.join(',') : (opts.cap || opts.capabilities || 'manual-shell')
73
84
  };
@@ -7,6 +7,7 @@ import { parseOpts, validateOpts, wantsHelp, intOpt } from '../../cli-args.mjs';
7
7
  import { printResult } from '../../format.mjs';
8
8
  import { commandPath } from '../../cli-runtime.mjs';
9
9
  import { nativePaths, readNativePointer } from '../../runtime/native/store.mjs';
10
+ import { openPrivateAppendFile } from '../../runtime/private-file.mjs';
10
11
  import { nativeRequest } from '../../runtime/native/client.mjs';
11
12
  import { startNativeService } from '../../runtime/native/service.mjs';
12
13
 
@@ -37,15 +38,21 @@ export function createNativeCommands(deps) {
37
38
  }
38
39
  }
39
40
  const paths = nativePaths(ctx, { create: true });
40
- const log = fs.openSync(paths.log, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW || 0), 0o600);
41
- if (!fs.fstatSync(log).isFile() || fs.fstatSync(log).nlink !== 1) {
42
- fs.closeSync(log);
43
- throw new CliError('NATIVE_STATE_UNSAFE', 'Native runtime log must be a private regular file');
41
+ let log;
42
+ let child;
43
+ try {
44
+ log = openPrivateAppendFile(paths.log);
45
+ child = spawn(process.execPath, [commandPath(), '--root', fs.realpathSync(ctx.root), '--db', path.resolve(ctx.dbPath), 'native', 'serve'], {
46
+ cwd: ctx.root, env: process.env, detached: true, stdio: ['ignore', log, log]
47
+ });
48
+ } catch (error) {
49
+ if (error?.code === 'PROJECT_PATH_FORBIDDEN') {
50
+ throw new CliError('NATIVE_STATE_UNSAFE', error.message);
51
+ }
52
+ throw error;
53
+ } finally {
54
+ if (log !== undefined) fs.closeSync(log);
44
55
  }
45
- const child = spawn(process.execPath, [commandPath(), '--root', fs.realpathSync(ctx.root), '--db', path.resolve(ctx.dbPath), 'native', 'serve'], {
46
- cwd: ctx.root, env: process.env, detached: true, stdio: ['ignore', log, log]
47
- });
48
- fs.closeSync(log);
49
56
  let failed;
50
57
  child.on('error', (error) => { failed = error; });
51
58
  child.unref();
@@ -113,7 +120,7 @@ export function createNativeCommands(deps) {
113
120
  const data = fs.readFileSync(path.resolve(ctx.cwd, opts['response-file']), 'utf8');
114
121
  if (data.length > 100000) throw new CliError('BAD_ARGS', 'Response file exceeds the limit');
115
122
  try { response = JSON.parse(data); } catch { throw new CliError('BAD_ARGS', 'Response file must contain JSON'); }
116
- if (!response || typeof response !== 'object' || Array.isArray(response) || Object.keys(response).some(key => !['permissions','scope','answers'].includes(key))) throw new CliError('BAD_ARGS', 'Response file accepts permissions, scope and answers only');
123
+ if (!response || typeof response !== 'object' || Array.isArray(response) || Object.keys(response).some(key => !['permissions','scope','answers','content'].includes(key))) throw new CliError('BAD_ARGS', 'Response file accepts permissions, scope, answers and MCP content only');
117
124
  }
118
125
  const request = pending[0];
119
126
  result = await nativeRequest(ctx, 'POST', '/respond', { peer: opts.peer, generation: state.generation,
@@ -1,5 +1,7 @@
1
1
  // Coordination query commands extracted from bin/hcc.mjs: peers, status, state, prompt.
2
2
 
3
+ import { shellQuoteArg } from '../../format.mjs';
4
+
3
5
  export function createQueryCommands(deps) {
4
6
  const {
5
7
  connect, now, printResult, table, CliError, ACTIVE_PEER_TTL,
@@ -63,7 +65,10 @@ export function createQueryCommands(deps) {
63
65
  const peer = required(opts, 'peer', 'HCC_PEER');
64
66
  const kind = opts.kind || 'codex';
65
67
  const role = opts.role || 'peer';
66
- const cmd = `node ${commandPath()} --root ${JSON.stringify(ctx.root)}`;
68
+ const cmd = ['node', commandPath(), '--root', ctx.root, '--db', ctx.dbPath].map(shellQuoteArg).join(' ');
69
+ const quotedPeer = shellQuoteArg(peer);
70
+ const quotedKind = shellQuoteArg(kind);
71
+ const quotedRole = shellQuoteArg(role);
67
72
  const text = `You are ${peer}, a ${kind} ${role} session working in this project.
68
73
 
69
74
  Use hcc as the shared coordination bus for this terminal session. This
@@ -71,23 +76,23 @@ project uses a flat peer mesh: there is no required main/worker hierarchy.
71
76
 
72
77
  Run these commands before changing files:
73
78
 
74
- ${cmd} register --peer ${peer} --kind ${kind} --role ${role}
75
- ${cmd} state --peer ${peer}
76
- ${cmd} msg inbox --peer ${peer}
77
- ${cmd} task next --peer ${peer}
79
+ ${cmd} register --peer ${quotedPeer} --kind ${quotedKind} --role ${quotedRole}
80
+ ${cmd} state --peer ${quotedPeer}
81
+ ${cmd} msg inbox --peer ${quotedPeer}
82
+ ${cmd} task next --peer ${quotedPeer}
78
83
 
79
84
  Coordination rules:
80
85
  - Claim exactly one task before editing.
81
86
  - If state shows a current task for ${peer}, continue that task before claiming another pending task.
82
87
  - Before editing a file, directory, module, or shared test resource, run:
83
- ${cmd} lock acquire --peer ${peer} --resource <path-or-module> [--scope <scope>] --task <task-id>
88
+ ${cmd} lock acquire --peer ${quotedPeer} --resource <path-or-module> [--scope <scope>] --task <task-id>
84
89
  - If a lock is held by another peer, message that peer instead of editing:
85
- ${cmd} msg send --from ${peer} --to <peer-id> --body "<question>"
90
+ ${cmd} msg send --from ${quotedPeer} --to <peer-id> --body "<question>"
86
91
  - Report progress or requests through msg send.
87
92
  - Before stopping, run tests, create a handoff, and release locks:
88
- ${cmd} handoff create --from ${peer} --task <task-id> --summary "<what changed>" --tests "<commands/results>" --risks "<known risks>"
89
- ${cmd} task done --peer ${peer} --id <task-id> --summary "<done summary>"
90
- ${cmd} lock release --peer ${peer} --resource <path-or-module> [--scope <scope>]
93
+ ${cmd} handoff create --from ${quotedPeer} --task <task-id> --summary "<what changed>" --tests "<commands/results>" --risks "<known risks>"
94
+ ${cmd} task done --peer ${quotedPeer} --id <task-id> --summary "<done summary>"
95
+ ${cmd} lock release --peer ${quotedPeer} --resource <path-or-module> [--scope <scope>]
91
96
  `;
92
97
  printResult(ctx, { prompt: text }, () => text);
93
98
  }
@@ -11,6 +11,7 @@ import { CliError } from '../../shared/errors.mjs';
11
11
  import { tx } from '../../db/schema.mjs';
12
12
  import { parseOpts, validateOpts } from '../../cli-args.mjs';
13
13
  import { runtimeProcessIdentity } from '../../runtime/state.mjs';
14
+ import { ensurePrivateProjectBufferDirectory } from '../../runtime/project-path.mjs';
14
15
  import { withBufferDirectoryLease } from '../../runtime/buffer-directory-lease.mjs';
15
16
  import {
16
17
  externalBufferEvidence,
@@ -178,8 +179,7 @@ async function cmdRunWebManaged(ctx, { id, kind, role, cwd, command, commandArgs
178
179
  const ptyModule = await import('node-pty');
179
180
  const pty = ptyModule.default || ptyModule;
180
181
 
181
- const bufsDir = path.join(ctx.root, '.hello-cc', BUFS_DIR_NAME);
182
- fs.mkdirSync(bufsDir, { recursive: true });
182
+ const bufsDir = ensurePrivateProjectBufferDirectory(ctx.root, BUFS_DIR_NAME);
183
183
  const outFile = path.join(bufsDir, `${id}.out`);
184
184
  const inFile = path.join(bufsDir, `${id}.in`);
185
185
  const resizeFile = path.join(bufsDir, `${id}.resize`);
@@ -219,8 +219,8 @@ async function cmdRunWebManaged(ctx, { id, kind, role, cwd, command, commandArgs
219
219
  }
220
220
  for (const file of bufferFiles) fs.rmSync(file, { force: true });
221
221
  }
222
- fs.writeFileSync(inFile, '');
223
- fs.writeFileSync(resizeFile, '');
222
+ fs.writeFileSync(inFile, '', { flag: 'wx', mode: 0o600 });
223
+ fs.writeFileSync(resizeFile, '', { flag: 'wx', mode: 0o600 });
224
224
  fs.writeFileSync(metaFile, JSON.stringify({
225
225
  id,
226
226
  generation: bufferGeneration,
@@ -231,7 +231,7 @@ async function cmdRunWebManaged(ctx, { id, kind, role, cwd, command, commandArgs
231
231
  wrapper_pid: process.pid,
232
232
  ...(publishingWrapperIdentity ? { wrapper_identity: publishingWrapperIdentity } : {}),
233
233
  publishing: true
234
- }));
234
+ }), { flag: 'wx', mode: 0o600 });
235
235
 
236
236
  const db = connect(ctx);
237
237
  try {
@@ -359,7 +359,8 @@ async function cmdRunWebManaged(ctx, { id, kind, role, cwd, command, commandArgs
359
359
  if (!ownsExternalBufferGroup()) {
360
360
  throw new CliError('EXTERNAL_SESSION_SUPERSEDED', `External session ${id} was replaced during startup`);
361
361
  }
362
- outFd = fs.openSync(outFile, 'w');
362
+ outFd = fs.openSync(outFile, fs.constants.O_WRONLY | fs.constants.O_CREAT |
363
+ fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600);
363
364
  });
364
365
  } catch (error) {
365
366
  await failStartup(error?.code || 'external_buffer_open_failed');
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { tx } from '../../db/schema.mjs';
3
+ import { automaticResultKey } from '../../shared/automatic-result-key.mjs';
3
4
  import { CliError } from '../../shared/errors.mjs';
4
5
 
5
6
  export const RESULT_EVENT = 'task.result.recorded';
@@ -18,7 +19,7 @@ function text(value, field, max, required = false) {
18
19
 
19
20
  // Evidence records do not mutate task status. The owner is checked in the same
20
21
  // transaction as the insert, including after a concurrent task takeover.
21
- export function recordTaskResult(db, input, { now = () => Math.floor(Date.now() / 1000), addEvent } = {}) {
22
+ export function recordTaskResult(db, input, { now = () => Math.floor(Date.now() / 1000), addEvent, automatic = false } = {}) {
22
23
  if (!input || typeof input !== 'object' || Array.isArray(input)) throw new CliError('BAD_REQUEST', 'A result object is required');
23
24
  const peer = text(input.peer, 'peer', 200, true);
24
25
  const taskId = input.taskId;
@@ -29,21 +30,35 @@ export function recordTaskResult(db, input, { now = () => Math.floor(Date.now()
29
30
  throw new CliError('BAD_REQUEST', 'Unsupported result stage, status, kind or source');
30
31
  }
31
32
  if (source === 'executor' && stage !== 'local') throw new CliError('BAD_REQUEST', 'Executor records can only report local evidence');
33
+ if (automatic && (source !== 'executor' || !['command', 'diff'].includes(kind))) {
34
+ throw new CliError('BAD_REQUEST', 'Automatic results must be local executor commands or diffs');
35
+ }
32
36
  const evidence = input.evidence || [];
33
37
  if (!Array.isArray(evidence) || evidence.length > 20) throw new CliError('BAD_REQUEST', 'evidence must contain up to 20 text references');
34
38
  const references = evidence.map((entry) => text(entry, 'evidence', 2000, true));
35
39
  if (source === 'user' && status === 'passed' && !references.length) {
36
40
  throw new CliError('BAD_REQUEST', 'A passed verification needs an evidence reference');
37
41
  }
42
+ const executorId = text(input.executorId, 'executorId', 512) || null;
43
+ const threadId = text(input.threadId, 'threadId', 512) || null;
44
+ const turnId = text(input.turnId, 'turnId', 512) || null;
45
+ const itemId = text(input.itemId, 'itemId', 512) || null;
46
+ if (automatic && (!executorId || !threadId || !turnId || (kind === 'command' && !itemId))) {
47
+ throw new CliError('BAD_REQUEST', 'Automatic results require executor, thread, turn and command item identities');
48
+ }
49
+ const resultKey = automatic
50
+ ? automaticResultKey({ executorId, threadId, turnId, kind, itemId: kind === 'command' ? itemId : null })
51
+ : input.resultKey;
38
52
  const record = {
39
- result_id: randomUUID(), result_key: text(input.resultKey, 'resultKey', 700) || randomUUID(),
53
+ result_id: randomUUID(), result_key: text(resultKey, 'resultKey', 700) || randomUUID(),
40
54
  peer, task_id: taskId, stage, status, kind, source,
41
- title: text(input.title, 'title', 300, true), details: text(input.details, 'details', 64000), evidence: references,
42
- command: text(input.command, 'command', 64000), diff: text(input.diff, 'diff', 64000),
43
- executor_id: text(input.executorId, 'executorId', 512) || null,
44
- thread_id: text(input.threadId, 'threadId', 512) || null,
45
- turn_id: text(input.turnId, 'turnId', 512) || null,
46
- item_id: text(input.itemId, 'itemId', 512) || null,
55
+ title: automatic ? (kind === 'command' ? 'Command execution' : 'Turn changes awaiting review')
56
+ : text(input.title, 'title', 300, true),
57
+ details: automatic ? '' : text(input.details, 'details', 64000),
58
+ evidence: automatic ? [] : references,
59
+ command: automatic ? '' : text(input.command, 'command', 64000),
60
+ diff: automatic ? '' : text(input.diff, 'diff', 64000),
61
+ executor_id: executorId, thread_id: threadId, turn_id: turnId, item_id: itemId,
47
62
  exit_code: Number.isSafeInteger(input.exitCode) ? input.exitCode : null,
48
63
  created_at: now()
49
64
  };
@@ -55,8 +70,10 @@ export function recordTaskResult(db, input, { now = () => Math.floor(Date.now()
55
70
  AND task_id = ? AND actor = ? AND json_valid(payload) AND json_extract(payload, '$.result_key') = ?
56
71
  ORDER BY id DESC LIMIT 1`).get(RESULT_EVENT, taskId, peer, record.result_key);
57
72
  if (previous) return { ...JSON.parse(previous.payload), id: previous.id, created_at: previous.created_at };
58
- record.task_title = task.title;
59
- record.task_status = task.status;
73
+ if (!automatic) {
74
+ record.task_title = task.title;
75
+ record.task_status = task.status;
76
+ }
60
77
  if (addEvent) addEvent(db, RESULT_EVENT, peer, taskId, record);
61
78
  else db.prepare('INSERT INTO events(type,actor,task_id,payload,created_at) VALUES(?,?,?,?,?)')
62
79
  .run(RESULT_EVENT, peer, taskId, JSON.stringify(record), record.created_at);