@logicseek/hello-cc 1.0.5 → 1.1.0-rc.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 (146) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/docs/codex-account.zh-CN.md +38 -0
  3. package/docs/codex-protocol.md +59 -0
  4. package/docs/commands.md +9 -0
  5. package/docs/commands.zh-CN.md +7 -0
  6. package/docs/macos-process-identity.md +2 -0
  7. package/docs/native.md +179 -14
  8. package/docs/native.zh-CN.md +136 -6
  9. package/docs/web-handoff.zh-CN.md +118 -8
  10. package/lib/cli/app.mjs +47 -4
  11. package/lib/cli/commands/coordination.mjs +14 -4
  12. package/lib/cli/commands/dsh.mjs +1 -1
  13. package/lib/cli/commands/gc.mjs +22 -15
  14. package/lib/cli/commands/hook.mjs +19 -3
  15. package/lib/cli/commands/install.mjs +80 -10
  16. package/lib/cli/commands/misc.mjs +36 -9
  17. package/lib/cli/commands/native.mjs +4 -38
  18. package/lib/cli/commands/peer.mjs +3 -0
  19. package/lib/cli/commands/run.mjs +13 -6
  20. package/lib/cli/commands/tmux.mjs +1 -2
  21. package/lib/cli-runtime.mjs +19 -2
  22. package/lib/core/coordination/results.mjs +27 -10
  23. package/lib/core/sessions/hook-root-identity.mjs +47 -0
  24. package/lib/core/sessions/launch.mjs +7 -1
  25. package/lib/db/connection.mjs +140 -42
  26. package/lib/db/schema.mjs +59 -0
  27. package/lib/diagnostics/codex-protocol.mjs +105 -0
  28. package/lib/guidance.mjs +21 -36
  29. package/lib/integrations/codex-account.mjs +143 -0
  30. package/lib/integrations/codex-interactions.mjs +13 -11
  31. package/lib/integrations/dsh-collaboration.mjs +6 -12
  32. package/lib/integrations/dsh-cordis.mjs +17 -13
  33. package/lib/integrations/dsh.mjs +16 -11
  34. package/lib/integrations/mcp-elicitation.mjs +116 -0
  35. package/lib/integrations/mcp-url-elicitation.mjs +25 -0
  36. package/lib/integrations/native/claude.mjs +24 -6
  37. package/lib/integrations/native/codex.mjs +38 -7
  38. package/lib/integrations/native/dsh-acp.mjs +67 -24
  39. package/lib/integrations/native/index.mjs +4 -1
  40. package/lib/integrations/native/interactions.mjs +2 -1
  41. package/lib/integrations/native/jsonrpc.mjs +37 -5
  42. package/lib/integrations/native/telemetry.mjs +30 -0
  43. package/lib/process/identity.mjs +4 -0
  44. package/lib/process/pinned-cwd.mjs +302 -0
  45. package/lib/process/pinned-guidance.mjs +138 -0
  46. package/lib/process/pinned-project-state.mjs +105 -0
  47. package/lib/process/selected-cwd-identity.mjs +95 -0
  48. package/lib/runtime/buffer-evidence.mjs +2 -1
  49. package/lib/runtime/client.mjs +9 -0
  50. package/lib/runtime/native/client.mjs +4 -1
  51. package/lib/runtime/native/history.mjs +140 -0
  52. package/lib/runtime/native/launcher.mjs +106 -0
  53. package/lib/runtime/native/service.mjs +64 -8
  54. package/lib/runtime/native/store.mjs +83 -58
  55. package/lib/runtime/paths.mjs +17 -2
  56. package/lib/runtime/private-file.mjs +76 -0
  57. package/lib/runtime/private-state.mjs +546 -0
  58. package/lib/runtime/project-context.mjs +2 -1
  59. package/lib/runtime/project-path.mjs +92 -20
  60. package/lib/runtime/project-trust.mjs +79 -0
  61. package/lib/runtime/projects.mjs +9 -3
  62. package/lib/runtime/state-migration.mjs +697 -0
  63. package/lib/runtime/state.mjs +69 -27
  64. package/lib/shared/automatic-result-key.mjs +6 -0
  65. package/lib/shared/json-file.mjs +55 -6
  66. package/lib/shared/redact.mjs +2 -1
  67. package/lib/ui/help.mjs +6 -4
  68. package/lib/web/agent-defaults.mjs +110 -0
  69. package/lib/web/auto-attach.mjs +3 -2
  70. package/lib/web/browser/contracts.d.ts +10 -0
  71. package/lib/web/browser/core.mjs +1997 -0
  72. package/lib/web/browser/project-data.mjs +158 -0
  73. package/lib/web/browser/session-sync.mjs +82 -0
  74. package/lib/web/browser-assets.mjs +19 -0
  75. package/lib/web/buffer-gc-runtime.mjs +2 -2
  76. package/lib/web/codex-app-server.mjs +111 -37
  77. package/lib/web/codex-sessions.mjs +61 -12
  78. package/lib/web/context-files.mjs +82 -0
  79. package/lib/web/cookie-auth.mjs +51 -3
  80. package/lib/web/external-sessions.mjs +76 -9
  81. package/lib/web/http-routes.mjs +238 -25
  82. package/lib/web/http.mjs +50 -4
  83. package/lib/web/liveness-reaper.mjs +2 -2
  84. package/lib/web/native-sessions.mjs +323 -14
  85. package/lib/web/project-contexts.mjs +236 -44
  86. package/lib/web/project-file-write.mjs +249 -0
  87. package/lib/web/project-files.mjs +268 -0
  88. package/lib/web/pty-sessions.mjs +11 -3
  89. package/lib/web/runtime-main.mjs +132 -80
  90. package/lib/web/session-serialize.mjs +87 -8
  91. package/lib/web/session-state-sync.mjs +98 -0
  92. package/lib/web/shutdown-diagnostics.mjs +38 -0
  93. package/lib/web/shutdown-response.mjs +21 -0
  94. package/lib/web/shutdown-state.mjs +91 -0
  95. package/lib/web/startup.mjs +103 -35
  96. package/lib/web/task-results.mjs +19 -19
  97. package/lib/web/tls.mjs +84 -33
  98. package/lib/web/tmux-sessions.mjs +73 -9
  99. package/lib/web/tmux-stream.mjs +36 -8
  100. package/lib/web/ui-agent-defaults.mjs +149 -0
  101. package/lib/web/ui-agent-start.mjs +330 -0
  102. package/lib/web/ui-codex-account.mjs +81 -0
  103. package/lib/web/ui-codex.mjs +105 -85
  104. package/lib/web/ui-command-palette.mjs +21 -5
  105. package/lib/web/ui-file-writes.mjs +220 -0
  106. package/lib/web/ui-files.mjs +231 -0
  107. package/lib/web/ui-handoff.mjs +10 -2
  108. package/lib/web/ui-history.mjs +175 -105
  109. package/lib/web/ui-i18n.mjs +280 -8
  110. package/lib/web/ui-interactions.mjs +107 -6
  111. package/lib/web/ui-native-timeline.mjs +326 -0
  112. package/lib/web/ui-native.mjs +93 -43
  113. package/lib/web/ui-review.mjs +5 -3
  114. package/lib/web/ui-safe-markdown.mjs +79 -0
  115. package/lib/web/ui-session-tools.mjs +224 -0
  116. package/lib/web/ui-template.mjs +29 -1968
  117. package/lib/web/ui-terminal-find.mjs +5 -3
  118. package/lib/web/ui-workbench.mjs +5 -3
  119. package/lib/web/ui-workspace.mjs +3 -0
  120. package/package.json +8 -2
  121. package/scripts/benchmark-session-sync.mjs +53 -0
  122. package/scripts/codex-protocol.mjs +88 -0
  123. package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
  124. package/scripts/dsh-package-provenance.mjs +118 -0
  125. package/scripts/mcp-form-live-fixture.mjs +81 -0
  126. package/scripts/native-bounded-live-acceptance.mjs +193 -0
  127. package/scripts/native-fixture-root.mjs +49 -0
  128. package/scripts/native-installed-acceptance.mjs +611 -0
  129. package/scripts/native-stability-checks.mjs +40 -0
  130. package/scripts/regression-cookie-expiry.mjs +70 -0
  131. package/scripts/regression-http-transport.mjs +9 -0
  132. package/scripts/regression-terminal-marker.mjs +95 -0
  133. package/scripts/regression.mjs +481 -219
  134. package/scripts/shim-reentry-probe.mjs +105 -0
  135. package/scripts/web-acceptance-diagnostics.mjs +90 -0
  136. package/scripts/web-codex-account-acceptance.mjs +178 -0
  137. package/scripts/web-file-preview-checks.mjs +302 -0
  138. package/scripts/web-mcp-form-acceptance.mjs +134 -0
  139. package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
  140. package/scripts/web-native-handoff-checks.mjs +128 -0
  141. package/scripts/web-native-interaction-acceptance.mjs +219 -18
  142. package/scripts/web-native-interaction-diagnostics.mjs +80 -0
  143. package/scripts/web-native-model-acceptance.mjs +22 -7
  144. package/scripts/web-session-tools-checks.mjs +112 -0
  145. package/scripts/web-workbench-acceptance.mjs +435 -0
  146. package/scripts/web-workbench-installed-acceptance.mjs +122 -0
