@logicseek/hello-cc 1.0.1 → 1.0.2

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 (97) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +4 -0
  3. package/README.zh-CN.md +4 -0
  4. package/docs/README.md +9 -0
  5. package/docs/README.zh-CN.md +9 -0
  6. package/docs/commands.md +53 -0
  7. package/docs/commands.zh-CN.md +48 -0
  8. package/docs/dsh.md +137 -0
  9. package/docs/dsh.zh-CN.md +139 -0
  10. package/docs/mcp.md +9 -0
  11. package/docs/mcp.zh-CN.md +23 -0
  12. package/docs/native.md +295 -0
  13. package/docs/native.zh-CN.md +253 -0
  14. package/docs/web-handoff.zh-CN.md +53 -0
  15. package/lib/cli/app.mjs +25 -6
  16. package/lib/cli/commands/doctor.mjs +17 -2
  17. package/lib/cli/commands/dsh.mjs +79 -0
  18. package/lib/cli/commands/hook.mjs +241 -90
  19. package/lib/cli/commands/lock.mjs +34 -19
  20. package/lib/cli/commands/mcp.mjs +28 -0
  21. package/lib/cli/commands/native.mjs +130 -0
  22. package/lib/cli/commands/run.mjs +66 -21
  23. package/lib/cli/commands/task.mjs +6 -3
  24. package/lib/cli-args.mjs +4 -0
  25. package/lib/coordination-state.mjs +15 -1
  26. package/lib/core/coordination/event-retention.mjs +14 -2
  27. package/lib/core/coordination/gc-plan.mjs +3 -13
  28. package/lib/core/coordination/locks.mjs +45 -0
  29. package/lib/core/coordination/results.mjs +79 -0
  30. package/lib/core/peers/bindings.mjs +9 -3
  31. package/lib/diagnostics/codex.mjs +121 -0
  32. package/lib/integrations/codex-interactions.mjs +114 -0
  33. package/lib/integrations/dsh-collaboration.mjs +170 -0
  34. package/lib/integrations/dsh-cordis.d.ts +19 -0
  35. package/lib/integrations/dsh-cordis.mjs +177 -0
  36. package/lib/integrations/dsh.bundle.yml +7 -0
  37. package/lib/integrations/dsh.mjs +287 -0
  38. package/lib/integrations/native/claude.mjs +451 -0
  39. package/lib/integrations/native/codex.mjs +242 -0
  40. package/lib/integrations/native/dsh-acp.mjs +343 -0
  41. package/lib/integrations/native/index.mjs +19 -0
  42. package/lib/integrations/native/interactions.mjs +53 -0
  43. package/lib/integrations/native/jsonrpc.mjs +185 -0
  44. package/lib/integrations/peers/identity.mjs +36 -10
  45. package/lib/integrations/peers/processes.mjs +22 -8
  46. package/lib/integrations/providers.mjs +6 -2
  47. package/lib/mcp/scope.mjs +104 -0
  48. package/lib/mcp/stdio.mjs +80 -0
  49. package/lib/mcp/tools.mjs +197 -0
  50. package/lib/process/identity.mjs +3 -1
  51. package/lib/release/release-notes.mjs +9 -0
  52. package/lib/runtime/native/client.mjs +38 -0
  53. package/lib/runtime/native/service.mjs +511 -0
  54. package/lib/runtime/native/store.mjs +121 -0
  55. package/lib/ui/help.mjs +5 -2
  56. package/lib/web/adoption-state.mjs +55 -0
  57. package/lib/web/auto-attach.mjs +18 -2
  58. package/lib/web/codex-app-server.mjs +557 -0
  59. package/lib/web/codex-sessions.mjs +287 -0
  60. package/lib/web/control-lease.mjs +204 -0
  61. package/lib/web/http-routes.mjs +197 -15
  62. package/lib/web/http.mjs +5 -4
  63. package/lib/web/native-sessions.mjs +286 -0
  64. package/lib/web/project-contexts.mjs +2 -1
  65. package/lib/web/pty-sessions.mjs +60 -33
  66. package/lib/web/runtime-main.mjs +98 -6
  67. package/lib/web/session-serialize.mjs +0 -0
  68. package/lib/web/task-results.mjs +76 -0
  69. package/lib/web/tmux-sessions.mjs +92 -11
  70. package/lib/web/tmux-stream.mjs +2 -2
  71. package/lib/web/ui-codex.mjs +550 -0
  72. package/lib/web/ui-command-palette.mjs +119 -0
  73. package/lib/web/ui-handoff.mjs +102 -0
  74. package/lib/web/ui-history.mjs +118 -0
  75. package/lib/web/ui-i18n.mjs +841 -0
  76. package/lib/web/ui-interactions.mjs +78 -0
  77. package/lib/web/ui-login.mjs +101 -0
  78. package/lib/web/ui-native.mjs +165 -0
  79. package/lib/web/ui-preferences.mjs +278 -0
  80. package/lib/web/ui-review.mjs +132 -0
  81. package/lib/web/ui-template.mjs +1106 -576
  82. package/lib/web/ui-terminal-find.mjs +98 -0
  83. package/lib/web/ui-workbench.mjs +94 -0
  84. package/lib/web/ui-workspace.mjs +222 -0
  85. package/package.json +11 -3
  86. package/scripts/check-free-identifiers.mjs +67 -8
  87. package/scripts/codex-diagnostics-installed-acceptance.mjs +72 -0
  88. package/scripts/dsh-cordis-acceptance.mjs +200 -0
  89. package/scripts/dsh-installed-acceptance.mjs +247 -0
  90. package/scripts/dsh-official-acceptance.mjs +180 -0
  91. package/scripts/dsh-web-startup-acceptance.mjs +87 -0
  92. package/scripts/github-release.mjs +3 -1
  93. package/scripts/native-live-acceptance.mjs +369 -0
  94. package/scripts/regression.mjs +243 -89
  95. package/scripts/web-handoff-installed-acceptance.mjs +98 -0
  96. package/scripts/web-native-interaction-acceptance.mjs +204 -0
  97. package/scripts/web-native-model-acceptance.mjs +138 -0
