@logicseek/hello-cc 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +4 -0
  3. package/README.zh-CN.md +4 -0
  4. package/docs/README.md +9 -0
  5. package/docs/README.zh-CN.md +9 -0
  6. package/docs/commands.md +53 -0
  7. package/docs/commands.zh-CN.md +48 -0
  8. package/docs/dsh.md +137 -0
  9. package/docs/dsh.zh-CN.md +139 -0
  10. package/docs/mcp.md +9 -0
  11. package/docs/mcp.zh-CN.md +23 -0
  12. package/docs/native.md +295 -0
  13. package/docs/native.zh-CN.md +253 -0
  14. package/docs/web-handoff.zh-CN.md +53 -0
  15. package/lib/cli/app.mjs +25 -6
  16. package/lib/cli/commands/doctor.mjs +17 -2
  17. package/lib/cli/commands/dsh.mjs +79 -0
  18. package/lib/cli/commands/hook.mjs +241 -90
  19. package/lib/cli/commands/lock.mjs +34 -19
  20. package/lib/cli/commands/mcp.mjs +28 -0
  21. package/lib/cli/commands/native.mjs +130 -0
  22. package/lib/cli/commands/run.mjs +66 -21
  23. package/lib/cli/commands/task.mjs +6 -3
  24. package/lib/cli-args.mjs +4 -0
  25. package/lib/coordination-state.mjs +15 -1
  26. package/lib/core/coordination/event-retention.mjs +14 -2
  27. package/lib/core/coordination/gc-plan.mjs +3 -13
  28. package/lib/core/coordination/locks.mjs +45 -0
  29. package/lib/core/coordination/results.mjs +79 -0
  30. package/lib/core/peers/bindings.mjs +9 -3
  31. package/lib/diagnostics/codex.mjs +121 -0
  32. package/lib/integrations/codex-interactions.mjs +114 -0
  33. package/lib/integrations/dsh-collaboration.mjs +170 -0
  34. package/lib/integrations/dsh-cordis.d.ts +19 -0
  35. package/lib/integrations/dsh-cordis.mjs +177 -0
  36. package/lib/integrations/dsh.bundle.yml +7 -0
  37. package/lib/integrations/dsh.mjs +287 -0
  38. package/lib/integrations/native/claude.mjs +451 -0
  39. package/lib/integrations/native/codex.mjs +242 -0
  40. package/lib/integrations/native/dsh-acp.mjs +343 -0
  41. package/lib/integrations/native/index.mjs +19 -0
  42. package/lib/integrations/native/interactions.mjs +53 -0
  43. package/lib/integrations/native/jsonrpc.mjs +185 -0
  44. package/lib/integrations/peers/identity.mjs +36 -10
  45. package/lib/integrations/peers/processes.mjs +22 -8
  46. package/lib/integrations/providers.mjs +6 -2
  47. package/lib/mcp/scope.mjs +104 -0
  48. package/lib/mcp/stdio.mjs +80 -0
  49. package/lib/mcp/tools.mjs +197 -0
  50. package/lib/process/identity.mjs +3 -1
  51. package/lib/release/release-notes.mjs +9 -0
  52. package/lib/runtime/native/client.mjs +38 -0
  53. package/lib/runtime/native/service.mjs +511 -0
  54. package/lib/runtime/native/store.mjs +121 -0
  55. package/lib/ui/help.mjs +5 -2
  56. package/lib/web/adoption-state.mjs +55 -0
  57. package/lib/web/auto-attach.mjs +18 -2
  58. package/lib/web/codex-app-server.mjs +557 -0
  59. package/lib/web/codex-sessions.mjs +287 -0
  60. package/lib/web/control-lease.mjs +204 -0
  61. package/lib/web/http-routes.mjs +197 -15
  62. package/lib/web/http.mjs +5 -4
  63. package/lib/web/native-sessions.mjs +286 -0
  64. package/lib/web/project-contexts.mjs +2 -1
  65. package/lib/web/pty-sessions.mjs +60 -33
  66. package/lib/web/runtime-main.mjs +98 -6
  67. package/lib/web/session-serialize.mjs +0 -0
  68. package/lib/web/task-results.mjs +76 -0
  69. package/lib/web/tmux-sessions.mjs +92 -11
  70. package/lib/web/tmux-stream.mjs +2 -2
  71. package/lib/web/ui-codex.mjs +550 -0
  72. package/lib/web/ui-command-palette.mjs +119 -0
  73. package/lib/web/ui-handoff.mjs +102 -0
  74. package/lib/web/ui-history.mjs +118 -0
  75. package/lib/web/ui-i18n.mjs +841 -0
  76. package/lib/web/ui-interactions.mjs +78 -0
  77. package/lib/web/ui-login.mjs +101 -0
  78. package/lib/web/ui-native.mjs +165 -0
  79. package/lib/web/ui-preferences.mjs +278 -0
  80. package/lib/web/ui-review.mjs +132 -0
  81. package/lib/web/ui-template.mjs +1106 -576
  82. package/lib/web/ui-terminal-find.mjs +98 -0
  83. package/lib/web/ui-workbench.mjs +94 -0
  84. package/lib/web/ui-workspace.mjs +222 -0
  85. package/package.json +11 -3
  86. package/scripts/check-free-identifiers.mjs +67 -8
  87. package/scripts/codex-diagnostics-installed-acceptance.mjs +72 -0
  88. package/scripts/dsh-cordis-acceptance.mjs +200 -0
  89. package/scripts/dsh-installed-acceptance.mjs +247 -0
  90. package/scripts/dsh-official-acceptance.mjs +180 -0
  91. package/scripts/dsh-web-startup-acceptance.mjs +87 -0
  92. package/scripts/github-release.mjs +3 -1
  93. package/scripts/native-live-acceptance.mjs +369 -0
  94. package/scripts/regression.mjs +243 -89
  95. package/scripts/web-handoff-installed-acceptance.mjs +98 -0
  96. package/scripts/web-native-interaction-acceptance.mjs +204 -0
  97. package/scripts/web-native-model-acceptance.mjs +138 -0
