@logicseek/hello-cc 1.0.0 → 1.0.2-dsh.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +149 -0
- package/README.md +36 -0
- package/README.zh-CN.md +35 -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/guide.md +58 -0
- package/docs/guide.zh-CN.md +55 -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 +27 -6
- package/lib/cli/commands/coordination.mjs +3 -1
- package/lib/cli/commands/doctor.mjs +17 -2
- package/lib/cli/commands/dsh.mjs +79 -0
- package/lib/cli/commands/gc.mjs +182 -32
- 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/misc.mjs +15 -0
- package/lib/cli/commands/native.mjs +130 -0
- package/lib/cli/commands/peer.mjs +21 -5
- 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 +33 -1
- package/lib/release/release-notes.mjs +9 -0
- package/lib/runtime/buffer-directory-lease.mjs +2 -1
- package/lib/runtime/client.mjs +3 -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/shared/file-lock.mjs +11 -4
- package/lib/shared/socket-lock-worker.mjs +73 -24
- package/lib/terminal/tmux.mjs +53 -23
- 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/buffer-gc-runtime.mjs +2 -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 +8 -2
- package/lib/web/pty-sessions.mjs +60 -33
- package/lib/web/runtime-main.mjs +101 -6
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/startup.mjs +4 -2
- 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 +12 -4
- 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 +338 -187
- 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/docs/architecture-split-review.md +0 -217
- package/docs/current-state-review-2026-08.md +0 -341
- package/docs/defect-review-2026-07.md +0 -382
- package/docs/layout-split-stack-audit.md +0 -298
- package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +0 -294
- package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +0 -400
- package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +0 -284
- package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +0 -303
- package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +0 -71
- package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +0 -117
- package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +0 -120
- package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +0 -117
- package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +0 -104
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
// Lock CLI commands extracted from bin/hcc.mjs.
|
|
2
2
|
// Factory pattern with injected deps.
|
|
3
3
|
|
|
4
|
+
import { readTx } from '../../db/schema.mjs';
|
|
5
|
+
import {
|
|
6
|
+
findRequestedLock,
|
|
7
|
+
upsertRequestedLock,
|
|
8
|
+
visibleLocksWithEvidence
|
|
9
|
+
} from '../../core/coordination/locks.mjs';
|
|
10
|
+
|
|
4
11
|
export function createLockCommands(deps) {
|
|
5
12
|
const {
|
|
6
13
|
connect,
|
|
@@ -109,21 +116,11 @@ export function createLockCommands(deps) {
|
|
|
109
116
|
}
|
|
110
117
|
const existing = subject.locks.find((row) => row.resource === requested.resource) || null;
|
|
111
118
|
const expiresAt = leaseDeadline(t, ttl);
|
|
112
|
-
db
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
ON CONFLICT(resource) DO UPDATE SET
|
|
116
|
-
base_resource = excluded.base_resource,
|
|
117
|
-
scope = excluded.scope,
|
|
118
|
-
owner = excluded.owner,
|
|
119
|
-
task_id = excluded.task_id,
|
|
120
|
-
reason = excluded.reason,
|
|
121
|
-
expires_at = excluded.expires_at,
|
|
122
|
-
created_at = excluded.created_at,
|
|
123
|
-
ttl_sec = excluded.ttl_sec
|
|
124
|
-
`).run(requested.resource, requested.base_resource, requested.scope, peer, taskId, reason, expiresAt, t, ttl);
|
|
119
|
+
const acquired = upsertRequestedLock(db, requested, {
|
|
120
|
+
owner: peer, task_id: taskId, reason, expires_at: expiresAt, created_at: t, ttl_sec: ttl
|
|
121
|
+
});
|
|
125
122
|
addEvent(db, 'lock.acquired', peer, taskId, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, ttl, previous_owner: existing ? existing.owner : null });
|
|
126
|
-
return { lock:
|
|
123
|
+
return { lock: acquired };
|
|
127
124
|
}
|
|
128
125
|
});
|
|
129
126
|
if (outcome.error) throw outcome.error;
|
|
@@ -143,7 +140,7 @@ export function createLockCommands(deps) {
|
|
|
143
140
|
const db = connect(ctx);
|
|
144
141
|
touchCurrentPeer(db, ctx, identity, null, 'shell');
|
|
145
142
|
const result = tx(db, () => {
|
|
146
|
-
const existing = db
|
|
143
|
+
const existing = findRequestedLock(db, requested);
|
|
147
144
|
if (!existing) return { released: false, ...requested };
|
|
148
145
|
if (existing.owner !== peer && !opts.force) {
|
|
149
146
|
throw new CliError('LOCK_OWNED', `Lock is owned by ${existing.owner}`, { owner: existing.owner });
|
|
@@ -166,7 +163,7 @@ export function createLockCommands(deps) {
|
|
|
166
163
|
const db = connect(ctx);
|
|
167
164
|
touchCurrentPeer(db, ctx, identity, 'working', 'shell');
|
|
168
165
|
const lock = tx(db, () => {
|
|
169
|
-
const existing = db
|
|
166
|
+
const existing = findRequestedLock(db, requested);
|
|
170
167
|
if (!existing) throw new CliError('NOT_FOUND', `No lock for ${lockLabel(requested)}`);
|
|
171
168
|
if (existing.owner !== peer) throw new CliError('LOCK_OWNED', `Lock is owned by ${existing.owner}`, { owner: existing.owner });
|
|
172
169
|
db.prepare('UPDATE locks SET expires_at = ?, ttl_sec = ? WHERE resource = ?').run(expiresAt, ttl, requested.resource);
|
|
@@ -180,9 +177,27 @@ export function createLockCommands(deps) {
|
|
|
180
177
|
const opts = parseOpts(args, { booleans: ['all'] });
|
|
181
178
|
const db = connect(ctx);
|
|
182
179
|
const t = now();
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
180
|
+
const { locks, owners, graceUntil } = readTx(db, () => {
|
|
181
|
+
const locks = db.prepare('SELECT * FROM locks ORDER BY resource ASC').all();
|
|
182
|
+
const graceUntil = readClockGraceUntil(db);
|
|
183
|
+
const expired = opts.all || clockGraceSuppressed(t, graceUntil)
|
|
184
|
+
? [] : locks.filter((lock) => Number(lock.expires_at) <= t);
|
|
185
|
+
const owners = [...new Set(expired
|
|
186
|
+
.map((lock) => lock.owner))].map((owner) => ({
|
|
187
|
+
owner,
|
|
188
|
+
peer: db.prepare('SELECT * FROM peers WHERE id = ?').get(owner) || null,
|
|
189
|
+
binding: db.prepare('SELECT * FROM peer_bindings WHERE peer = ?').get(owner) || null
|
|
190
|
+
}));
|
|
191
|
+
return { locks, owners, graceUntil };
|
|
192
|
+
});
|
|
193
|
+
const evidenceByOwner = new Map(owners.map(({ owner, peer, binding }) => [
|
|
194
|
+
owner,
|
|
195
|
+
peer ? observePeerEvidence(ctx, peer, binding) : { state: 'unknown', reason: 'peer_missing' }
|
|
196
|
+
]));
|
|
197
|
+
const rows = opts.all ? locks : visibleLocksWithEvidence(locks, t, {
|
|
198
|
+
graceActive: clockGraceSuppressed(t, graceUntil),
|
|
199
|
+
evidenceByOwner
|
|
200
|
+
});
|
|
186
201
|
printResult(ctx, rows, (data) => table(data, [
|
|
187
202
|
{ label: 'resource', value: (r) => lockBaseResource(r) },
|
|
188
203
|
{ label: 'scope', value: (r) => lockScope(r) },
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { CliError } from '../../shared/errors.mjs';
|
|
2
|
+
import { parseOpts, required, validateOpts, wantsHelp } from '../../cli-args.mjs';
|
|
3
|
+
import { loadScopedMcpBootstrap } from '../../mcp/scope.mjs';
|
|
4
|
+
import { createScopedMcpTools } from '../../mcp/tools.mjs';
|
|
5
|
+
import { serveMcpStdio } from '../../mcp/stdio.mjs';
|
|
6
|
+
|
|
7
|
+
export function mcpHelp() {
|
|
8
|
+
console.log(`Usage: hcc --root PROJECT --db DATABASE mcp serve --peer PEER
|
|
9
|
+
|
|
10
|
+
Serve project/peer-scoped coordination tools over MCP stdio. The executor must
|
|
11
|
+
supply a private, temporary capability; --peer alone does not grant ownership.
|
|
12
|
+
No global Codex configuration is changed. Task claiming and handoff never mark
|
|
13
|
+
tasks done. Errors from tools are returned as MCP tool results.`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createMcpCommands(dependencies) {
|
|
17
|
+
async function cmdMcp(ctx, args) {
|
|
18
|
+
if (!args[0] || wantsHelp(args)) return mcpHelp();
|
|
19
|
+
if (args[0] !== 'serve') throw new CliError('BAD_ARGS', 'Unknown MCP command');
|
|
20
|
+
const options = parseOpts(args.slice(1));
|
|
21
|
+
validateOpts('mcp serve', options, ['peer']);
|
|
22
|
+
const peer = required(options, 'peer');
|
|
23
|
+
const authority = loadScopedMcpBootstrap(ctx, peer);
|
|
24
|
+
const tools = createScopedMcpTools({ ...dependencies, ctx, authority });
|
|
25
|
+
await serveMcpStdio({ tools });
|
|
26
|
+
}
|
|
27
|
+
return { cmdMcp };
|
|
28
|
+
}
|
|
@@ -5,6 +5,7 @@ export function createMiscCommands(deps) {
|
|
|
5
5
|
path, process, CliError, parseOpts, printResult,
|
|
6
6
|
readRuntime, runtimeRequest, runtimePath, globalRuntimePath,
|
|
7
7
|
reclaimRuntimePointerFiles, helpDown, loadSetup,
|
|
8
|
+
waitForProcessIdentityExit,
|
|
8
9
|
PRODUCT_NAME
|
|
9
10
|
} = deps;
|
|
10
11
|
|
|
@@ -35,6 +36,20 @@ export function createMiscCommands(deps) {
|
|
|
35
36
|
printResult(ctx, { runtime: localSource }, () => `${PRODUCT_NAME} stale runtime pointer removed`);
|
|
36
37
|
return;
|
|
37
38
|
}
|
|
39
|
+
if (runtime.source !== 'env') {
|
|
40
|
+
const processIdentity = runtime.process_identity || runtime.processIdentity || runtime.pid;
|
|
41
|
+
const stopped = await waitForProcessIdentityExit(processIdentity, {
|
|
42
|
+
timeoutMs: 5_000,
|
|
43
|
+
intervalMs: 25
|
|
44
|
+
});
|
|
45
|
+
if (stopped.state !== 'dead') {
|
|
46
|
+
throw new CliError(
|
|
47
|
+
'RUNTIME_STOP_TIMEOUT',
|
|
48
|
+
`Runtime accepted the stop request, but process ${runtime.pid || 'unknown'} did not exit within 5s.`,
|
|
49
|
+
{ pid: runtime.pid || null, state: stopped.state }
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
38
53
|
printResult(ctx, { runtime: runtime.source || runtime.base_url }, () => `${PRODUCT_NAME} runtime stopped`);
|
|
39
54
|
}
|
|
40
55
|
|