package/lib/cli/app.mjs CHANGED
@@ -33,6 +33,9 @@ import { createTeamCommands } from '../cli/commands/team.mjs';
33
33
  import { createMiscCommands } from '../cli/commands/misc.mjs';
34
34
  import { createInstallCommands } from '../cli/commands/install.mjs';
35
35
  import { createHookCommand } from '../cli/commands/hook.mjs';
36
+ import { createDshCommands } from '../cli/commands/dsh.mjs';
37
+ import { createNativeCommands } from '../cli/commands/native.mjs';
38
+ import { createMcpCommands } from '../cli/commands/mcp.mjs';
36
39
  import { createPeerCommands } from '../cli/commands/peer.mjs';
37
40
  import { createRunCommands } from '../cli/commands/run.mjs';
38
41
  import { createTmuxCommands } from '../cli/commands/tmux.mjs';
@@ -291,6 +294,11 @@ const { cmdPeers, cmdStatus, cmdState, cmdPrompt } = createQueryCommands({
291
294
  clockGraceSuppressed, readClockGraceUntil, commandPath
292
295
  });
293
296
 
297
+ const { cmdNative } = createNativeCommands({ connect, upsertPeer, upsertCanonicalPeerBinding,
298
+ sendMessage, ackMessage, queryInbox, addEvent, liveProcessIdentity, detectBranch,
299
+ resolveCurrentPeer });
300
+ const { cmdMcp } = createMcpCommands({ connect, touchPeer, now, addEvent });
301
+
294
302
  const { cmdInit, cmdRegister, cmdEnv, cmdJoin } = createLifecycleCommands({
295
303
  connect, now, tx, addEvent, printResult, CliError, parseOpts, intOpt,
296
304
  registerProjectActivity, resolveCurrentPeer, resolveTargetPeer,
@@ -341,6 +349,8 @@ const { cmdHook } = createHookCommand({
341
349
  path, fs, process, CliError
342
350
  });
343
351
 
352
+ const { cmdDsh } = createDshCommands();
353
+
344
354
  const { cmdPeer } = createPeerCommands({
345
355
  connect, now, tx, addEvent, auditPayload, printResult, CliError, parseOpts,
346
356
  detectBranch, buildPeerCommand, childSessionEnv, inferPeerKind,
@@ -471,8 +481,8 @@ const { cmdWeb } = createWebRuntime({
471
481
  sameResolvedPath, sendWebHtml, shellCommand, webErrorStatus
472
482
  });
473
483
 
474
- function renderWebIndex(nonce) {
475
- return webUiTemplate.webIndexHtml({ nonce });
484
+ function renderWebIndex(nonceOrOptions) {
485
+ return webUiTemplate.webIndexHtml(typeof nonceOrOptions === 'string' ? { nonce: nonceOrOptions } : nonceOrOptions);
476
486
  }
477
487
 
478
488
  function renderWebLogin(nonce) {
@@ -484,18 +494,24 @@ function renderWebLogin(nonce) {
484
494
  </body></html>`;
485
495
  }
486
496
 
487
- function sendWebHtml(res, render) {
497
+ function sendWebHtml(res, render, { pane = false } = {}) {
488
498
  const nonce = randomBytes(18).toString('base64url');
489
- sendHttp(res, 200, 'text/html; charset=utf-8', render(nonce), { nonce });
499
+ const options = { nonce, pane: pane === true };
500
+ sendHttp(res, 200, 'text/html; charset=utf-8', render(options.pane ? options : nonce), options);
490
501
  }
491
502
 
492
503
  function webErrorStatus(err) {
493
504
  if (!(err instanceof CliError)) return 500;
494
505
  if (err.code === 'REGISTRY_BUSY') return 503;
506
+ if (err.code === 'CODEX_START_FAILED') return 503;
507
+ if (err.code.startsWith('CODEX_') || ['STALE_CONTROL_EPOCH', 'UNKNOWN_CONTROL_CLIENT'].includes(err.code)) return 409;
495
508
  if (['BAD_ARGS', 'BAD_REQUEST', 'PEER_IDENTITY_REQUIRED', 'REQUEST_TOO_LARGE'].includes(err.code)) return 400;
496
- if (['PEER_IDENTITY_MISMATCH', 'TASK_OWNED', 'LOCK_OWNED', 'PROJECT_NOT_REGISTERED', 'PROJECT_PATH_FORBIDDEN'].includes(err.code)) return 403;
509
+ if (['PEER_IDENTITY_MISMATCH', 'TASK_OWNED', 'TASK_OWNER_MISMATCH', 'LOCK_OWNED', 'PROJECT_NOT_REGISTERED', 'PROJECT_PATH_FORBIDDEN', 'RUNTIME_ADMIN_REQUIRED'].includes(err.code)) return 403;
510
+ if (err.code.startsWith('NATIVE_')) return err.code === 'NATIVE_RUNTIME_OFFLINE' ? 503 : 409;
497
511
  if (['NOT_FOUND'].includes(err.code)) return 404;
498
- if (['LOCK_HELD', 'SESSION_NOT_RUNNING'].includes(err.code)) return 409;
512
+ if (['LOCK_HELD', 'SESSION_NOT_RUNNING', 'CONTROL_REQUIRED', 'CONTROL_STALE',
513
+ 'CONTROL_HELD', 'CONTROL_DISCONNECTED', 'HANDOFF_REQUIRED', 'THREAD_IN_USE',
514
+ 'SUBMISSION_EXISTS', 'STRUCTURED_SESSION_NO_TERMINAL', 'CODEX_UNCERTAIN_SUBMISSION', 'CODEX_TURN_ACTIVE'].includes(err.code)) return 409;
499
515
  return 500;
500
516
  }
501
517
 
@@ -598,6 +614,9 @@ async function dispatch(ctx, rest) {
598
614
  if (command === 'event') return cmdEvent(ctx, args);
599
615
  if (command === 'web') return cmdWeb(ctx, args);
600
616
  if (command === 'hook') return cmdHook(ctx, args);
617
+ if (command === 'dsh') return cmdDsh(ctx, args);
618
+ if (command === 'native') return cmdNative(ctx, args);
619
+ if (command === 'mcp') return cmdMcp(ctx, args);
601
620
  if (command === 'install-hooks') return cmdInstallHooks(ctx, args);
602
621
  if (command === 'shim') return cmdShim(ctx, args);
603
622
  if (command === 'setup') return cmdSetup(ctx, args);
@@ -2,15 +2,20 @@
2
2
 
3
3
  import fs from 'node:fs';
4
4
  import { printResult } from '../../format.mjs';
5
+ import { diagnoseCodex, unknownCodexDiagnostics } from '../../diagnostics/codex.mjs';
5
6
 
6
- export function createDoctorCommand({ connectReadOnly, readSchemaVersion, DB_SCHEMA_VERSION, CLI_NAME }) {
7
+ export function createDoctorCommand({ connectReadOnly, readSchemaVersion, DB_SCHEMA_VERSION, CLI_NAME,
8
+ codexDiagnostics = diagnoseCodex }) {
7
9
  async function cmdDoctor(ctx, args) {
8
10
  if (args[0] === '--help' || args[0] === '-h') {
9
- console.log(`${CLI_NAME} doctor [--json]
11
+ console.log(`${CLI_NAME} doctor [--codex] [--json]
10
12
 
11
13
  Runs a read-only health check on the project database: PRAGMA integrity_check,
12
14
  schema compatibility, persistent journal mode, DB and WAL file sizes, and
13
15
  per-table row counts. Exits non-zero for corruption or an unsupported schema.
16
+ With --codex, also reads bounded version/help output, hooks configuration and
17
+ project hook invocation evidence. Configuration is not trust or a delivery
18
+ receipt. No model, session, hook, account or trust change is performed.
14
19
  `);
15
20
  return;
16
21
  }
@@ -52,6 +57,11 @@ per-table row counts. Exits non-zero for corruption or an unsupported schema.
52
57
  },
53
58
  row_counts: counts
54
59
  };
60
+ const delimiter = args.indexOf('--');
61
+ if ((delimiter < 0 ? args : args.slice(0, delimiter)).includes('--codex')) {
62
+ try { report.codex = codexDiagnostics(ctx, db); }
63
+ catch { report.codex = unknownCodexDiagnostics(); }
64
+ }
55
65
  } finally {
56
66
  db.close();
57
67
  }
@@ -72,6 +82,11 @@ per-table row counts. Exits non-zero for corruption or an unsupported schema.
72
82
  ` db size: ${r.db_size_bytes ?? '?'} bytes${r.wal_size_bytes ? ` (wal ${r.wal_size_bytes})` : ''}`,
73
83
  ` rows: ` + Object.entries(r.row_counts).map(([k, v]) => `${k}=${v}`).join(' ')
74
84
  ];
85
+ if (r.codex) {
86
+ lines.push(` codex version: ${r.codex.version.value ?? 'unknown'} (${r.codex.version.status})`,
87
+ ` app-server: ${r.codex.app_server.status} startup: ${r.codex.app_server.startup_arguments?.join(' ') || 'unknown'} handshake: unknown`,
88
+ ` codex hooks: configured=${r.codex.hooks.configuration.status} invocation=${r.codex.hooks.invocation.status} stdout receipt=unknown provider acceptance=unknown trust=unknown`);
89
+ }
75
90
  return lines.join('\n');
76
91
  });