@@ -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
+ }
@@ -0,0 +1,130 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import { CliError } from '../../shared/errors.mjs';
6
+ import { parseOpts, validateOpts, wantsHelp, intOpt } from '../../cli-args.mjs';
7
+ import { printResult } from '../../format.mjs';
8
+ import { commandPath } from '../../cli-runtime.mjs';
9
+ import { nativePaths, readNativePointer } from '../../runtime/native/store.mjs';
10
+ import { nativeRequest } from '../../runtime/native/client.mjs';
11
+ import { startNativeService } from '../../runtime/native/service.mjs';
12
+
13
+ export function nativeHelp() {
14
+ console.log(`Usage: hcc native up|status|down
15
+ hcc native start --peer NAME --provider codex|claude|dsh [--cwd DIR] [--model MODEL] [--binary PATH] [--resume last]
16
+ hcc native send --peer NAME --body TEXT [--from NAME] [--task ID]
17
+ hcc native events --peer NAME [--after ID]
18
+ hcc native deliveries [--peer NAME]
19
+ hcc native requests --peer NAME
20
+ hcc native respond --peer NAME --request ID --decision accept|decline|cancel [--response-file JSON]
21
+ hcc native interrupt --peer NAME [--turn ID]
22
+ hcc native close --peer NAME
23
+
24
+ Native workers share the project task/message/lock bus. Only HCC-owned saved
25
+ sessions can resume. Existing TUI/Desktop sessions keep their own transport.
26
+ Providers retain their permission checks; interactive requests await an explicit Web or local response.
27
+ Claude workers require the optional @anthropic-ai/claude-agent-sdk package.
28
+ dsh workers use the ACP profile, not its Web-service terminal.`);
29
+ }
30
+
31
+ export function createNativeCommands(deps) {
32
+ async function up(ctx) {
33
+ if (readNativePointer(ctx)) {
34
+ try { return await nativeRequest(ctx, 'GET', '/status', null, { timeoutMs: 2000 }); }
35
+ catch (error) {
36
+ if (error.code !== 'NATIVE_RUNTIME_OFFLINE') throw error;
37
+ }
38
+ }
39
+ const paths = nativePaths(ctx, { create: true });
40
+ const log = fs.openSync(paths.log, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW || 0), 0o600);
41
+ if (!fs.fstatSync(log).isFile() || fs.fstatSync(log).nlink !== 1) {
42
+ fs.closeSync(log);
43
+ throw new CliError('NATIVE_STATE_UNSAFE', 'Native runtime log must be a private regular file');
44
+ }
45
+ const child = spawn(process.execPath, [commandPath(), '--root', fs.realpathSync(ctx.root), '--db', path.resolve(ctx.dbPath), 'native', 'serve'], {
46
+ cwd: ctx.root, env: process.env, detached: true, stdio: ['ignore', log, log]
47
+ });
48
+ fs.closeSync(log);
49
+ let failed;
50
+ child.on('error', (error) => { failed = error; });
51
+ child.unref();
52
+ const deadline = Date.now() + 10000;
53
+ while (Date.now() < deadline) {
54
+ if (failed) throw new CliError('NATIVE_RUNTIME_START_FAILED', failed.message);
55
+ if (readNativePointer(ctx)) {
56
+ try { return await nativeRequest(ctx, 'GET', '/status', null, { timeoutMs: 1000 }); } catch {}
57
+ }
58
+ await delay(100);
59
+ }
60
+ throw new CliError('NATIVE_RUNTIME_START_FAILED', `Native runtime did not become ready. Inspect ${paths.log}`);
61
+ }
62
+
63
+ async function cmdNative(ctx, args) {
64
+ const command = args[0];
65
+ if (!command || wantsHelp(args)) return nativeHelp();
66
+ const opts = parseOpts(args.slice(1));
67
+ const allowed = {
68
+ up: [], serve: [], down: [], status: [],
69
+ start: ['peer', 'provider', 'cwd', 'model', 'binary', 'resume'],
70
+ send: ['peer', 'body', 'from', 'task'],
71
+ events: ['peer', 'after'], deliveries: ['peer'], requests: ['peer'],
72
+ respond: ['peer', 'request', 'decision', 'response-file'],
73
+ interrupt: ['peer', 'turn'], close: ['peer']
74
+ };
75
+ if (!Object.hasOwn(allowed, command)) throw new CliError('BAD_ARGS', `Unknown native command: ${command}`);
76
+ validateOpts(`native ${command}`, opts, allowed[command]);
77
+ if (['start', 'send', 'events', 'interrupt', 'close', 'requests', 'respond'].includes(command) && !opts.peer) throw new CliError('BAD_ARGS', 'Missing --peer');
78
+ if (command === 'start' && !opts.provider) throw new CliError('BAD_ARGS', 'Missing --provider');
79
+ if (command === 'start' && opts.provider === 'claude' && opts.binary !== undefined) throw new CliError('BAD_ARGS', 'Claude workers use the Agent SDK; --binary is available for Codex and dsh');
80
+ if (command === 'send' && !opts.body?.trim()) throw new CliError('BAD_ARGS', 'Missing --body');
81
+ let result;
82
+ if (command === 'up') result = await up(ctx);
83
+ else if (command === 'serve') {
84
+ const service = await startNativeService(ctx, deps);
85
+ const shutdown = () => { void service.shutdown().catch((error) => console.error(JSON.stringify({ code: error.code, message: error.message }))); };
86
+ process.once('SIGTERM', shutdown);
87
+ process.once('SIGINT', shutdown);
88
+ return;
89
+ } else if (command === 'start') {
90
+ await up(ctx);
91
+ result = await nativeRequest(ctx, 'POST', '/workers', { peer: opts.peer, provider: opts.provider,
92
+ cwd: opts.cwd ? path.resolve(ctx.cwd, opts.cwd) : ctx.root, model: opts.model, binary: opts.binary, resume: opts.resume });
93
+ } else if (command === 'send') {
94
+ const taskId = intOpt(opts, 'task', null);
95
+ if (taskId !== null && taskId < 1) throw new CliError('BAD_ARGS', 'Task ID must be positive');
96
+ const sender = deps.resolveCurrentPeer(ctx, opts, 'from', 'shell').id;
97
+ result = await nativeRequest(ctx, 'POST', '/send', { peer: opts.peer, from: sender, body: opts.body, taskId });
98
+ } else if (command === 'events') {
99
+ const after = intOpt(opts, 'after', 0);
100
+ if (after < 0) throw new CliError('BAD_ARGS', 'Event cursor must be nonnegative');
101
+ result = await nativeRequest(ctx, 'GET', `/events?peer=${encodeURIComponent(opts.peer)}&after=${after}`);
102
+ } else if (command === 'deliveries') {
103
+ result = await nativeRequest(ctx, 'GET', `/deliveries${opts.peer ? `?peer=${encodeURIComponent(opts.peer)}` : ''}`);
104
+ } else if (command === 'requests' || command === 'respond') {
105
+ const state = await nativeRequest(ctx, 'GET', '/workers/' + encodeURIComponent(opts.peer) + '/state');
106
+ if (command === 'requests') result = state.snapshot.pendingApprovals || [];
107
+ else {
108
+ if (!opts.request || !['accept', 'decline', 'cancel'].includes(opts.decision)) throw new CliError('BAD_ARGS', 'Respond requires --request ID and --decision accept|decline|cancel');
109
+ const pending = (state.snapshot.pendingApprovals || []).filter(request => String(request.requestId) === opts.request);
110
+ if (pending.length !== 1) throw new CliError('NATIVE_APPROVAL_MISMATCH', 'Select an exact pending request from hcc native requests');
111
+ let response = {};
112
+ if (opts['response-file']) {
113
+ const data = fs.readFileSync(path.resolve(ctx.cwd, opts['response-file']), 'utf8');
114
+ if (data.length > 100000) throw new CliError('BAD_ARGS', 'Response file exceeds the limit');
115
+ try { response = JSON.parse(data); } catch { throw new CliError('BAD_ARGS', 'Response file must contain JSON'); }
116
+ if (!response || typeof response !== 'object' || Array.isArray(response) || Object.keys(response).some(key => !['permissions','scope','answers'].includes(key))) throw new CliError('BAD_ARGS', 'Response file accepts permissions, scope and answers only');
117
+ }
118
+ const request = pending[0];
119
+ result = await nativeRequest(ctx, 'POST', '/respond', { peer: opts.peer, generation: state.generation,
120
+ owner: state.owner, provider: state.provider, executorId: state.owner, sessionId: request.sessionId,
121
+ turnId: request.turnId, requestId: request.requestId, decision: opts.decision, ...response });
122
+ }
123
+ } else if (command === 'interrupt') result = await nativeRequest(ctx, 'POST', '/interrupt', { peer: opts.peer, turnId: opts.turn });
124
+ else if (command === 'close') result = await nativeRequest(ctx, 'POST', '/close', { peer: opts.peer });
125
+ else if (command === 'down') result = await nativeRequest(ctx, 'POST', '/down', {});
126
+ else result = await nativeRequest(ctx, 'GET', '/status');
127
+ printResult(ctx, result, (data) => JSON.stringify(data, null, 2));
128
+ }
129
+ return { cmdNative };
130
+ }
@@ -18,6 +18,7 @@ import {
18
18
  readExternalBufferMetadata
19
19
  } from '../../runtime/buffer-evidence.mjs';