@@ -0,0 +1,98 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { applyStateOperations, SESSION_SYNC_PROTOCOL } from './browser/session-sync.mjs';
3
+
4
+ export const STATE_HIGH_WATER_BYTES = 1024 * 1024;
5
+ export const STATE_MAX_FRAME_BYTES = 16 * 1024 * 1024;
6
+ const wireClone = value => value === undefined ? null : JSON.parse(JSON.stringify(value));
7
+
8
+ /** Immutable patch construction; retained text is appended without recopying historical turns. */
9
+ export function diffSessionState(previous, next, metadata = {}) {
10
+ const operations = [];
11
+ const set = (path, value) => operations.push({ op: 'set', path, value: wireClone(value) });
12
+ function diff(before, after, path) {
13
+ if (Object.is(before, after)) return;
14
+ if (typeof before === 'string' && typeof after === 'string' && after.startsWith(before)) {
15
+ operations.push({ op: 'append', path, value: after.slice(before.length) }); return;
16
+ }
17
+ if (!before || !after || typeof before !== 'object' || typeof after !== 'object' || Array.isArray(before) !== Array.isArray(after)) { set(path, after); return; }
18
+ if (Array.isArray(before)) {
19
+ if (before.length !== after.length) { set(path, after); return; }
20
+ for (let index = 0; index < before.length; index++) diff(before[index], after[index], [...path, index]);
21
+ return;
22
+ }
23
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
24
+ if ([...keys].some(key => ['__proto__', 'prototype', 'constructor'].includes(key))) { set(path, after); return; }
25
+ for (const key of keys) {
26
+ if (!Object.hasOwn(after, key) || after[key] === undefined) {
27
+ if (Object.hasOwn(before, key)) operations.push({ op: 'delete', path: [...path, key] });
28
+ }
29
+ else if (!Object.hasOwn(before, key)) set([...path, key], after[key]);
30
+ else diff(before[key], after[key], [...path, key]);
31
+ }
32
+ }
33
+ // Adapter-supplied hints cover every delta in the 50ms batch. Validate layout
34
+ // identities before indexing; newly created/evicted items take the full diff.
35
+ const paths = [];
36
+ const textOnly = metadata.textOnly === true && Array.isArray(metadata.textItems) &&
37
+ previous?.threads?.length === next?.threads?.length && metadata.textItems.every(({ threadId, turnId, itemId }) => {
38
+ const ti = next.threads.findIndex(thread => thread.id === threadId), thread = next.threads[ti], oldThread = previous.threads[ti];
39
+ if (!thread || oldThread?.id !== threadId || oldThread.turns.length !== thread.turns.length) return false;
40
+ const ui = thread.turns.findIndex(turn => turn.id === turnId), turn = thread.turns[ui], oldTurn = oldThread.turns[ui];
41
+ if (!turn || oldTurn?.id !== turnId || turn.items.length !== oldTurn.items.length) return false;
42
+ const ii = turn.items.findIndex(item => item.id === itemId), item = turn.items[ii];
43
+ if (!item || oldTurn.items[ii]?.id !== itemId || item.type !== 'agentMessage') return false;
44
+ paths.push(['threads', ti, 'turns', ui, 'items', ii, 'text']); return true;
45
+ });
46
+ if (textOnly) {
47
+ for (const path of paths) {
48
+ const read = object => path.reduce((value, key) => value?.[key], object);
49
+ diff(read(previous), read(next), path);
50
+ }
51
+ diff(previous.truncated, next.truncated, ['truncated']);
52
+ diff(previous.updateSequence, next.updateSequence, ['updateSequence']);
53
+ const count = next.updateSequence - previous.updateSequence;
54
+ if (Number.isSafeInteger(count) && count > 0 && count <= 100 && Array.isArray(previous.events) && Array.isArray(next.events)) {
55
+ const values = wireClone(next.events.slice(-count));
56
+ operations.push({ op: 'splice', path: ['events'], start: 0, deleteCount: Math.max(0, previous.events.length + values.length - 100), values: [] });
57
+ operations.push({ op: 'splice', path: ['events'], start: Math.min(previous.events.length, 100 - values.length), deleteCount: 0, values });
58
+ } else diff(previous.events, next.events, ['events']);
59
+ } else diff(previous, next, []);
60
+ return operations;
61
+ }
62
+
63
+ /** One server-side revision stream per session, independent of observer count. */
64
+ export function createSessionStateStream({ root, sessionId, channel, state, generation = randomUUID() }) {
65
+ let current = wireClone(state), revision = 0;
66
+ const executorId = String(state.executorId || state.owner || '');
67
+ const envelope = () => ({ type: 'state_sync', protocol: SESSION_SYNC_PROTOCOL, root, sessionId, channel, executorId, generation, revision });
68
+ const api = {
69
+ snapshot(requestId) { return { ...envelope(), mode: 'snapshot', state: current, ...(requestId ? { requestId } : {}) }; },
70
+ update(next, metadata) {
71
+ if (String(next.executorId || next.owner || '') !== executorId) return { replaced: true };
72
+ const operations = diffSessionState(current, next, metadata);
73
+ if (!operations.length) return null;
74
+ const baseRevision = revision++;
75
+ current = operations.length > 2048 ? wireClone(next) : applyStateOperations(current, operations);
76
+ // A huge structural replacement is a snapshot; ordinary text stays delta.
77
+ if (operations.length > 2048) return { ...envelope(), mode: 'snapshot', state: current, replacement: true };
78
+ return { ...envelope(), mode: 'patch', baseRevision, operations };
79
+ }
80
+ };
81
+ Object.defineProperties(api, { executorId: { get: () => executorId }, state: { get: () => current } });
82
+ return api;
83
+ }
84
+
85
+ /** Slow structured observers disconnect and recover from a new snapshot. No work is replayed. */
86
+ export function sendStructuredFrame(client, text) {
87
+ if (client.hccStateClosing || client.readyState !== client.OPEN) return false;
88
+ const bytes = Buffer.byteLength(text);
89
+ if ((client.bufferedAmount || 0) > STATE_HIGH_WATER_BYTES || bytes > STATE_MAX_FRAME_BYTES) {
90
+ client.hccStateClosing = true;
91
+ try { client.close(bytes > STATE_MAX_FRAME_BYTES ? 1009 : 1013, 'state stream requires snapshot recovery'); } catch { client.terminate?.(); }
92
+ // close() itself may be queued behind a slow reader. Bound that lifetime.
93
+ const timer = setTimeout(() => { if (client.readyState !== client.CLOSED) client.terminate?.(); }, 1000);
94
+ timer.unref?.(); client.once?.('close', () => clearTimeout(timer));
95
+ return false;
96
+ }
97
+ client.send(text); return true;
98
+ }
@@ -0,0 +1,38 @@
1
+ import { performance } from 'node:perf_hooks';
2
+
3
+ const PREFIX = 'HCC_SHUTDOWN_PHASE ';
4
+ const PHASES = new Set(['request_received', 'stop_accepted', 'response_finished',
5
+ 'cleanup_begin', 'pollers_closed', 'state_cleaned', 'sessions_cleaned', 'http_closed', 'cleanup_end']);
6
+ const COUNTS = ['sessions', 'running', 'tmux', 'appServer', 'native', 'external', 'pty'];
7
+ const bounded = value => Number.isSafeInteger(value) && value >= 0 && value <= 86400000;
8
+
9
+ // Opt-in fixture telemetry only: fixed phases and counts, no identifiers,
10
+ // paths, command arguments, request fields, messages or environment values.
11
+ export function createShutdownDiagnostics({ enabled = false, pid = process.pid,
12
+ now = () => performance.now(), write = line => console.error(line) } = {}) {
13
+ const started = enabled ? now() : 0;
14
+ return (phase, counts = {}) => {
15
+ if (!enabled || !PHASES.has(phase)) return;
16
+ try {
17
+ const elapsedMs = Math.max(0, Math.round(now() - started));
18
+ if (!bounded(elapsedMs)) return;
19
+ write(PREFIX + JSON.stringify({ pid, phase, elapsedMs,
20
+ ...Object.fromEntries(COUNTS.filter(key => bounded(counts[key])).map(key => [key, counts[key]])) }));
21
+ } catch { /* Diagnostics must never change shutdown behavior. */ }
22
+ };
23
+ }
24
+
25
+ export function parseShutdownDiagnostics(text, pid) {
26
+ if (typeof text !== 'string' || Buffer.byteLength(text) > 65536 || !Number.isSafeInteger(pid) || pid <= 0) return [];
27
+ const result = [];
28
+ for (const line of text.split(/\r?\n/)) {
29
+ if (!line.startsWith(PREFIX) || line.length > 2048) continue;
30
+ try {
31
+ const value = JSON.parse(line.slice(PREFIX.length));
32
+ if (value?.pid !== pid || !PHASES.has(value.phase) || !bounded(value.elapsedMs)) continue;
33
+ result.push({ phase: value.phase, elapsedMs: value.elapsedMs,
34
+ ...Object.fromEntries(COUNTS.filter(key => bounded(value[key])).map(key => [key, value[key]])) });
35
+ } catch {}
36
+ }
37
+ return result.slice(-16);
38
+ }
@@ -0,0 +1,21 @@
1
+ // Stop accepting work before calling this helper. Cleanup starts after the
2
+ // reply is flushed or disconnected, with a bound for an unfinished response.
3
+ export function scheduleResponseShutdown(response, shutdown, {
4
+ fallbackMs = 250, schedule = setTimeout, cancel = clearTimeout
5
+ } = {}) {
6
+ let started = false;
7
+ let timer;
8
+ function finish() {
9
+ if (started) return;
10
+ started = true;
11
+ response.removeListener('finish', finish);
12
+ response.removeListener('close', finish);
13
+ if (timer !== undefined) cancel(timer);
14
+ shutdown();
15
+ }
16
+ response.once('finish', finish);
17
+ response.once('close', finish);
18
+ timer = schedule(finish, fallbackMs);
19
+ timer?.unref?.();
20
+ if (response.writableFinished || response.destroyed) finish();
21
+ }
@@ -0,0 +1,91 @@
1
+ import path from 'node:path';
2
+ import { performance } from 'node:perf_hooks';
3
+ import { contextForProject } from '../runtime/paths.mjs';
4
+ import { clearRuntime } from '../runtime/state.mjs';
5
+ import { readProjectRegistry } from '../runtime/projects.mjs';
6
+
7
+ // This budget bounds work admitted between synchronous operations. Nonblocking
8
+ // leases avoid contention waits; their worker startup/release and filesystem
9
+ // validation retain their own bounds. This is not a total shutdown deadline.
10
+ export function cleanupRuntimeState({
11
+ ctx, sessions, connectWebProject, now, ownerIdentity,
12
+ budgetMs = 1000, monotonicNow = () => performance.now(),
13
+ readProjects = readProjectRegistry, clearPointers = clearRuntime
14
+ }) {
15
+ const deadline = monotonicNow() + budgetMs;
16
+ const expired = () => monotonicNow() >= deadline;
17
+ const result = { detachedProjects: 0, skippedProjects: 0 };
18
+ const pointerOptions = { nonblocking: true, expectedIdentity: ownerIdentity, deadline, monotonicNow };
19
+ // Only this process instance may clear its pointers. A missing identity is
20
+ // left for the normal stale-pointer recovery path after the owner exits.
21
+ if (ownerIdentity) {
22
+ try { clearPointers(ctx, ownerIdentity.pid, pointerOptions); } catch {}
23
+ const seen = new Set([path.resolve(ctx.root)]);
24
+ try {
25
+ if (!expired()) for (const project of readProjects()) {
26
+ if (expired()) break;
27
+ const root = path.resolve(project.root);
28
+ if (seen.has(root)) continue;
29
+ seen.add(root);
30
+ clearPointers({ root }, ownerIdentity.pid, { ...pointerOptions, clearGlobal: false });
31
+ }
32
+ } catch {}
33
+ }
34
+
35
+ const byProject = new Map();
36
+ for (const session of sessions.values()) {
37
+ if (expired()) return result;
38
+ // The PTY factory predates explicit session.type; serialization uses the
39
+ // same PTY default. Require its actual handle before accepting that shape.
40
+ const type = session.type ?? (session.pty && typeof session.pty === 'object' ? 'pty' : null);
41
+ if (session.status !== 'running' || !['tmux', 'pty'].includes(type)) continue;
42
+ const peer = session.peerId || session.id;
43
+ if (!peer) continue;
44
+ try {
45
+ const projectCtx = session.ctx || contextForProject(path.resolve(session.root || ctx.root), null, { json: ctx.json });
46
+ const identity = projectCtx.rootIdentity?.identity;
47
+ const key = [projectCtx.root, projectCtx.dbPath, identity?.dev, identity?.ino, identity?.birthtimeNs].join('\0');
48
+ if (!byProject.has(key)) byProject.set(key, { projectCtx, sessions: [] });
49
+ byProject.get(key).sessions.push({ peer, id: session.id, type,
50
+ pane: session.pane, pid: session.pid });
51
+ } catch { result.skippedProjects++; }
52
+ }
53
+ for (const { projectCtx, sessions: owned } of byProject.values()) {
54
+ if (expired()) break;
55
+ let db;
56
+ try {
57
+ db = connectWebProject(projectCtx, { create: false, existingSchema: true });
58
+ if (expired()) continue;
59
+ // Do not use tx(): its normal write path retries busy writers. Both
60
+ // advisory changes either commit together now or are left for recovery.
61
+ db.exec('BEGIN IMMEDIATE');
62
+ try {
63
+ projectCtx.rootIdentity?.assertUnchanged();
64
+ const ids = [];
65
+ const binding = db.prepare(`SELECT p.pid, b.transport, b.runtime_session_id, b.runtime_target
66
+ FROM peers p JOIN peer_bindings b ON b.peer = p.id WHERE p.id = ?`);
67
+ for (const session of owned) {
68
+ if (expired()) break;
69
+ const row = binding.get(session.peer);
70
+ if (!row || row.transport !== (session.type === 'tmux' ? 'tmux' : 'web-pty') ||
71
+ row.runtime_session_id !== session.id ||
72
+ (session.type === 'tmux' && row.runtime_target !== session.pane) ||
73
+ (Number.isInteger(session.pid) && row.pid !== session.pid)) continue;
74
+ ids.push(session.peer);
75
+ }
76
+ if (ids.length && !expired()) {
77
+ const placeholders = ids.map(() => '?').join(',');
78
+ db.prepare(`UPDATE peers SET status = 'detached' WHERE id IN (${placeholders}) AND status IN ('running','working','busy')`).run(...ids);
79
+ db.prepare(`UPDATE peer_bindings SET runtime_target = NULL, updated_at = ? WHERE peer IN (${placeholders})`).run(now(), ...ids);
80
+ }
81
+ if (expired()) db.exec('ROLLBACK');
82
+ else { db.exec('COMMIT'); result.detachedProjects++; }
83
+ } catch (error) {
84
+ if (db.isTransaction) db.exec('ROLLBACK');
85
+ throw error;
86
+ }
87
+ } catch { result.skippedProjects++; }
88
+ finally { try { db?.close(); } catch {} }
89
+ }
90
+ return result;
91
+ }
@@ -7,7 +7,7 @@ import path from 'node:path';
7
7
  import process from 'node:process';