77
92
  if (!healthy) process.exitCode = 1;
@@ -0,0 +1,79 @@
1
+ import process from 'node:process';
2
+ import { CliError } from '../../shared/errors.mjs';
3
+ import { parseOpts, validateOpts, wantsHelp } from '../../cli-args.mjs';
4
+ import { commandPath } from '../../cli-runtime.mjs';
5
+ import { printResult } from '../../format.mjs';
6
+ import {
7
+ DSH_INSTALL_HINT, ensureDshIntegration, inspectDshIntegration,
8
+ resolveDshBinary, launchDshWeb
9
+ } from '../../integrations/dsh.mjs';
10
+
11
+ function defaultHelp() {
12
+ console.log([
13
+ 'Usage: hcc dsh setup [--mode hooks|cordis|off]',
14
+ ' hcc dsh status [--dsh-bin PATH]',
15
+ ' hcc dsh web [--mode hooks|cordis|off] [--dsh-bin PATH] [--dsh-home PATH] -- [dsh web arguments]',
16
+ '',
17
+ 'Setup writes project-local configuration under .hello-cc/dsh/. Modes: hooks (default), cordis (native tools), off.',
18
+ 'Web starts the official dsh web runtime; its real sessions join hello-cc through the selected integration.',
19
+ 'Pass runtime arguments after --, for example: hcc dsh web -- --port 8080',
20
+ 'Show dsh web help: hcc dsh web -- --help',
21
+ `Install the tested dsh version: ${DSH_INSTALL_HINT}`
22
+ ].join('\n'));
23
+ }
24
+
25
+ export function createDshCommands(deps = {}) {
26
+ const getCommandPath = deps.commandPath || commandPath;
27
+ const help = deps.helpDsh || defaultHelp;
28
+
29
+ async function cmdDsh(ctx, args) {
30
+ const helpBoundary = args.indexOf('--');
31
+ const wrapperArgs = helpBoundary < 0 ? args : args.slice(0, helpBoundary);
32
+ if (!args.length || wantsHelp(wrapperArgs)) return help();
33
+ const sub = args[0];
34
+ if (!['setup', 'status', 'web'].includes(sub)) throw new CliError('BAD_ARGS', `Unknown dsh subcommand: ${sub}`);
35
+ const tail = args.slice(1);
36
+ const boundary = tail.indexOf('--');
37
+ const forwarded = boundary < 0 ? [] : tail.slice(boundary + 1);
38
+ const opts = parseOpts(boundary < 0 ? tail : tail.slice(0, boundary));
39
+ const allowed = sub === 'setup' ? ['mode'] : sub === 'status' ? ['dsh-bin'] : ['mode', 'dsh-bin', 'dsh-home'];
40
+ validateOpts(`dsh ${sub}`, opts, allowed);
41
+ if (sub !== 'web' && boundary >= 0) throw new CliError('BAD_ARGS', `dsh ${sub}: unexpected -- argument separator`);
42
+ if (opts.mode !== undefined && !['hooks', 'cordis', 'off'].includes(opts.mode)) throw new CliError('BAD_ARGS', '--mode must be hooks, cordis, or off');
43
+ for (const name of allowed.filter(name => name !== 'mode')) {
44
+ if (opts[name] !== undefined && !opts[name]) throw new CliError('BAD_ARGS', `--${name} must be a non-empty path`);
45
+ }
46
+ const config = { hccBin: getCommandPath(), ...(opts.mode !== undefined ? { mode: opts.mode } : {}) };
47
+ if (sub === 'setup') {
48
+ const result = ensureDshIntegration(ctx, config);
49
+ const label = result.changed.length ? 'configured' : 'already current';
50
+ printResult(ctx, result, (data) => [
51
+ `dsh integration ${label}: ${data.root}`,
52
+ `hooks: ${data.hooksPath}`,
53
+ `patch: ${data.patchPath}`,
54
+ `mode: ${data.mode}`,
55
+ 'start: hcc dsh web'
56
+ ].join('\n'));
57
+ return;
58
+ }
59
+ if (sub === 'status') {
60
+ const result = inspectDshIntegration(ctx, config);
61
+ const binary = resolveDshBinary({ dshBin: opts['dsh-bin'], cwd: ctx.cwd });
62
+ const data = { ...result, binary, installed: Boolean(binary), installHint: binary ? null : DSH_INSTALL_HINT };
63
+ const binaryLabel = binary || `missing (install: ${DSH_INSTALL_HINT})`;
64
+ printResult(ctx, data, () => [
65
+ `dsh integration: ${result.state}`,
66
+ `mode: ${result.mode || 'unknown'}`,
67
+ `binary: ${binaryLabel}`,
68
+ result.error?.message,
69
+ !result.ready && result.state !== 'conflict' ? 'configure: hcc dsh setup' : null
70
+ ].filter(Boolean).join('\n'));
71
+ return;
72
+ }
73
+ const result = await launchDshWeb(ctx, { ...config, dshBin: opts['dsh-bin'], dshHome: opts['dsh-home'], args: forwarded });
74
+ if (result.signal) process.kill(process.pid, result.signal);
75
+ else process.exitCode = result.code ?? 1;
76
+ }
77
+
78
+ return { cmdDsh };
79
+ }
@@ -1,5 +1,9 @@
1
1
  // Hook command extracted from bin/hcc.mjs.