20
20
  import { inspectProcessIdentity } from '../../process/identity.mjs';
21
+ import { resolvePeerEvidence } from '../../core/peers/evidence.mjs';
21
22
  import {
22
23
  capturePtyStartupEvidence,
23
24
  installPtyTerminationHandlers,
@@ -73,22 +74,58 @@ async function cmdRun(ctx, args) {
73
74
  }
74
75
 
75
76
  const db = connect(ctx);
77
+ const wrapperIdentity = runtimeProcessIdentity();
76
78
  try {
77
- upsertPeer(db, {
78
- id, kind, role,
79
- worktree: cwd,
80
- branch: detectBranch(cwd),
81
- pid: process.pid,
82
- status: 'running',
83
- capabilities: 'run-wrapper'
84
- });
85
- upsertCanonicalPeerBinding(db, binding, Boolean(opts.force));
86
- addEvent(db, 'run.session.started', id, null, auditPayload({
87
- actor: id,
88
- target: id,
89
- command: [command, ...commandArgs].join(' '),
90
- cwd
91
- }));
79
+ let registered = false;
80
+ for (let attempt = 0; attempt < 2 && !registered; attempt += 1) {
81
+ const previous = db.prepare('SELECT * FROM peers WHERE id = ?').get(id) || null;
82
+ const evidence = previous ? resolvePeerEvidence({
83
+ peer: previous,
84
+ processes: [{
85
+ storedIdentity: {
86
+ pid: Number(previous.pid),
87
+ startToken: previous.pid_start_token,
88
+ commandHash: previous.pid_command_hash
89
+ },
90
+ current: inspectProcessIdentity(previous.pid)
91
+ }]
92
+ }) : null;
93
+ registered = tx(db, () => {
94
+ const current = db.prepare('SELECT * FROM peers WHERE id = ?').get(id) || null;
95
+ if (JSON.stringify(current) !== JSON.stringify(previous)) return false;
96
+ if (previous && evidence.state !== 'dead' && (
97
+ previous.pid || ['running', 'working', 'busy', 'starting', 'blocked', 'detached'].includes(previous.status)
98
+ )) {
99
+ throw new CliError('PEER_SESSION_EXISTS', `Peer ${id} already has a live or unresolved owner`, {
100
+ peer: id,
101
+ evidence_state: evidence.state
102
+ });
103
+ }
104
+ upsertPeer(db, {
105
+ id, kind, role,
106
+ worktree: cwd,
107
+ branch: detectBranch(cwd),
108
+ pid: process.pid,
109
+ processIdentity: wrapperIdentity,
110
+ status: 'running',
111
+ capabilities: 'run-wrapper'
112
+ });
113
+ upsertCanonicalPeerBinding(db, binding, Boolean(opts.force));
114
+ addEvent(db, 'run.session.started', id, null, auditPayload({
115
+ actor: id,
116
+ target: id,
117
+ command: [command, ...commandArgs].join(' '),
118
+ cwd
119
+ }));
120
+ return true;
121
+ });
122
+ }
123
+ if (!registered) {
124
+ throw new CliError('SUBJECT_CHANGED', `Peer ${id} changed while its owner was being observed; retry`, {
125
+ peer: id,
126
+ retryable: true
127
+ });
128
+ }
92
129
  } finally {
93
130
  db.close();
94
131
  }
@@ -107,12 +144,20 @@ async function cmdRun(ctx, args) {
107
144
  });