8
8
  import { createHash, randomUUID } from 'node:crypto';
9
9
  import { URL } from 'node:url';
10
- import { spawn, spawnSync } from 'node:child_process';
10
+ import { spawnSync } from 'node:child_process';
11
11
  import { setTimeout as sleep } from 'node:timers/promises';
12
12
  import { CliError } from '../shared/errors.mjs';
13
13
  import { redactCliArgs } from '../shared/redact.mjs';
@@ -15,8 +15,13 @@ import { intOpt, parseOpts, splitGlobalArgs, validateOpts } from '../cli-args.mj
15
15
  import { printResult } from '../format.mjs';
16
16
  import { commandPath, tailFile } from '../cli-runtime.mjs';
17
17
  import { compareProcessIdentity, inspectProcessIdentity } from '../process/identity.mjs';
18
+ import { spawnPinnedCwdProcess } from '../process/pinned-cwd.mjs';
19
+ import { assertSelectedCwdSnapshot, captureSelectedCwdSnapshot } from '../process/selected-cwd-identity.mjs';
18
20
  import { registerProject } from '../runtime/projects.mjs';
19
21
  import { runtimePath, globalRuntimePath, globalStateDir, webLogPath } from '../runtime/paths.mjs';
22
+ import { resolveProjectDatabase, secureExistingProjectStateDirectory } from '../runtime/project-path.mjs';
23
+ import { privateProjectStateDir } from '../runtime/private-state.mjs';
24
+ import { openPrivateAppendFile } from '../runtime/private-file.mjs';
20
25
  import { readHealthyGlobalRuntime, writeRuntime } from '../runtime/state.mjs';