2
- // This is the provider hook entry point called by Claude/Codex hooks.
2
+ // This is the provider hook entry point called by Claude/Codex/dsh hooks.
3
+
4
+ import { parseOpts, validateOpts } from '../../cli-args.mjs';
5
+ import { tx } from '../../db/schema.mjs';
6
+ import { resolveProjectDatabase } from '../../runtime/project-path.mjs';
3
7
 
4
8
  export function createHookCommand(deps) {
5
9
  const {
@@ -18,46 +22,86 @@ export function createHookCommand(deps) {
18
22
  } = deps;
19
23
 
20
24
  async function cmdHook(ctx, args) {
21
- let hookType = args[0] || 'unknown';
25
+ const opts = parseOpts(args);
26
+ let hookType = opts._.shift() || 'unknown';
27
+ validateOpts('hook', opts, ['provider']);
28
+ if (opts.provider !== undefined && !['claude', 'codex', 'dsh'].includes(opts.provider)) {
29
+ throw new CliError('BAD_ARGS', 'hook: --provider must be claude, codex, or dsh');
30
+ }
31
+ const kind = opts.provider || autoPeerKind('other');
32
+ const isDsh = kind === 'dsh';
33
+ const nativeOwner = process.env.HCC_NATIVE_OWNER || null;
34
+ if (nativeOwner && (!process.env.HCC_PEER || !process.env.HCC_ROOT || !process.env.HCC_DB)) {
35
+ throw new CliError('NATIVE_HOOK_OWNERSHIP_MISMATCH', 'Native hook needs an explicit worker peer, root, and database');
36
+ }
22
37
 
23
38
  // Read stdin with a short timeout (hooks must complete quickly)
24
39
  const raw = await new Promise((resolve) => {
25
40
  let buf = '';
26
- const timer = setTimeout(() => resolve(buf), 2000);
41
+ const finish = () => {
42
+ clearTimeout(timer);
43
+ process.stdin.removeListener('data', onData);
44
+ process.stdin.removeListener('end', finish);
45
+ process.stdin.removeListener('error', finish);
46
+ process.stdin.pause();
47
+ resolve(buf);
48
+ };
49
+ const onData = (c) => { buf += c; };
50
+ const timer = setTimeout(finish, 2000);
27
51
  process.stdin.setEncoding('utf8');
28
- process.stdin.on('data', (c) => { buf += c; });
29
- process.stdin.on('end', () => { clearTimeout(timer); resolve(buf); });
30
- process.stdin.on('error', () => { clearTimeout(timer); resolve(buf); });
52
+ process.stdin.on('data', onData);
53
+ process.stdin.on('end', finish);
54
+ process.stdin.on('error', finish);
31
55
  });
32
56
 
33
57
  let payload = {};
34
58
  try { payload = JSON.parse(raw); } catch {}
59
+ if (isDsh && (!payload || typeof payload !== 'object' || Array.isArray(payload))) {
60
+ throw new CliError('BAD_HOOK_PAYLOAD', 'dsh hook requires a JSON object with session_id and cwd');
61
+ }
62
+ // Keep ordinary hooks tolerant of malformed/empty input as before.
63
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) payload = {};
35
64
  hookType = payload.hook_event_name || payload.hookEventName || hookType;
65
+ if (isDsh && typeof hookType !== 'string') {
66
+ throw new CliError('BAD_HOOK_PAYLOAD', 'dsh hook requires a valid hook event');
67
+ }
36
68
  const hookEventName = formatHookEventName(hookType);
37
69
  const hookKey = hookEventName.replace(/[^a-z]/gi, '').toLowerCase();
70
+ if (isDsh && !['sessionstart', 'userpromptsubmit', 'pretooluse', 'posttooluse', 'stop'].includes(hookKey)) {
71
+ throw new CliError('BAD_HOOK_PAYLOAD', 'Unsupported dsh hook event');
72
+ }
38
73
 
39
- // Extract session identity (Claude Code sets these in its hook JSON)
40
- const kind = autoPeerKind('other');
41
- const providerSession = autoPeerProviderSession(kind);
42
- const sessionId = payload.session_id || payload.sessionId || payload.conversation_id || payload.conversationId ||
43
- providerSession.sessionId || '';
44
- const hookCwd = payload.cwd || payload.workingDirectory || process.cwd();
74
+ // A dsh service can host many sessions and workspaces. Only its per-hook
75
+ // payload identifies the conversation; service environment belongs to none.
76
+ const providerSession = isDsh ? {} : autoPeerProviderSession(kind);
77
+ const sessionId = isDsh
78
+ ? payload.session_id
79
+ : payload.session_id || payload.sessionId || payload.conversation_id || payload.conversationId || providerSession.sessionId || '';
80
+ const hookCwd = isDsh ? payload.cwd : payload.cwd || payload.workingDirectory || process.cwd();
81
+ if (isDsh) validateDshHookIdentity(sessionId, hookCwd);
45
82
 
46
83
  // Hooks auto-join the exact current project path, creating its mesh.db on
47
- // first use. Cross-path sharing is explicit via HCC_ROOT/HCC_DB.
48
- const hccRoot = process.env.HCC_ROOT
84
+ // first use. Ordinary hooks allow explicit cross-path sharing via HCC_ROOT.
85
+ const hccRoot = (nativeOwner || !isDsh) && process.env.HCC_ROOT
49
86
  ? path.resolve(process.env.HCC_ROOT)
50
87
  : path.resolve(hookCwd);
51
88
 
89
+ const dshProject = isDsh && !nativeOwner ? resolveProjectDatabase({ root: hccRoot, createStateDir: false }) : null;
90
+ const nativeProject = nativeOwner ? resolveProjectDatabase({ root: hccRoot,
91
+ db: process.env.HCC_DB, createStateDir: false }) : null;
92
+ if (nativeProject && !fs.existsSync(nativeProject.db)) {
93
+ throw new CliError('NATIVE_HOOK_OWNERSHIP_MISMATCH', 'Native hook database must already be owned by its runtime');
94
+ }
52
95
  const hookCtx = {
53
96
  ...ctx,
54
- root: hccRoot,
55
- dbPath: path.join(hccRoot, '.hello-cc', 'mesh.db')
97
+ root: nativeProject?.root || dshProject?.root || hccRoot,
98
+ dbPath: nativeProject?.db || dshProject?.db || path.join(hccRoot, '.hello-cc', 'mesh.db')
56
99
  };
57
- registerProjectActivity(hookCtx);
100
+ if (!nativeOwner) registerProjectActivity(hookCtx);
58
101
 
59
- // Derive peer ID: HCC_PEER > resume ID from parent cmdline > session ID > terminal
60
- let peerId = process.env.HCC_PEER;
102
+ // dsh uses only its true session ID. Ordinary hooks retain environment,
103
+ // resume, session, and terminal identity precedence.
104
+ let peerId = isDsh && !nativeOwner ? providerSessionPeerId(kind, sessionId) : process.env.HCC_PEER;
61
105
  let resumeId = null;
62
106
  if (!peerId) {
63
107
  resumeId = providerSession.resumeId || readParentResumeId(kind);
@@ -71,67 +115,99 @@ async function cmdHook(ctx, args) {
71
115
  : `${kind}-${shortHash(`${hookCtx.root}:${autoPeerBasis(kind)}`)}`;
72
116
  }
73
117
 
74
- const db = connect(hookCtx);
118
+ // Hook delivery must not migrate another workspace's registered database.
119
+ const db = isDsh || nativeOwner ? connect(hookCtx, { migrateRegistered: false }) : connect(hookCtx);
75
120
  try {
121
+ if (nativeOwner) {
122
+ // Native delivery owns context injection and completion receipts. Hooks
123
+ // only renew its verified owner, never turn a native binding into a
124
+ // terminal binding or consume the same inbox through a second path.
125
+ tx(db, () => {
126
+ const binding = db.prepare(`
127
+ SELECT b.*, p.kind AS peer_kind FROM peer_bindings b
128
+ JOIN peers p ON p.id = b.peer WHERE b.peer = ?
129
+ `).get(peerId);
130
+ if (!binding || binding.transport !== 'native' || binding.runtime_target !== nativeOwner ||
131
+ binding.peer_kind !== binding.provider || (opts.provider && binding.provider !== kind)) {
132
+ throw new CliError('NATIVE_HOOK_OWNERSHIP_MISMATCH', 'Native hook does not match its runtime-owned peer binding');
133
+ }
134
+ const observedAt = now();
135
+ db.prepare('UPDATE peers SET last_seen_at = ? WHERE id = ?').run(observedAt, peerId);
136
+ const renewed = renewOwnedLocks(db, { owner: peerId, nowSec: observedAt,
137
+ ttlCap: 3600, includeExpired: true });
138
+ if (renewed > 0) addEvent(db, 'lock.renewed_by_hook', peerId, null, { renewed, source: 'native-hook' });
139
+ });
140
+ process.exitCode = 0;
141
+ return;
142
+ }
76
143
  const status = hookKey === 'stop' ? 'idle' : 'working';
77
- const providerAncestor = readAncestorCliInfo();
144
+ const providerAncestor = readAncestorCliInfo(isDsh ? 'dsh' : undefined);
78
145
  const providerPid = providerAncestor?.kind === kind ? Number(providerAncestor.pid) : null;
79
146
  const providerIdentity = providerPid ? liveProcessIdentity(providerPid) : null;
80
- const existing = db.prepare('SELECT id FROM peers WHERE id = ?').get(peerId);
81
- if (!existing) {
82
- upsertPeer(db, {
83
- id: peerId, kind, role: 'peer',
84
- worktree: hookCwd,
85
- branch: detectBranch(hookCwd),
86
- pid: providerIdentity?.pid || null,
87
- processIdentity: providerIdentity,
88
- status,
89
- capabilities: `hook-${hookKey}`
90
- });
91
- } else {
92
- refreshHookOwnerIdentity(db, {
93
- peerId,
94
- status,
95
- observedAt: now(),
96
- processIdentity: providerIdentity
97
- });
98
- }
99
- const hookBinding = {
100
- peer: peerId,
101
- provider: kind,
102
- ...providerSessionParts(resumeId || sessionId),
103
- resume_mode: resumeId ? 'resume' : (sessionId ? 'detected' : 'unknown'),
104
- resume_arg: resumeId || null,
105
- command: null,
106
- transport: 'hook',
107
- runtime_session_id: peerId
108
- };
109
- const canonical = upsertCanonicalPeerBinding(db, hookBinding, true);
110
- if (canonical.peer !== peerId) {
111
- const previousPeer = peerId;
112
- peerId = canonical.peer;
113
- refreshHookOwnerIdentity(db, {
114
- peerId,
115
- status,
116
- observedAt: now(),
117
- processIdentity: providerIdentity
118
- });
119
- addEvent(db, 'provider.session.merged', peerId, null, auditPayload({
147
+ const registerHookIdentity = () => {
148
+ if (isDsh) assertDshHookBinding(db, peerId, sessionId);
149
+ const existing = db.prepare('SELECT id FROM peers WHERE id = ?').get(peerId);
150
+ if (!existing) {
151
+ upsertPeer(db, {
152
+ id: peerId, kind, role: 'peer',
153
+ worktree: hookCwd,
154
+ branch: detectBranch(hookCwd),
155
+ pid: providerIdentity?.pid || null,
156
+ processIdentity: providerIdentity,
157
+ status,
158
+ capabilities: `hook-${hookKey}`
159
+ });
160
+ } else {
161
+ refreshHookOwnerIdentity(db, {
162
+ peerId,
163
+ status,
164
+ observedAt: now(),
165
+ processIdentity: providerIdentity
166
+ });
167
+ }
168
+ const hookBinding = {
169
+ peer: peerId,
170
+ provider: kind,
171
+ ...(isDsh
172
+ ? { provider_session_id: sessionId, provider_session_name: null }
173
+ : providerSessionParts(resumeId || sessionId)),
174
+ resume_mode: resumeId ? 'resume' : (sessionId ? 'detected' : 'unknown'),
175
+ resume_arg: resumeId || null,
176
+ command: null,
177
+ transport: 'hook',
178
+ runtime_session_id: peerId
179
+ };
180
+ const canonical = upsertCanonicalPeerBinding(db, hookBinding, true);
181
+ if (canonical.peer !== peerId) {
182
+ const previousPeer = peerId;
183
+ peerId = canonical.peer;
184
+ refreshHookOwnerIdentity(db, {
185
+ peerId,
186
+ status,
187
+ observedAt: now(),
188
+ processIdentity: providerIdentity
189
+ });
190
+ addEvent(db, 'provider.session.merged', peerId, null, auditPayload({
191
+ actor: peerId,
192
+ target: peerId,
193
+ source: 'hook',
194
+ from_peer: previousPeer,
195
+ provider: kind,
196
+ session_id: sessionId || resumeId || null
197
+ }));
198
+ }
199
+ addEvent(db, `hook.${hookKey}`, peerId, null, auditPayload({
120
200
  actor: peerId,
121
201
  target: peerId,
122
202
  source: 'hook',
123
- from_peer: previousPeer,
124
- provider: kind,
125
- session_id: sessionId || resumeId || null
203
+ session_id: sessionId,
204
+ cwd: hookCwd
126
205
  }));
127
- }
128
- addEvent(db, `hook.${hookKey}`, peerId, null, auditPayload({
129
- actor: peerId,
130
- target: peerId,
131
- source: 'hook',
132
- session_id: sessionId,
133
- cwd: hookCwd
134
- }));
206
+ };
207
+ // Keep the dsh conflict check and identity/binding writes atomic, so a
208
+ // concurrent service/session registration cannot change the inspected row.
209
+ if (isDsh) tx(db, registerHookIdentity);
210
+ else registerHookIdentity();
135
211
  // hb-06: an active hook is proof the peer is working. During clock grace,
136
212
  // renew retained locks even when the wall-clock jump made them look expired.
137
213
  const hookNow = now();
@@ -152,46 +228,118 @@ async function cmdHook(ctx, args) {
152
228
  if (hookLockRenewals > 0) {
153
229
  addEvent(db, 'lock.renewed_by_hook', peerId, null, { renewed: hookLockRenewals });
154
230
  }
155
- try {
156
- reconcileRunningPeerBindings(db, hookCtx, {
157
- inspectProcess: inspectProviderProcess,
158
- latestProviderSessionForPeer: (peer) => latestHookProviderSession(db, peer),
159
- addEvent,
160
- now
161
- });
162
- } catch {}
231
+ if (!isDsh) {
232
+ try {
233
+ reconcileRunningPeerBindings(db, hookCtx, {
234
+ inspectProcess: inspectProviderProcess,
235
+ latestProviderSessionForPeer: (peer) => latestHookProviderSession(db, peer),
236
+ addEvent,
237
+ now
238
+ });
239
+ } catch {}
240
+ }
163
241
 
164
242
  if (['sessionstart', 'userpromptsubmit'].includes(hookKey)) {
165
243
  const snapshot = buildHookCoordinationContext(db, hookCtx, peerId);
166
- ackMessages(db, peerId, snapshot.messages);
167
- process.stdout.write(JSON.stringify({
244
+ await writeHookOutput({
168
245
  hookSpecificOutput: {
169
246
  hookEventName,
170
247
  additionalContext: snapshot.text
171
248
  }
172
- }) + '\n');
173
- } else if (['posttooluse', 'stop'].includes(hookKey)) {
249
+ });
250
+ ackMessages(db, peerId, snapshot.messages);
251
+ } else if (hookKey === 'posttooluse') {
174
252
  const snapshot = buildHookCoordinationContext(db, hookCtx, peerId);
175
253
  if (snapshot.messages.length > 0) {
176
- ackMessages(db, peerId, snapshot.messages);
177
- process.stdout.write(JSON.stringify({
254
+ await writeHookOutput({
178
255
  hookSpecificOutput: {
179
256
  hookEventName,
180
257
  additionalContext: snapshot.text
181
258
  }
182
- }) + '\n');
259
+ });
260
+ ackMessages(db, peerId, snapshot.messages);
261
+ }
262
+ } else if (hookKey === 'stop' && payload.stop_hook_active !== true) {
263
+ // A blocking Stop hook causes another model turn. Do not block that
264
+ // continuation again; newly arrived messages stay unread for the next
265
+ // SessionStart, UserPromptSubmit, PostToolUse, or independent Stop.
266
+ const snapshot = buildHookCoordinationContext(db, hookCtx, peerId);
267
+ if (snapshot.messages.length > 0) {
268
+ await writeHookOutput({
269
+ decision: 'block',
270
+ reason: snapshot.text
271
+ });
272
+ ackMessages(db, peerId, snapshot.messages);
183
273
  }
184
274
  }
185
275
  } finally {
186
276
  try { db.close(); } catch {}
187
277
  }
188
- process.exit(0);
278
+ // Let Node exit after the hook output has flushed instead of truncating a
279
+ // pipe with process.exit(). A successful write is not provider acceptance.
280
+ process.exitCode = 0;
189
281
  }
190
282
 
191
- // ─── hcc install-hooks ───────────────────────────────────────────────────────
283
+ function writeHookOutput(output) {
284
+ return new Promise((resolve, reject) => {
285
+ const stdout = process.stdout;
286
+ const onError = (error) => reject(error);
287
+ stdout.once('error', onError);
288
+ try {
289
+ stdout.write(JSON.stringify(output) + '\n', (error) => {
290
+ if (error) {
291
+ // Writable emits its error after invoking the callback. Keep the
292
+ // once listener until that event so a failed pipe is handled.
293
+ reject(error);
294
+ } else {
295
+ stdout.removeListener('error', onError);
296
+ resolve();
297
+ }
298
+ });
299
+ } catch (error) {
300
+ stdout.removeListener('error', onError);
301
+ reject(error);
302
+ }
303
+ });
304
+ }
192
305
 
306
+ function validateDshHookIdentity(sessionId, cwd) {
307
+ if (typeof sessionId !== 'string' || !sessionId || sessionId.length > 1024 ||
308
+ sessionId.trim() !== sessionId || /[\u0000-\u001f\u007f]/.test(sessionId)) {
309
+ throw new CliError('BAD_HOOK_PAYLOAD', 'dsh hook requires a nonempty string session_id');
310
+ }
311
+ if (typeof cwd !== 'string' || !cwd || /[\u0000-\u001f\u007f]/.test(cwd) || !path.isAbsolute(cwd)) {
312
+ throw new CliError('BAD_HOOK_PAYLOAD', 'dsh hook requires an absolute cwd directory');
313
+ }
314
+ let isDirectory = false;
315
+ try { isDirectory = fs.statSync(cwd).isDirectory(); } catch {}
316
+ if (!isDirectory) throw new CliError('BAD_HOOK_PAYLOAD', 'dsh hook cwd must be an existing directory');
317
+ }
193
318
 
194
- return { cmdHook };
319
+ function assertDshHookBinding(db, peerId, sessionId) {
320
+ const conflict = (reason, boundPeer = peerId) => {
321
+ throw new CliError('DSH_PEER_BINDING_CONFLICT',
322
+ `dsh hook session ${sessionId} conflicts with existing peer binding ${boundPeer}`, {
323
+ peer: peerId, bound_peer: boundPeer, session_id: sessionId, reason
324
+ });
325
+ };
326
+ const peer = db.prepare('SELECT kind FROM peers WHERE id = ?').get(peerId);
327
+ if (peer && peer.kind !== 'dsh') conflict('peer_provider_mismatch');
328
+ const binding = db.prepare('SELECT * FROM peer_bindings WHERE peer = ?').get(peerId);
329
+ if (binding) {
330
+ if (binding.provider !== 'dsh') conflict('binding_provider_mismatch');
331
+ if (binding.transport !== 'hook' || binding.runtime_target !== null) conflict('runtime_binding');
332
+ if (binding.provider_session_id !== sessionId || binding.provider_session_name !== null) {
333
+ conflict('session_id_mismatch');
334
+ }
335
+ }
336
+ const alias = db.prepare(`
337
+ SELECT peer FROM peer_bindings
338
+ WHERE provider = 'dsh' AND peer <> ?
339
+ AND (provider_session_id = ? OR provider_session_name = ?)
340
+ LIMIT 1
341
+ `).get(peerId, sessionId, sessionId);
342
+ if (alias) conflict('session_bound_to_another_peer', alias.peer);
195
343
  }
196
344
 
197
345
  function readParentResumeId(kind) {
@@ -203,3 +351,6 @@ function readParentResumeId(kind) {
203
351
  } catch {}
204
352
  return null;
205
353
  }
354
+
355
+ return { cmdHook };
356
+ }