@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.
Files changed (123) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/README.md +36 -0
  3. package/README.zh-CN.md +35 -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/guide.md +58 -0
  11. package/docs/guide.zh-CN.md +55 -0
  12. package/docs/mcp.md +9 -0
  13. package/docs/mcp.zh-CN.md +23 -0
  14. package/docs/native.md +295 -0
  15. package/docs/native.zh-CN.md +253 -0
  16. package/docs/web-handoff.zh-CN.md +53 -0
  17. package/lib/cli/app.mjs +27 -6
  18. package/lib/cli/commands/coordination.mjs +3 -1
  19. package/lib/cli/commands/doctor.mjs +17 -2
  20. package/lib/cli/commands/dsh.mjs +79 -0
  21. package/lib/cli/commands/gc.mjs +182 -32
  22. package/lib/cli/commands/hook.mjs +241 -90
  23. package/lib/cli/commands/lock.mjs +34 -19
  24. package/lib/cli/commands/mcp.mjs +28 -0
  25. package/lib/cli/commands/misc.mjs +15 -0
  26. package/lib/cli/commands/native.mjs +130 -0
  27. package/lib/cli/commands/peer.mjs +21 -5
  28. package/lib/cli/commands/run.mjs +66 -21
  29. package/lib/cli/commands/task.mjs +6 -3
  30. package/lib/cli-args.mjs +4 -0
  31. package/lib/coordination-state.mjs +15 -1
  32. package/lib/core/coordination/event-retention.mjs +14 -2
  33. package/lib/core/coordination/gc-plan.mjs +3 -13
  34. package/lib/core/coordination/locks.mjs +45 -0
  35. package/lib/core/coordination/results.mjs +79 -0
  36. package/lib/core/peers/bindings.mjs +9 -3
  37. package/lib/diagnostics/codex.mjs +121 -0
  38. package/lib/integrations/codex-interactions.mjs +114 -0
  39. package/lib/integrations/dsh-collaboration.mjs +170 -0
  40. package/lib/integrations/dsh-cordis.d.ts +19 -0
  41. package/lib/integrations/dsh-cordis.mjs +177 -0
  42. package/lib/integrations/dsh.bundle.yml +7 -0
  43. package/lib/integrations/dsh.mjs +287 -0
  44. package/lib/integrations/native/claude.mjs +451 -0
  45. package/lib/integrations/native/codex.mjs +242 -0
  46. package/lib/integrations/native/dsh-acp.mjs +343 -0
  47. package/lib/integrations/native/index.mjs +19 -0
  48. package/lib/integrations/native/interactions.mjs +53 -0
  49. package/lib/integrations/native/jsonrpc.mjs +185 -0
  50. package/lib/integrations/peers/identity.mjs +36 -10
  51. package/lib/integrations/peers/processes.mjs +22 -8
  52. package/lib/integrations/providers.mjs +6 -2
  53. package/lib/mcp/scope.mjs +104 -0
  54. package/lib/mcp/stdio.mjs +80 -0
  55. package/lib/mcp/tools.mjs +197 -0
  56. package/lib/process/identity.mjs +33 -1
  57. package/lib/release/release-notes.mjs +9 -0
  58. package/lib/runtime/buffer-directory-lease.mjs +2 -1
  59. package/lib/runtime/client.mjs +3 -0
  60. package/lib/runtime/native/client.mjs +38 -0
  61. package/lib/runtime/native/service.mjs +511 -0
  62. package/lib/runtime/native/store.mjs +121 -0
  63. package/lib/shared/file-lock.mjs +11 -4
  64. package/lib/shared/socket-lock-worker.mjs +73 -24
  65. package/lib/terminal/tmux.mjs +53 -23
  66. package/lib/ui/help.mjs +5 -2
  67. package/lib/web/adoption-state.mjs +55 -0
  68. package/lib/web/auto-attach.mjs +18 -2
  69. package/lib/web/buffer-gc-runtime.mjs +2 -2
  70. package/lib/web/codex-app-server.mjs +557 -0
  71. package/lib/web/codex-sessions.mjs +287 -0
  72. package/lib/web/control-lease.mjs +204 -0
  73. package/lib/web/http-routes.mjs +197 -15
  74. package/lib/web/http.mjs +5 -4
  75. package/lib/web/native-sessions.mjs +286 -0
  76. package/lib/web/project-contexts.mjs +8 -2
  77. package/lib/web/pty-sessions.mjs +60 -33
  78. package/lib/web/runtime-main.mjs +101 -6
  79. package/lib/web/session-serialize.mjs +0 -0
  80. package/lib/web/startup.mjs +4 -2
  81. package/lib/web/task-results.mjs +76 -0
  82. package/lib/web/tmux-sessions.mjs +92 -11
  83. package/lib/web/tmux-stream.mjs +2 -2
  84. package/lib/web/ui-codex.mjs +550 -0
  85. package/lib/web/ui-command-palette.mjs +119 -0
  86. package/lib/web/ui-handoff.mjs +102 -0
  87. package/lib/web/ui-history.mjs +118 -0
  88. package/lib/web/ui-i18n.mjs +841 -0
  89. package/lib/web/ui-interactions.mjs +78 -0
  90. package/lib/web/ui-login.mjs +101 -0
  91. package/lib/web/ui-native.mjs +165 -0
  92. package/lib/web/ui-preferences.mjs +278 -0
  93. package/lib/web/ui-review.mjs +132 -0
  94. package/lib/web/ui-template.mjs +1106 -576
  95. package/lib/web/ui-terminal-find.mjs +98 -0
  96. package/lib/web/ui-workbench.mjs +94 -0
  97. package/lib/web/ui-workspace.mjs +222 -0
  98. package/package.json +12 -4
  99. package/scripts/check-free-identifiers.mjs +67 -8
  100. package/scripts/codex-diagnostics-installed-acceptance.mjs +72 -0
  101. package/scripts/dsh-cordis-acceptance.mjs +200 -0
  102. package/scripts/dsh-installed-acceptance.mjs +247 -0
  103. package/scripts/dsh-official-acceptance.mjs +180 -0
  104. package/scripts/dsh-web-startup-acceptance.mjs +87 -0
  105. package/scripts/github-release.mjs +3 -1
  106. package/scripts/native-live-acceptance.mjs +369 -0
  107. package/scripts/regression.mjs +338 -187
  108. package/scripts/web-handoff-installed-acceptance.mjs +98 -0
  109. package/scripts/web-native-interaction-acceptance.mjs +204 -0
  110. package/scripts/web-native-model-acceptance.mjs +138 -0
  111. package/docs/architecture-split-review.md +0 -217
  112. package/docs/current-state-review-2026-08.md +0 -341
  113. package/docs/defect-review-2026-07.md +0 -382
  114. package/docs/layout-split-stack-audit.md +0 -298
  115. package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +0 -294
  116. package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +0 -400
  117. package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +0 -284
  118. package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +0 -303
  119. package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +0 -71
  120. package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +0 -117
  121. package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +0 -120
  122. package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +0 -117
  123. 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
- 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
+ }
@@ -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.prepare(`
113
- INSERT INTO locks(resource, base_resource, scope, owner, task_id, reason, expires_at, created_at, ttl_sec)
114
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
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: db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource) };
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.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
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.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
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 rows = opts.all || clockGraceSuppressed(t, readClockGraceUntil(db))
184
- ? db.prepare('SELECT * FROM locks ORDER BY resource ASC').all()
185
- : db.prepare('SELECT * FROM locks WHERE expires_at > ? ORDER BY resource ASC').all(t);
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