21
26
  import { runtimeRequest } from '../runtime/client.mjs';
22
27
  import { withFileLock } from '../shared/file-lock.mjs';
@@ -234,29 +239,74 @@ async function startWebBackground(ctx, args) {
234
239
  }
235
240
 
236
241
  async function startWebBackgroundLocked(ctx, args, opts) {
237
- const setup = await prepareLocalBus(ctx, {
242
+ // `prepareLocalBus` opens the database and may write guidance before its
243
+ // first await. Resolve both paths before handing it a context that can be
244
+ // redirected by a symlink alias while setup is in progress.
245
+ if (!fs.existsSync(ctx.root)) fs.mkdirSync(ctx.root, { recursive: true });
246
+ const expectedRootIdentity = ctx.initialRootIdentity || captureSelectedCwdSnapshot(ctx.root);
247
+ assertSelectedCwdSnapshot(expectedRootIdentity);
248
+ const requestedRuntimeFile = path.join(path.resolve(ctx.root), '.hello-cc', 'runtime.json');
249
+ const bound = resolveProjectDatabase({
250
+ root: ctx.root, db: requestedRuntimeFile, createStateDir: true
251
+ });
252
+ const boundRoot = bound.root;
253
+ const requestedDb = path.resolve(ctx.dbPath);
254
+ const managedDb = [
255
+ path.join(path.resolve(ctx.root), '.hello-cc'),
256
+ path.join(boundRoot, '.hello-cc'),
257
+ privateProjectStateDir(boundRoot)
258
+ ].some((stateDir) => {
259
+ const relative = path.relative(stateDir, requestedDb);
260
+ return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) &&
261
+ !path.isAbsolute(relative));
262
+ });
263
+ // External --db/HCC_DB paths are a documented caller-managed option. Only
264
+ // project-managed DBs need this canonicalization (including alias-spelled
265
+ // nested paths whose parent has not been created yet).
266
+ const boundDb = managedDb
267
+ ? resolveProjectDatabase({
268
+ root: ctx.root, db: requestedDb, createStateDir: true, createDatabaseParents: true
269
+ }).db
270
+ : requestedDb;
271
+ const boundCtx = { ...ctx, root: boundRoot, dbPath: boundDb };
272
+ const boundStateDir = bound.stateDir;
273
+ const runtimeFile = path.join(boundStateDir, 'runtime.json');
274
+ const logFile = path.join(boundStateDir, 'web.log');
275
+ const assertBoundProject = () => {
276
+ assertSelectedCwdSnapshot(expectedRootIdentity);
277
+ const current = resolveProjectDatabase({ root: ctx.root, db: requestedRuntimeFile });
278
+ if (current.root !== boundRoot || current.stateDir !== boundStateDir ||
279
+ (managedDb && resolveProjectDatabase({ root: ctx.root, db: requestedDb }).db !== boundDb)) {
280
+ throw new CliError('PROJECT_PATH_FORBIDDEN', 'Project state route changed during Web startup');
281
+ }
282
+ secureExistingProjectStateDirectory(boundRoot);
283
+ };
284
+ assertBoundProject();
285
+ const setup = await prepareLocalBus(boundCtx, {
238
286
  ...opts,
239
287
  installShims: process.env.HCC_SKIP_SHIM_INSTALL === '1' ? false : true
240
288
  });
241
- registerProject(ctx);
289
+ assertBoundProject();
290
+ registerProject(boundCtx);
242
291
 
243
292
  const existing = await readHealthyGlobalRuntime();
244
293
  if (existing) {
245
294
  if (webRuntimeMatchesRequest(existing, opts)) {
246
- await stopOrphanWebRuntimes(ctx, existing.pid || null);
295
+ await stopOrphanWebRuntimes(boundCtx, existing.pid || null);
247
296
  rememberRuntimeToken(existing, opts);
248
297
  try {
249
- await runtimeRequest(ctx, 'POST', '/api/projects', { root: ctx.root, db: ctx.dbPath }, existing);
298
+ await runtimeRequest(boundCtx, 'POST', '/api/projects', { root: boundRoot, db: boundCtx.dbPath }, existing);
250
299
  } catch {}
251
- writeRuntime(ctx, {
300
+ assertBoundProject();
301
+ writeRuntime(boundCtx, {
252
302
  ...existing,
253
303
  api_version: API_VERSION,
254
- root: ctx.root,
255
- db: ctx.dbPath,
256
- project_root: ctx.root,
304
+ root: boundRoot,
305
+ db: boundCtx.dbPath,
306
+ project_root: boundRoot,
257
307
  global_runtime: true
258
308
  });
259
- return printWebRuntime(ctx, existing, { already: true, logFile: webLogPath(ctx), setup });
309
+ return printWebRuntime(boundCtx, existing, { already: true, logFile, runtimeFile, setup });
260
310
  }
261
311
  // TLS-2: an idempotent `hcc web` must not silently stop a TLS runtime and
262
312
  // downgrade to plaintext (or vice versa) when only --tls/--trust-proxy
@@ -272,48 +322,66 @@ async function startWebBackgroundLocked(ctx, args, opts) {
272
322
  `A ${runtimeTls ? 'TLS' : 'plaintext'} web runtime is already running${existing.trust_proxy ? ' with --trust-proxy' : ''} on port ${existing.port}. ` +
273
323
  `Run ${CLI_NAME} down first, or re-run with matching flags (${opts.tls ? '--tls' : 'no --tls'}${opts['trust-proxy'] ? ', --trust-proxy' : ''}).`);
274
324
  }
275
- try { await runtimeRequest(ctx, 'POST', '/api/runtime/stop', {}, existing); } catch {}
325
+ try { await runtimeRequest(boundCtx, 'POST', '/api/runtime/stop', {}, existing); } catch {}
276
326
  await sleep(250);
277
327
  }
278
- await stopOrphanWebRuntimes(ctx);
328
+ assertBoundProject();
329
+ await stopOrphanWebRuntimes(boundCtx);
279
330
 
280
- try { fs.rmSync(runtimePath(ctx), { force: true }); } catch {}
331
+ // A project path can be reused for a different directory after setup. Do
332
+ // not remove its old private runtime pointer or append to its old Web log.
333
+ assertBoundProject();
334
+ try { fs.rmSync(runtimeFile, { force: true }); } catch {}
281
335
  try { fs.rmSync(globalRuntimePath(), { force: true }); } catch {}
282
336
 
283
- const logFile = webLogPath(ctx);
284
- fs.mkdirSync(path.dirname(logFile), { recursive: true });
285
- // bg-05: rotate web.log once it grows past 5 MB (keep the previous .1).
337
+ assertBoundProject();
338
+ let logFd = openPrivateAppendFile(logFile);
286
339
  try {
287
- if (fs.statSync(logFile).size > 5 * 1024 * 1024) {
288
- try { fs.rmSync(`${logFile}.1`, { force: true }); } catch {}
289
- try { fs.renameSync(logFile, `${logFile}.1`); } catch {}
340
+ // bg-05: rotate web.log once it grows past 5 MB (keep the previous .1).
341
+ if (fs.fstatSync(logFd).size > 5 * 1024 * 1024) {
342
+ const previous = `${logFile}.1`;
343
+ try {
344
+ const stat = fs.lstatSync(previous);
345
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 ||
346
+ (typeof process.getuid === 'function' && stat.uid !== process.getuid())) {
347
+ throw new CliError('PROJECT_PATH_FORBIDDEN', `Rotated Web log is not owned: ${previous}`);
348
+ }
349
+ fs.unlinkSync(previous);
350
+ } catch (error) {
351
+ if (error?.code !== 'ENOENT') throw error;
352
+ }
353
+ fs.closeSync(logFd);
354
+ logFd = null;
355
+ fs.renameSync(logFile, previous);
356
+ logFd = openPrivateAppendFile(logFile);
290
357
  }
291
- } catch {}
292
- const redactedStart = redactedLogText(`${CLI_NAME} web ${redactCliArgs(args).join(' ')}`);
293
- fs.appendFileSync(logFile, `\n[${new Date().toISOString()}] ${redactedStart}\n`, { mode: 0o600 });
294
- // The runtime echoes token-bearing URLs into web.log; keep it owner-only so a
295
- // co-tenant on the machine cannot read the token (net-02).
296
- try { fs.chmodSync(logFile, 0o600); } catch {}
297
- const logFd = fs.openSync(logFile, 'a');
358
+ const redactedStart = redactedLogText(`${CLI_NAME} web ${redactCliArgs(args).join(' ')}`);
359
+ assertBoundProject();
360
+ fs.writeSync(logFd, `\n[${new Date().toISOString()}] ${redactedStart}\n`);
361
+ } catch (error) {
362
+ if (logFd !== null) fs.closeSync(logFd);
363
+ throw error;
364
+ }
298
365
 
299
366
  const childArgs = [
300
367
  commandPath(),
301
- '--root', ctx.root,
302
- '--db', ctx.dbPath,
368
+ '--root', boundRoot,
369
+ '--db', boundCtx.dbPath,
303
370
  'web',
304
371
  ...args
305
372
  ];
306
373
  const childEnv = {
307
374
  ...process.env,
308
375
  [WEB_CHILD_ENV]: '1',
309
- HCC_ROOT: ctx.root,
310
- HCC_DB: ctx.dbPath
376
+ HCC_ROOT: boundRoot,
377
+ HCC_DB: boundCtx.dbPath
311
378
  };
312
379
 
313
380
  let child;
314
381
  try {
315
- child = spawn(process.execPath, childArgs, {
316
- cwd: ctx.cwd,
382
+ child = spawnPinnedCwdProcess(process.execPath, childArgs, {
383
+ cwd: boundRoot,
384
+ expectedIdentity: expectedRootIdentity,
317
385
  env: childEnv,
318
386
  detached: true,
319
387
  stdio: ['ignore', logFd, logFd]
@@ -322,9 +390,9 @@ async function startWebBackgroundLocked(ctx, args, opts) {
322
390
  try { fs.closeSync(logFd); } catch {}
323
391
  }
324
392
 
325
- const runtime = await waitForStartedRuntime(ctx, child, logFile);
393
+ const runtime = await waitForStartedRuntime(boundCtx, child, logFile);
326
394
  child.unref();
327
- return printWebRuntime(ctx, runtime, { already: false, logFile, setup });
395
+ return printWebRuntime(boundCtx, runtime, { already: false, logFile, runtimeFile, setup });
328
396
  }
329
397
 
330
398
  async function waitForStartedRuntime(ctx, child, logFile) {
@@ -370,7 +438,7 @@ function printWebRuntime(ctx, runtime, opts = {}) {
370
438
  port: runtime.port || null,
371
439
  url: publicRuntimeUrl(runtime, ctx.root),
372
440
  local_url: localRuntimeUrl(runtime, ctx.root),
373
- runtime: runtimePath(ctx),
441
+ runtime: opts.runtimeFile || runtimePath(ctx),
374
442
  log: logFile,
375
443
  stop: `${CLI_NAME} down`
376
444
  };
@@ -1,8 +1,6 @@
1
- import { createHash } from 'node:crypto';
2
1
  import { selectCurrentTask } from '../core/coordination/automation.mjs';
3
2
  import { listTaskResults, recordTaskResult, summarizeTaskResults } from '../core/coordination/results.mjs';
4
3
  import { CliError } from '../shared/errors.mjs';
5
- import { redactSecrets } from '../shared/redact.mjs';
6
4
 
7
5
  export function createTaskResults({ connectWebProject, resolveSessionPeerId, now, addEvent }) {
8
6
  const turnScopes = new WeakMap();
@@ -31,7 +29,7 @@ export function createTaskResults({ connectWebProject, resolveSessionPeerId, now
31
29
  return { result };
32
30
  } finally { db.close(); }
33
31
  }
34
- function observeCodexResults(session, state) {
32
+ function observeCodexResults(session, state, event) {
35
33
  if (!session.binding?.provider_session_id || state.executorId !== (session.executorId || session.adapter?.snapshot().executorId)) return;
36
34
  const thread = state.threads?.find(t => t.id === session.binding.provider_session_id);
37
35
  if (!thread?.turns?.length) return;
@@ -40,9 +38,17 @@ export function createTaskResults({ connectWebProject, resolveSessionPeerId, now
40
38
  const peer = resolveSessionPeerId(db, session);
41
39
  let scopes = turnScopes.get(session);
42
40
  if (!scopes) { scopes = new Map(); turnScopes.set(session, scopes); }
43
- for (const event of state.events || []) {
44
- if (event.method === 'turn/started' && event.params?.threadId === thread.id && event.params.turn?.id && !scopes.has(event.params.turn.id)) {
45
- scopes.set(event.params.turn.id, currentTask(db, peer)?.id || null);
41
+ // The snapshot retains old events. Only the adapter's fresh event may bind
42
+ // a turn to the task that is current at this instant.
43
+ if (event?.method === 'turn/started' && event.params?.threadId === thread.id &&
44
+ event.params.turn?.id && !scopes.has(event.params.turn.id)) {
45
+ scopes.set(event.params.turn.id, currentTask(db, peer)?.id || null);
46
+ }
47
+ function recordAutomatic(input) {
48
+ try { recordTaskResult(db, input, { now, addEvent, automatic: true }); return true; }
49
+ catch (error) {
50
+ if (error instanceof CliError && ['TASK_OWNER_MISMATCH', 'NOT_FOUND'].includes(error.code)) return false;
51
+ throw error;
46
52
  }
47
53
  }
48
54
  for (const turn of thread.turns) {
@@ -50,29 +56,23 @@ export function createTaskResults({ connectWebProject, resolveSessionPeerId, now
50
56
  if (!taskId) continue;
51
57
  const common = { peer, taskId, executorId: state.executorId, threadId: thread.id,
52
58
  turnId: turn.id, source: 'executor', stage: 'local' };
59
+ let taskUnavailable = false;
53
60
  for (const item of turn.items || []) {
54
61
  if (item.type !== 'commandExecution' || !['completed', 'failed', 'declined'].includes(item.status)) continue;
55
62
  const exitCode = item.exitCode;
56
63
  const status = exitCode === 0 && item.status === 'completed' ? 'passed'
57
64
  : item.status === 'failed' || item.status === 'declined' || (Number.isSafeInteger(exitCode) && exitCode !== 0) ? 'failed' : 'pending';
58
- const command = redactSecrets(String(item.command || ''));
59
- const output = redactSecrets(String(item.aggregatedOutput || ''));
60
- recordTaskResult(db, { ...common, itemId: item.id, kind: 'command', status, exitCode,
61
- resultKey: `${state.executorId}:${turn.id}:${item.id}:command`,
62
- title: (command || 'Command execution').slice(0, 300),
63
- command: command.slice(0, 64000),
64
- details: output.slice(0, 64000), evidence: [] }, { now, addEvent });
65
+ if (!recordAutomatic({ ...common, itemId: item.id, kind: 'command', status, exitCode })) {
66
+ taskUnavailable = true;
67
+ break;
68
+ }
65
69
  }
70
+ if (taskUnavailable) continue;
66
71
  if (turn.diff && ['completed', 'failed', 'interrupted'].includes(turn.status)) {
67
- const diff = redactSecrets(String(turn.diff)).slice(0, 64000);
68
- recordTaskResult(db, { ...common, kind: 'diff', status: 'pending', title: 'Turn changes awaiting review', diff,
69
- resultKey: `${state.executorId}:${turn.id}:diff:${createHash('sha256').update(diff).digest('hex')}` }, { now, addEvent });
72
+ recordAutomatic({ ...common, kind: 'diff', status: 'pending' });
70
73
  }
71
74
  }
72
75
  while (scopes.size > 100) scopes.delete(scopes.keys().next().value);
73
- } catch (error) {
74
- // A delayed executor result must not attach to a task after ownership changed.
75
- if (!(error instanceof CliError) || !['TASK_OWNER_MISMATCH', 'NOT_FOUND'].includes(error.code)) throw error;
76
76
  } finally { db.close(); }
77
77
  }
78
78
  return { readSessionResults, writeSessionResult, observeCodexResults };