108
145
  const db2 = connect(ctx);
109
146
  try {
110
- db2.prepare('UPDATE peers SET status = ?, last_seen_at = ? WHERE id = ?').run('exited', now(), id);
111
- addEvent(db2, 'run.session.exited', id, null, auditPayload({
112
- actor: id,
113
- target: id,
114
- ...exitCode
115
- }));
147
+ tx(db2, () => {
148
+ const mutation = db2.prepare(`
149
+ UPDATE peers SET status = ?, last_seen_at = ?
150
+ WHERE id = ? AND pid IS ? AND pid_start_token IS ? AND pid_command_hash IS ?
151
+ `).run('exited', now(), id, process.pid, wrapperIdentity?.startToken || null,
152
+ wrapperIdentity?.commandHash || null);
153
+ if (Number(mutation.changes) > 0) {
154
+ addEvent(db2, 'run.session.exited', id, null, auditPayload({
155
+ actor: id,
156
+ target: id,
157
+ ...exitCode
158
+ }));
159
+ }
160
+ });
116
161
  } finally {
117
162
  db2.close();
118
163
  }
@@ -1,5 +1,7 @@
1
1
  // Task CLI commands extracted from bin/hcc.mjs.
2
2
 
3
+ import { visibleLocksWithEvidence } from '../../core/coordination/locks.mjs';
4
+
3
5
  const TASK_STATUS_SHORTCUTS = new Set(['running', 'review', 'blocked', 'abandoned']);
