@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.
- package/CHANGELOG.md +105 -0
- package/README.md +4 -0
- package/README.zh-CN.md +4 -0
- package/docs/README.md +9 -0
- package/docs/README.zh-CN.md +9 -0
- package/docs/commands.md +53 -0
- package/docs/commands.zh-CN.md +48 -0
- package/docs/dsh.md +137 -0
- package/docs/dsh.zh-CN.md +139 -0
- package/docs/mcp.md +9 -0
- package/docs/mcp.zh-CN.md +23 -0
- package/docs/native.md +295 -0
- package/docs/native.zh-CN.md +253 -0
- package/docs/web-handoff.zh-CN.md +53 -0
- package/lib/cli/app.mjs +25 -6
- package/lib/cli/commands/doctor.mjs +17 -2
- package/lib/cli/commands/dsh.mjs +79 -0
- package/lib/cli/commands/hook.mjs +241 -90
- package/lib/cli/commands/lock.mjs +34 -19
- package/lib/cli/commands/mcp.mjs +28 -0
- package/lib/cli/commands/native.mjs +130 -0
- package/lib/cli/commands/run.mjs +66 -21
- package/lib/cli/commands/task.mjs +6 -3
- package/lib/cli-args.mjs +4 -0
- package/lib/coordination-state.mjs +15 -1
- package/lib/core/coordination/event-retention.mjs +14 -2
- package/lib/core/coordination/gc-plan.mjs +3 -13
- package/lib/core/coordination/locks.mjs +45 -0
- package/lib/core/coordination/results.mjs +79 -0
- package/lib/core/peers/bindings.mjs +9 -3
- package/lib/diagnostics/codex.mjs +121 -0
- package/lib/integrations/codex-interactions.mjs +114 -0
- package/lib/integrations/dsh-collaboration.mjs +170 -0
- package/lib/integrations/dsh-cordis.d.ts +19 -0
- package/lib/integrations/dsh-cordis.mjs +177 -0
- package/lib/integrations/dsh.bundle.yml +7 -0
- package/lib/integrations/dsh.mjs +287 -0
- package/lib/integrations/native/claude.mjs +451 -0
- package/lib/integrations/native/codex.mjs +242 -0
- package/lib/integrations/native/dsh-acp.mjs +343 -0
- package/lib/integrations/native/index.mjs +19 -0
- package/lib/integrations/native/interactions.mjs +53 -0
- package/lib/integrations/native/jsonrpc.mjs +185 -0
- package/lib/integrations/peers/identity.mjs +36 -10
- package/lib/integrations/peers/processes.mjs +22 -8
- package/lib/integrations/providers.mjs +6 -2
- package/lib/mcp/scope.mjs +104 -0
- package/lib/mcp/stdio.mjs +80 -0
- package/lib/mcp/tools.mjs +197 -0
- package/lib/process/identity.mjs +3 -1
- package/lib/release/release-notes.mjs +9 -0
- package/lib/runtime/native/client.mjs +38 -0
- package/lib/runtime/native/service.mjs +511 -0
- package/lib/runtime/native/store.mjs +121 -0
- package/lib/ui/help.mjs +5 -2
- package/lib/web/adoption-state.mjs +55 -0
- package/lib/web/auto-attach.mjs +18 -2
- package/lib/web/codex-app-server.mjs +557 -0
- package/lib/web/codex-sessions.mjs +287 -0
- package/lib/web/control-lease.mjs +204 -0
- package/lib/web/http-routes.mjs +197 -15
- package/lib/web/http.mjs +5 -4
- package/lib/web/native-sessions.mjs +286 -0
- package/lib/web/project-contexts.mjs +2 -1
- package/lib/web/pty-sessions.mjs +60 -33
- package/lib/web/runtime-main.mjs +98 -6
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/task-results.mjs +76 -0
- package/lib/web/tmux-sessions.mjs +92 -11
- package/lib/web/tmux-stream.mjs +2 -2
- package/lib/web/ui-codex.mjs +550 -0
- package/lib/web/ui-command-palette.mjs +119 -0
- package/lib/web/ui-handoff.mjs +102 -0
- package/lib/web/ui-history.mjs +118 -0
- package/lib/web/ui-i18n.mjs +841 -0
- package/lib/web/ui-interactions.mjs +78 -0
- package/lib/web/ui-login.mjs +101 -0
- package/lib/web/ui-native.mjs +165 -0
- package/lib/web/ui-preferences.mjs +278 -0
- package/lib/web/ui-review.mjs +132 -0
- package/lib/web/ui-template.mjs +1106 -576
- package/lib/web/ui-terminal-find.mjs +98 -0
- package/lib/web/ui-workbench.mjs +94 -0
- package/lib/web/ui-workspace.mjs +222 -0
- package/package.json +11 -3
- package/scripts/check-free-identifiers.mjs +67 -8
- package/scripts/codex-diagnostics-installed-acceptance.mjs +72 -0
- package/scripts/dsh-cordis-acceptance.mjs +200 -0
- package/scripts/dsh-installed-acceptance.mjs +247 -0
- package/scripts/dsh-official-acceptance.mjs +180 -0
- package/scripts/dsh-web-startup-acceptance.mjs +87 -0
- package/scripts/github-release.mjs +3 -1
- package/scripts/native-live-acceptance.mjs +369 -0
- package/scripts/regression.mjs +243 -89
- package/scripts/web-handoff-installed-acceptance.mjs +98 -0
- package/scripts/web-native-interaction-acceptance.mjs +204 -0
- 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(
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
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
|
|
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',
|
|
29
|
-
process.stdin.on('end',
|
|
30
|
-
process.stdin.on('error',
|
|
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
|
-
//
|
|
40
|
-
|
|
41
|
-
const providerSession = autoPeerProviderSession(kind);
|
|
42
|
-
const sessionId
|
|
43
|
-
|
|
44
|
-
|
|
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.
|
|
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
|
-
//
|
|
60
|
-
|
|
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
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
session_id: sessionId || resumeId || null
|
|
203
|
+
session_id: sessionId,
|
|
204
|
+
cwd: hookCwd
|
|
126
205
|
}));
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
167
|
-
process.stdout.write(JSON.stringify({
|
|
244
|
+
await writeHookOutput({
|
|
168
245
|
hookSpecificOutput: {
|
|
169
246
|
hookEventName,
|
|
170
247
|
additionalContext: snapshot.text
|
|
171
248
|
}
|
|
172
|
-
})
|
|
173
|
-
|
|
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
|
-
|
|
177
|
-
process.stdout.write(JSON.stringify({
|
|
254
|
+
await writeHookOutput({
|
|
178
255
|
hookSpecificOutput: {
|
|
179
256
|
hookEventName,
|
|
180
257
|
additionalContext: snapshot.text
|
|
181
258
|
}
|
|
182
|
-
})
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|