4
6
 
5
7
  export function createTaskCommands(deps) {
@@ -270,9 +272,10 @@ export function createTaskCommands(deps) {
270
272
  peerRow.evidence_reason = evidence.reason;
271
273
  }
272
274
  const graceUntil = readClockGraceUntil(db);
273
- const locks = clockGraceSuppressed(t, graceUntil)
274
- ? db.prepare('SELECT * FROM locks').all()
275
- : db.prepare('SELECT * FROM locks WHERE expires_at > ?').all(t);
275
+ const locks = visibleLocksWithEvidence(db.prepare('SELECT * FROM locks').all(), t, {
276
+ graceActive: clockGraceSuppressed(t, graceUntil),
277
+ evidenceByOwner: new Map(peers.map((peer) => [peer.id, { state: peer.evidence_state }]))
278
+ });
276
279
  rows = annotateTasksWithLiveness(rows, peers, locks, t, ACTIVE_PEER_TTL, graceUntil);
277
280
  printResult(ctx, rows, (data) => table(data, [
278
281
  { label: 'id', value: (r) => `#${r.id}` },
package/lib/cli-args.mjs CHANGED
@@ -6,6 +6,10 @@ export function splitGlobalArgs(argv) {
6
6
  const rest = [];
7
7
  for (let i = 0; i < argv.length; i += 1) {
8
8
  const arg = argv[i];
9
+ if (arg === '--') {
10
+ rest.push(...argv.slice(i));
11
+ break;
12
+ }
9
13
  if (arg === '--json') {
10
14
  global.json = true;
11
15
  } else if (arg === '--root') {
@@ -11,6 +11,9 @@ import { timelineFromRows } from './core/coordination/timeline.mjs';
11
11
  import { readTx } from './db/schema.mjs';
12
12
  import { clockGraceSuppressed, readClockGraceUntil } from './shared/clock-grace.mjs';
13
13
  import { classifyPeerActivity } from './core/peers/evidence.mjs';
14
+ import { shellQuoteArg } from './format.mjs';
15
+ import process from 'node:process';
16
+ import { fileURLToPath } from 'node:url';
14
17
 
15
18
  const DEFAULT_ACTIVE_PEER_TTL = 600;
16
19
  const DEFAULT_LOCK_TTL = 900;
@@ -147,13 +150,24 @@ export function createCoordinationState(deps = {}) {
147
150
  '[hello-cc coordination]',
148
151
  `peer: ${peerId}`,
149
152
  'This is live project coordination context injected by hello-cc.',
150
- 'You are not isolated for project coordination: hcc is the source of truth for other Claude/Codex/shell sessions in this project.',
153
+ 'You are not isolated for project coordination: hcc is the source of truth for other Claude/Codex/dsh/shell sessions in this project.',
151
154
  'If the user asks what other sessions are doing, what tasks exist, or whether you can see other sessions, do not answer from generic model knowledge and do not say sessions are isolated. Run hcc status, hcc state, hcc peers, hcc task list, hcc msg inbox, and hcc lock list, then answer from those results.',
152
155
  'Tasks are project work facts, not read/unread items. Open tasks stay relevant to every session until they are marked done or abandoned. Messages are the unread/ack notification channel.',
153
156
  'If you already own a current task, continue that task until handoff/done/blocked; do not claim a different task just because a new prompt arrived.',
154
157
  'When a hello-cc message asks for a response, reply with hcc msg reply --id <message-id> --body "<answer>" so the answer stays in the same thread.'
155
158
  ];
156
159
 
160
+ if (snapshot.peers.some((peer) => peer.id === peerId && peer.kind === 'dsh')) {
161
+ const hccBin = fileURLToPath(new URL('../bin/hcc.mjs', import.meta.url));
162
+ const command = ['env', 'HCC_PEER=' + peerId, 'HCC_ROOT=' + ctx.root,
163
+ 'HCC_DB=' + ctx.dbPath, process.execPath, hccBin].map(shellQuoteArg).join(' ');
164
+ parts.push('[hello-cc dsh session commands]');
165
+ parts.push('Every dsh agent has its own peer identity. Use this exact command prefix for ALL hcc commands, including the commands shown above and below:');
166
+ parts.push(command);
167
+ parts.push(`For example: ${command} status; ${command} msg inbox; ${command} msg reply --id <message-id> --body "<answer>".`);
168
+ parts.push('The prefix selects this session and project even when other session variables are inherited. Do not use a bare hcc command or borrow another peer identity.');
169
+ }
170
+
157
171
  if (peers.length > 0) {
158
172
  parts.push('[hello-cc known peers]');
159
173
  parts.push(...peers.map((peer) => {
@@ -1,4 +1,4 @@
1
- const CURRENT_TMUX_AUTHORITIES = `
1
+ export const DURABLE_RUNTIME_EVENTS = `
2
2
  SELECT MAX(e.id)
3
3
  FROM events e
4
4
  JOIN peer_bindings b
@@ -7,10 +7,22 @@ const CURRENT_TMUX_AUTHORITIES = `
7
7
  AND b.transport = 'tmux'
8
8
  AND (b.runtime_target IS NULL OR b.runtime_target = json_extract(e.payload, '$.pane'))
9
9
  GROUP BY b.peer, b.runtime_target
10
+ UNION
11
+ SELECT MAX(e.id) FROM events e
12
+ WHERE e.type IN ('web.adoption.paused','web.adoption.resumed') AND json_valid(e.payload)
13
+ GROUP BY json_extract(e.payload, '$.peer'), json_extract(e.payload, '$.pane')
14
+ UNION
15
+ SELECT e.id FROM events e WHERE e.type = 'codex.submission.pending'
16
+ UNION
17
+ SELECT e.id FROM events e WHERE e.type = 'task.result.recorded'
18
+ UNION
19
+ SELECT e.id FROM events e WHERE e.type = 'native.web.submission.pending'
20
+ UNION
21
+ SELECT e.id FROM events e WHERE e.type = 'codex.executor.started'
10
22
  `;
11
23
 
12
24
  export function pruneOldEventsPreservingTmuxAuthority(db, cutoff, options = {}) {
13
- const predicate = `created_at < ? AND id NOT IN (${CURRENT_TMUX_AUTHORITIES})`;
25
+ const predicate = `created_at < ? AND id NOT IN (${DURABLE_RUNTIME_EVENTS})`;
14
26
  const count = db.prepare(`SELECT COUNT(*) AS n FROM events WHERE ${predicate}`).get(cutoff).n;
15
27
  if (!options.dryRun) db.prepare(`DELETE FROM events WHERE ${predicate}`).run(cutoff);
16
28
  return Number(count || 0);
@@ -2,23 +2,13 @@ import { randomBytes } from 'node:crypto';
2
2
 
3
3
  import { readTx, tx } from '../../db/schema.mjs';
4
4
  import { aggregateCleanupFailure } from '../../shared/cleanup-error.mjs';
5
+ import { DURABLE_RUNTIME_EVENTS } from './event-retention.mjs';
5
6
 
6
7
  export const HISTORY_GC_BATCH_SIZE = 256;
7
8
 
8
9
  const HISTORY_CATEGORIES = Object.freeze(['events', 'tasks', 'messages', 'handoffs']);
9
10
  const PLAN_CHANGED = Symbol('gc-plan-changed');
10
11
 
11
- const CURRENT_TMUX_AUTHORITIES = `
12
- SELECT MAX(e.id)
13
- FROM events e
14
- JOIN peer_bindings b
15
- ON b.peer = json_extract(e.payload, '$.target_peer')
16
- WHERE e.type = 'tmux.session.attached'
17
- AND b.transport = 'tmux'
18
- AND (b.runtime_target IS NULL OR b.runtime_target = json_extract(e.payload, '$.pane'))
19
- GROUP BY b.peer, b.runtime_target
20
- `;
21
-
22
12
  function rowById(db, table, column, value) {
23
13
  return db.prepare(`SELECT * FROM ${table} WHERE ${column} = ?`).get(value) || null;
24
14
  }
@@ -108,7 +98,7 @@ function insertSnapshotCategory(db, snapshot, category) {
108
98
  db.prepare(`
109
99
  INSERT INTO ${table}(category, id)
110
100
  SELECT 'events', id FROM events
111
- WHERE created_at < ? AND id NOT IN (${CURRENT_TMUX_AUTHORITIES})
101
+ WHERE created_at < ? AND id NOT IN (${DURABLE_RUNTIME_EVENTS})
112
102
  `).run(snapshot.cutoff);
113
103
  } else if (category === 'tasks') {
114
104
  db.prepare(`
@@ -416,7 +406,7 @@ function captureHistoryGcPlanFromSnapshot(db, cutoff, options, snapshot) {
416
406
  FROM ${table} s
417
407
  JOIN events e ON e.id = s.id
418
408
  WHERE s.category = 'events' AND s.id > ? AND e.created_at < ?
419
- AND e.id NOT IN (${CURRENT_TMUX_AUTHORITIES})
409
+ AND e.id NOT IN (${DURABLE_RUNTIME_EVENTS})
420
410
  ORDER BY s.id ASC
421
411
  LIMIT ?
422
412
  `).all(after.events, cutoff, allocation);
@@ -44,3 +44,48 @@ export function locksConflict(a, b) {
44
44
  return lockBaseResource(a) === lockBaseResource(b) &&
45
45
  (lockScope(a) === WHOLE_LOCK_SCOPE || lockScope(b) === WHOLE_LOCK_SCOPE || lockScope(a) === lockScope(b));
46
46
  }
47
+
48
+ export function findRequestedLock(db, requested) {
49
+ const row = db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource) || null;
50
+ return row && lockBaseResource(row) === requested.base_resource && lockScope(row) === requested.scope
51
+ ? row
52
+ : null;
53
+ }
54
+
55
+ // Keep the persisted key format compatible with existing locks. A whole-resource
56
+ // name can equal a scoped key, but that physical collision is never authority to
57
+ // update or remove the other logical resource.
58
+ export function upsertRequestedLock(db, requested, lease) {
59
+ const result = db.prepare(`
60
+ INSERT INTO locks(resource, base_resource, scope, owner, task_id, reason, expires_at, created_at, ttl_sec)
61
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
62
+ ON CONFLICT(resource) DO UPDATE SET
63
+ base_resource = excluded.base_resource,
64
+ scope = excluded.scope,
65
+ owner = excluded.owner,
66
+ task_id = excluded.task_id,
67
+ reason = excluded.reason,
68
+ expires_at = excluded.expires_at,
69
+ created_at = excluded.created_at,
70
+ ttl_sec = excluded.ttl_sec
71
+ WHERE COALESCE(NULLIF(locks.base_resource, ''), locks.resource) = excluded.base_resource
72
+ AND COALESCE(NULLIF(locks.scope, ''), '*') = excluded.scope
73
+ `).run(requested.resource, requested.base_resource, requested.scope, lease.owner,
74
+ lease.task_id ?? null, lease.reason || '', lease.expires_at, lease.created_at, lease.ttl_sec);
75
+ if (Number(result.changes) !== 1) {
76
+ throw new CliError('LOCK_RESOURCE_COLLISION', `Resource ${lockLabel(requested)} collides with an existing lock key`, {
77
+ resource: requested.base_resource,
78
+ scope: requested.scope,
79
+ lock_resource: requested.resource
80
+ });
81
+ }
82
+ return findRequestedLock(db, requested);
83
+ }
84
+
85
+ export function visibleLocksWithEvidence(locks, nowSec, {
86
+ graceActive = false,
87
+ evidenceByOwner = new Map()
88
+ } = {}) {
89
+ return (locks || []).filter((lock) => graceActive || Number(lock.expires_at) > nowSec ||
90
+ evidenceByOwner.get(lock.owner)?.state === 'live');
91
+ }
@@ -0,0 +1,79 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { tx } from '../../db/schema.mjs';
3
+ import { CliError } from '../../shared/errors.mjs';
4
+
5
+ export const RESULT_EVENT = 'task.result.recorded';
6
+ const STAGES = new Set(['local', 'publication', 'business']);
7
+ const STATUSES = new Set(['passed', 'failed', 'pending']);
8
+ const KINDS = new Set(['verification', 'command', 'diff']);
9
+
10
+ function text(value, field, max, required = false) {
11
+ if (value !== undefined && value !== null && typeof value !== 'string') {
12
+ throw new CliError('BAD_REQUEST', `${field} must be text`);
13
+ }
14
+ const result = (value || '').trim();
15
+ if ((required && !result) || result.length > max) throw new CliError('BAD_REQUEST', `${field} must contain ${required ? '1' : '0'} to ${max} characters`);
16
+ return result;
17
+ }
18
+
19
+ // Evidence records do not mutate task status. The owner is checked in the same
20
+ // transaction as the insert, including after a concurrent task takeover.
21
+ export function recordTaskResult(db, input, { now = () => Math.floor(Date.now() / 1000), addEvent } = {}) {
22
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new CliError('BAD_REQUEST', 'A result object is required');
23
+ const peer = text(input.peer, 'peer', 200, true);
24
+ const taskId = input.taskId;
25
+ if (!Number.isSafeInteger(taskId) || taskId < 1) throw new CliError('BAD_REQUEST', 'taskId must be a positive integer');
26
+ const stage = input.stage || 'local', status = input.status || 'pending';
27
+ const kind = input.kind || 'verification', source = input.source || 'user';
28
+ if (!STAGES.has(stage) || !STATUSES.has(status) || !KINDS.has(kind) || !['user', 'executor'].includes(source)) {
29
+ throw new CliError('BAD_REQUEST', 'Unsupported result stage, status, kind or source');
30
+ }
31
+ if (source === 'executor' && stage !== 'local') throw new CliError('BAD_REQUEST', 'Executor records can only report local evidence');
32
+ const evidence = input.evidence || [];
33
+ if (!Array.isArray(evidence) || evidence.length > 20) throw new CliError('BAD_REQUEST', 'evidence must contain up to 20 text references');
34
+ const references = evidence.map((entry) => text(entry, 'evidence', 2000, true));
35
+ if (source === 'user' && status === 'passed' && !references.length) {
36
+ throw new CliError('BAD_REQUEST', 'A passed verification needs an evidence reference');
37
+ }
38
+ const record = {
39
+ result_id: randomUUID(), result_key: text(input.resultKey, 'resultKey', 700) || randomUUID(),
40
+ peer, task_id: taskId, stage, status, kind, source,
41
+ title: text(input.title, 'title', 300, true), details: text(input.details, 'details', 64000), evidence: references,
42
+ command: text(input.command, 'command', 64000), diff: text(input.diff, 'diff', 64000),
43
+ executor_id: text(input.executorId, 'executorId', 512) || null,
44
+ thread_id: text(input.threadId, 'threadId', 512) || null,
45
+ turn_id: text(input.turnId, 'turnId', 512) || null,
46
+ item_id: text(input.itemId, 'itemId', 512) || null,
47
+ exit_code: Number.isSafeInteger(input.exitCode) ? input.exitCode : null,
48
+ created_at: now()
49
+ };
50
+ return tx(db, () => {
51
+ const task = db.prepare('SELECT id, owner, title, status FROM tasks WHERE id = ?').get(taskId);
52
+ if (!task) throw new CliError('NOT_FOUND', 'Task not found');
53
+ if (task.owner !== peer) throw new CliError('TASK_OWNER_MISMATCH', 'This task belongs to a different peer');
54
+ const previous = db.prepare(`SELECT id, payload, created_at FROM events WHERE type = ?
55
+ AND task_id = ? AND actor = ? AND json_valid(payload) AND json_extract(payload, '$.result_key') = ?
56
+ ORDER BY id DESC LIMIT 1`).get(RESULT_EVENT, taskId, peer, record.result_key);
57
+ if (previous) return { ...JSON.parse(previous.payload), id: previous.id, created_at: previous.created_at };
58
+ record.task_title = task.title;
59
+ record.task_status = task.status;
60
+ if (addEvent) addEvent(db, RESULT_EVENT, peer, taskId, record);
61
+ else db.prepare('INSERT INTO events(type,actor,task_id,payload,created_at) VALUES(?,?,?,?,?)')
62
+ .run(RESULT_EVENT, peer, taskId, JSON.stringify(record), record.created_at);
63
+ return { ...record, id: Number(db.prepare('SELECT last_insert_rowid() AS id').get().id) };
64
+ });
65
+ }
66
+
67
+ export function listTaskResults(db, { peer, taskId = null, limit = 100 } = {}) {
68
+ if (!peer || !Number.isSafeInteger(limit) || limit < 1 || limit > 200) throw new CliError('BAD_REQUEST', 'peer and a limit from 1 to 200 are required');
69
+ if (taskId !== null && (!Number.isSafeInteger(taskId) || taskId < 1)) throw new CliError('BAD_REQUEST', 'taskId must be a positive integer');
70
+ return db.prepare(`SELECT id,payload,created_at FROM events
71
+ WHERE type = ? AND actor = ? AND (? IS NULL OR task_id = ?) AND json_valid(payload)
72
+ ORDER BY id DESC LIMIT ?`).all(RESULT_EVENT, peer, taskId, taskId, limit)
73
+ .map((row) => ({ ...JSON.parse(row.payload), id: row.id, created_at: row.created_at }));
74
+ }
75
+
76
+ export function summarizeTaskResults(results) {
77
+ return Object.fromEntries([...STAGES].map((stage) => [stage,
78
+ results.find((record) => record.stage === stage && record.kind === 'verification') || null]));
79
+ }