@logicseek/hello-cc 1.1.0-rc.8 → 1.1.0

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 (74) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +2 -0
  3. package/README.zh-CN.md +2 -0
  4. package/docs/README.md +2 -0
  5. package/docs/README.zh-CN.md +2 -0
  6. package/docs/commands.md +16 -0
  7. package/docs/commands.zh-CN.md +14 -0
  8. package/docs/dsh.md +6 -0
  9. package/docs/dsh.zh-CN.md +5 -0
  10. package/docs/native.md +21 -1
  11. package/docs/native.zh-CN.md +19 -1
  12. package/docs/private-state.md +115 -0
  13. package/docs/private-state.zh-CN.md +92 -0
  14. package/lib/cli/app.mjs +46 -5
  15. package/lib/cli/commands/hook.mjs +31 -4
  16. package/lib/cli/commands/mcp.mjs +6 -0
  17. package/lib/cli/commands/native.mjs +17 -4
  18. package/lib/cli/commands/peer.mjs +3 -0
  19. package/lib/cli/commands/run.mjs +196 -62
  20. package/lib/cli/commands/tmux.mjs +28 -19
  21. package/lib/cli/commands/up.mjs +25 -17
  22. package/lib/cli-runtime.mjs +16 -1
  23. package/lib/core/coordination/event-retention.mjs +2 -0
  24. package/lib/core/sessions/codex-thread-root.mjs +144 -0
  25. package/lib/core/sessions/launch.mjs +4 -0
  26. package/lib/discover.mjs +95 -231
  27. package/lib/integrations/dsh-collaboration.mjs +9 -2
  28. package/lib/integrations/native/claude.mjs +6 -0
  29. package/lib/integrations/native/codex.mjs +4 -2
  30. package/lib/integrations/native/dsh-acp.mjs +1 -1
  31. package/lib/integrations/native/jsonrpc.mjs +2 -2
  32. package/lib/mcp/scope.mjs +50 -8
  33. package/lib/mcp/tools.mjs +15 -2
  34. package/lib/process/pinned-cwd.mjs +64 -0
  35. package/lib/process/pinned-guidance.mjs +1 -0
  36. package/lib/process/pinned-project-state.mjs +1 -0
  37. package/lib/process/tmux-cwd-handoff.mjs +277 -0
  38. package/lib/runtime/native/cli-submissions.mjs +220 -0
  39. package/lib/runtime/native/client.mjs +7 -1
  40. package/lib/runtime/native/launcher.mjs +20 -4
  41. package/lib/runtime/native/service.mjs +143 -50
  42. package/lib/runtime/native/store.mjs +303 -14
  43. package/lib/runtime/private-state.mjs +435 -24
  44. package/lib/runtime/project-path.mjs +53 -1
  45. package/lib/runtime/project-trust.mjs +6 -2
  46. package/lib/runtime/state-migration.mjs +66 -13
  47. package/lib/shared/redact.mjs +4 -1
  48. package/lib/terminal/tmux.mjs +13 -2
  49. package/lib/ui/help.mjs +1 -0
  50. package/lib/web/codex-app-server.mjs +3 -1
  51. package/lib/web/codex-sessions.mjs +68 -8
  52. package/lib/web/context-files.mjs +194 -41
  53. package/lib/web/http-routes.mjs +43 -12
  54. package/lib/web/native-sessions.mjs +3 -0
  55. package/lib/web/project-contexts.mjs +27 -5
  56. package/lib/web/project-files.mjs +108 -26
  57. package/lib/web/pty-sessions.mjs +15 -2
  58. package/lib/web/session-serialize.mjs +6 -1
  59. package/lib/web/startup.mjs +3 -1
  60. package/lib/web/tmux-sessions.mjs +153 -50
  61. package/lib/web/ui-interactions.mjs +7 -4
  62. package/native/bin/darwin-arm64/hcc-cwd-handoff +0 -0
  63. package/native/bin/darwin-x64/hcc-cwd-handoff +0 -0
  64. package/native/bin/linux-arm64/hcc-cwd-handoff +0 -0
  65. package/native/bin/linux-x64/hcc-cwd-handoff +0 -0
  66. package/native/cwd-handoff/README.md +45 -0
  67. package/native/cwd-handoff/hcc-cwd-handoff.c +693 -0
  68. package/package.json +8 -2
  69. package/scripts/build-cwd-handoff.mjs +47 -0
  70. package/scripts/pack-cwd-handoff.mjs +27 -0
  71. package/scripts/regression.mjs +113 -7
  72. package/scripts/verify-cwd-handoff-package.mjs +60 -0
  73. package/scripts/web-workbench-acceptance.mjs +79 -1
  74. package/scripts/web-workbench-installed-acceptance.mjs +11 -1
@@ -1,23 +1,86 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import process from 'node:process';
3
4
  import { CliError } from '../shared/errors.mjs';
5
+ import { captureSelectedCwdIdentity } from '../process/selected-cwd-identity.mjs';
6
+ import { privateComponent, PROJECT_PRIVATE_COMPONENT_WORKER_SOURCE,
7
+ runPinnedDirectoryWorker } from './project-files.mjs';
4
8
 
5
- const ignored = new Set(['.git', 'node_modules', '.hcc', '.hello-cc', '.codex', '.claude', '.dsh', '.ssh', '.aws', 'dist', 'build', 'coverage', '.next']);
6
- const ignoredName = name => ignored.has(name) || name === '.env' || name.startsWith('.env.');
9
+ const contextIgnored = new Set(['.git', 'node_modules', '.hcc', '.hello-cc', '.codex', '.claude', '.dsh', '.ssh', '.aws', 'dist', 'build', 'coverage', '.next']);
7
10
 
8
- function assertBoundRoot(rootIdentity, stat = null) {
11
+ function changed() {
12
+ return new CliError('PROJECT_PATH_CHANGED', 'Selected project directory changed while listing files');
13
+ }
14
+
15
+ function assertBoundRoot(rootIdentity) {
9
16
  if (!rootIdentity) return;
10
17
  rootIdentity.assertUnchanged();
11
- if (stat && (rootIdentity.identity.dev !== stat.dev.toString() ||
12
- rootIdentity.identity.ino !== stat.ino.toString() ||
13
- rootIdentity.identity.birthtimeNs !== (stat.birthtimeNs?.toString() || null))) {
14
- throw new CliError('PROJECT_PATH_CHANGED', 'Selected project directory changed; select its current location again');
18
+ }
19
+
20
+ function sameDirectory(left, right) {
21
+ return left.isDirectory() && right.isDirectory() && left.dev === right.dev &&
22
+ left.ino === right.ino && left.mode === right.mode && left.size === right.size &&
23
+ left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
24
+ }
25
+
26
+ // Windows cannot use the POSIX pinned helper, so keep the existing read-only
27
+ // traversal there. Its transient ancestor-replacement risk is not closed here.
28
+ async function listContextFilesByPath(root, query, { limit, maxEntries, timeBudgetMs, now, rootIdentity }) {
29
+ assertBoundRoot(rootIdentity);
30
+ const base = await fs.realpath(root), pending = [''], paths = [], needle = query.toLocaleLowerCase();
31
+ const directorySnapshots = [];
32
+ if (rootIdentity) {
33
+ const parent = path.dirname(base);
34
+ directorySnapshots.push({ directory: parent, stat: await fs.lstat(parent, { bigint: true }) });
35
+ const initial = await fs.lstat(base, { bigint: true });
36
+ assertBoundRoot(rootIdentity);
37
+ if (rootIdentity.identity.dev !== initial.dev.toString() ||
38
+ rootIdentity.identity.ino !== initial.ino.toString() ||
39
+ rootIdentity.identity.birthtimeNs !== (initial.birthtimeNs?.toString() || null)) throw changed();
40
+ directorySnapshots.push({ directory: base, stat: initial });
15
41
  }
42
+ const exact = await exactContextFile(base, query, rootIdentity);
43
+ if (exact) paths.push(exact);
44
+ let seen = 0, truncated = false;
45
+ const deadline = now() + timeBudgetMs;
46
+ outer: while (pending.length) {
47
+ if (now() >= deadline || seen >= maxEntries || paths.length >= limit) { truncated = true; break; }
48
+ assertBoundRoot(rootIdentity);
49
+ const relative = pending.shift(), directory = path.join(base, relative);
50
+ try {
51
+ const before = await fs.lstat(directory, { bigint: true });
52
+ if (before.isSymbolicLink() || !before.isDirectory()) continue;
53
+ if (rootIdentity) directorySnapshots.push({ directory, stat: before });
54
+ const resolved = await fs.realpath(directory), fromRoot = path.relative(base, resolved);
55
+ if (fromRoot === '..' || fromRoot.startsWith('..' + path.sep) || path.isAbsolute(fromRoot)) continue;
56
+ const handle = await fs.opendir(directory);
57
+ for await (const item of handle) {
58
+ seen++;
59
+ if (seen > maxEntries || now() >= deadline) { truncated = true; break outer; }
60
+ if (item.isSymbolicLink() || contextIgnored.has(item.name) || privateComponent(item.name) ||
61
+ /[\\:\x00-\x1f\x7f]/.test(item.name)) continue;
62
+ const name = relative ? relative + '/' + item.name : item.name;
63
+ if (item.isDirectory()) pending.push(name);
64
+ else if (item.isFile() && name !== exact && name.toLocaleLowerCase().includes(needle)) {
65
+ paths.push(name);
66
+ if (paths.length >= limit) { truncated = true; break outer; }
67
+ }
68
+ }
69
+ if (rootIdentity && !sameDirectory(before, await fs.lstat(directory, { bigint: true }))) throw changed();
70
+ } catch (error) { if (!['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM'].includes(error.code)) throw error; }
71
+ }
72
+ assertBoundRoot(rootIdentity);
73
+ if (rootIdentity) {
74
+ for (const { directory, stat } of directorySnapshots) {
75
+ if (!sameDirectory(stat, await fs.lstat(directory, { bigint: true }))) throw changed();
76
+ }
77
+ }
78
+ return { paths: paths.sort(), truncated };
16
79
  }
17
80
 
18
81
  async function exactContextFile(base, query, rootIdentity) {
19
82
  const parts = query.split('/');
20
- if (!query || parts.some(part => !part || part === '.' || ignoredName(part))) return null;
83
+ if (!query || parts.some(part => !part || part === '.' || contextIgnored.has(part) || privateComponent(part))) return null;
21
84
  let directory = base;
22
85
  try {
23
86
  for (let index = 0; index < parts.length; index++) {
@@ -26,7 +89,7 @@ async function exactContextFile(base, query, rootIdentity) {
26
89
  if (stat.isSymbolicLink() || (index < parts.length - 1 ? !stat.isDirectory() : !stat.isFile())) return null;
27
90
  const resolved = await fs.realpath(target), relative = path.relative(base, resolved);
28
91
  if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative) ||
29
- relative.split(path.sep).some(ignoredName)) return null;
92
+ relative.split(path.sep).some(part => contextIgnored.has(part) || privateComponent(part))) return null;
30
93
  // Recheck the named entry after resolving it and discard any observed
31
94
  // link or entry replacement before offering the filename.
32
95
  const confirmed = await fs.lstat(target);
@@ -38,6 +101,103 @@ async function exactContextFile(base, query, rootIdentity) {
38
101
  return null;
39
102
  }
40
103
 
104
+ function contextListWorker() {
105
+ const fs = require('node:fs');
106
+ const { query, limit, maxEntries, timeBudgetMs } = JSON.parse(process.argv[1]);
107
+ const deadline = Date.now() + timeBudgetMs;
108
+ const root = fs.statSync('.', { bigint: true });
109
+ const same = (left, right) => left.isDirectory() && right.isDirectory() &&
110
+ left.dev === right.dev && left.ino === right.ino && left.birthtimeNs === right.birthtimeNs;
111
+ const skippable = new Set(['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM', 'ELOOP']);
112
+ const changed = () => { const error = new Error('Directory identity changed'); error.code = 'PROJECT_PATH_CHANGED'; return error; };
113
+
114
+ function leave(held) {
115
+ for (let index = held.length - 1; index >= 0; index--) {
116
+ process.chdir('..');
117
+ const current = fs.statSync('.', { bigint: true });
118
+ const expected = index ? held[index - 1].stat : root;
119
+ fs.closeSync(held[index].fd);
120
+ if (!same(current, expected)) throw changed();
121
+ }
122
+ }
123
+
124
+ function enter(relative) {
125
+ const held = [];
126
+ for (const part of relative ? relative.split('/') : []) {
127
+ let fd;
128
+ try { fd = fs.openSync(part, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW); }
129
+ catch (error) {
130
+ leave(held);
131
+ if (skippable.has(error.code)) return null;
132
+ throw error;
133
+ }
134
+ let stat;
135
+ try { stat = fs.fstatSync(fd, { bigint: true }); }
136
+ catch (error) { fs.closeSync(fd); leave(held); throw error; }
137
+ if (!stat.isDirectory()) { fs.closeSync(fd); leave(held); return null; }
138
+ try { process.chdir(part); }
139
+ catch (error) {
140
+ fs.closeSync(fd); leave(held);
141
+ if (skippable.has(error.code)) return null;
142
+ throw error;
143
+ }
144
+ // A rename between no-follow open and chdir must not let us inspect B.
145
+ if (!same(stat, fs.statSync('.', { bigint: true }))) throw changed();
146
+ held.push({ fd, stat });
147
+ }
148
+ return held;
149
+ }
150
+
151
+ function exactPath() {
152
+ const parts = query.split('/');
153
+ if (!query || parts.some(part => !part || part === '.' || contextIgnored.has(part) || privateComponent(part))) return null;
154
+ const held = enter(parts.slice(0, -1).join('/'));
155
+ if (held === null) return null;
156
+ try {
157
+ const entry = fs.lstatSync(parts.at(-1));
158
+ return entry.isFile() && !entry.isSymbolicLink() ? query : null;
159
+ } catch (error) { if (skippable.has(error.code)) return null; throw error; }
160
+ finally { leave(held); }
161
+ }
162
+
163
+ const pending = [''], paths = [], needle = query.toLocaleLowerCase();
164
+ const exact = exactPath();
165
+ if (exact) paths.push(exact);
166
+ let seen = 0, truncated = false, stop = false;
167
+ try {
168
+ while (pending.length && !stop) {
169
+ if (Date.now() >= deadline || seen >= maxEntries || paths.length >= limit) { truncated = true; break; }
170
+ const relative = pending.shift(), held = enter(relative);
171
+ if (held === null) continue;
172
+ let directory;
173
+ try {
174
+ directory = fs.opendirSync('.');
175
+ for (let item; (item = directory.readSync()) !== null;) {
176
+ seen++;
177
+ if (seen > maxEntries || Date.now() >= deadline) { truncated = true; stop = true; break; }
178
+ if (item.isSymbolicLink() || contextIgnored.has(item.name) || privateComponent(item.name) ||
179
+ /[\\:\x00-\x1f\x7f]/.test(item.name)) continue;
180
+ const name = relative ? relative + '/' + item.name : item.name;
181
+ if (item.isDirectory()) pending.push(name);
182
+ else if (item.isFile() && name !== exact && name.toLocaleLowerCase().includes(needle)) {
183
+ paths.push(name);
184
+ if (paths.length >= limit) { truncated = true; stop = true; break; }
185
+ }
186
+ }
187
+ } catch (error) { if (!skippable.has(error.code)) throw error; }
188
+ finally { directory?.closeSync(); leave(held); }
189
+ }
190
+ process.stdout.write(JSON.stringify({ paths, truncated, exact }));
191
+ } catch (error) {
192
+ process.stderr.write('HCC_DIRECTORY_WORKER_FAILED:' + (error.code || 'PROJECT_PATH_CHANGED') + '\n');
193
+ process.exitCode = 1;
194
+ }
195
+ }
196
+
197
+ const CONTEXT_LIST_WORKER_SOURCE = PROJECT_PRIVATE_COMPONENT_WORKER_SOURCE +
198
+ `const contextIgnored = new Set(${JSON.stringify([...contextIgnored])});\n` +
199
+ `(${contextListWorker.toString()})()`;
200
+
41
201
  /** List bounded project-relative filenames only. Never open or upload their contents. */
42
202
  export async function listContextFiles(root, query = '', { limit = 80, maxEntries = 10000, timeBudgetMs = 150, now = Date.now, rootIdentity = null } = {}) {
43
203
  if (typeof query !== 'string' || query.length > 256 || /[\0\r\n]/.test(query) || path.isAbsolute(query) || query.includes('\\') || query.split('/').includes('..')) {
@@ -46,37 +206,30 @@ export async function listContextFiles(root, query = '', { limit = 80, maxEntrie
46
206
  if (!Number.isSafeInteger(limit) || limit < 1 || !Number.isSafeInteger(maxEntries) || maxEntries < 1 || !Number.isFinite(timeBudgetMs) || timeBudgetMs < 0 || typeof now !== 'function') {
47
207
  throw new TypeError('Invalid context file listing budget');
48
208
  }
49
- assertBoundRoot(rootIdentity);
50
- const base = await fs.realpath(root), pending = [''], paths = [], needle = query.toLocaleLowerCase();
51
- if (rootIdentity) assertBoundRoot(rootIdentity, await fs.lstat(base, { bigint: true }));
52
- // An explicit filename remains usable even if an unrelated directory read
53
- // consumes the scan budget. Keep the bounded substring scan for other hits.
54
- const exact = await exactContextFile(base, query, rootIdentity);
55
- if (exact) paths.push(exact);
56
- let seen = 0, truncated = false;
57
- const deadline = now() + timeBudgetMs;
58
- outer: while (pending.length) {
59
- if (now() >= deadline || seen >= maxEntries || paths.length >= limit) { truncated = true; break; }
60
- assertBoundRoot(rootIdentity);
61
- const relative = pending.shift(), directory = path.join(base, relative);
209
+ if (process.platform === 'win32') return listContextFilesByPath(root, query,
210
+ { limit, maxEntries, timeBudgetMs, now, rootIdentity });
211
+ const ownedIdentity = rootIdentity ? null : captureSelectedCwdIdentity(root);
212
+ const identity = rootIdentity || ownedIdentity;
213
+ try {
214
+ assertBoundRoot(identity);
215
+ let result;
62
216
  try {
63
- if ((await fs.lstat(directory)).isSymbolicLink()) continue;
64
- const resolved = await fs.realpath(directory), fromRoot = path.relative(base, resolved);
65
- if (fromRoot === '..' || fromRoot.startsWith('..' + path.sep) || path.isAbsolute(fromRoot)) continue;
66
- const handle = await fs.opendir(directory);
67
- for await (const item of handle) {
68
- seen++;
69
- if (seen > maxEntries || now() >= deadline) { truncated = true; break outer; }
70
- if (item.isSymbolicLink() || ignoredName(item.name)) continue;
71
- const name = relative ? relative + '/' + item.name : item.name;
72
- if (item.isDirectory()) pending.push(name);
73
- else if (item.isFile() && name.toLocaleLowerCase().includes(needle) && name !== exact) {
74
- paths.push(name);
75
- if (paths.length >= limit) { truncated = true; break outer; }
76
- }
77
- }
78
- } catch (error) { if (!['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM'].includes(error.code)) throw error; }
79
- }
80
- assertBoundRoot(rootIdentity);
81
- return { paths: paths.sort(), truncated };
217
+ result = await runPinnedDirectoryWorker(identity.canonical, identity,
218
+ CONTEXT_LIST_WORKER_SOURCE, { query, limit, maxEntries, timeBudgetMs });
219
+ } catch (error) {
220
+ if (['PROJECT_FILE_CHANGED', 'PROJECT_PATH_CHANGED'].includes(error.code)) throw changed();
221
+ throw error;
222
+ }
223
+ assertBoundRoot(identity);
224
+ const paths = [];
225
+ // Apply the listing budget inside the pinned worker; bootstrap latency
226
+ // must not discard names that the bounded scan already found.
227
+ const deadline = now() + timeBudgetMs;
228
+ let truncated = result.truncated;
229
+ for (const file of result.paths) {
230
+ if (file !== result.exact && now() >= deadline) { truncated = true; break; }
231
+ paths.push(file);
232
+ }
233
+ return { paths: paths.sort(), truncated };
234
+ } finally { ownedIdentity?.release(); }
82
235
  }
@@ -13,8 +13,7 @@ import { createAgentDefaults } from './agent-defaults.mjs';
13
13
  import { listProjectFiles, previewProjectFile, inspectProjectFileStatus, projectFileErrorStatus } from './project-files.mjs';
14
14
  import { saveProjectFile, uploadProjectFile } from './project-file-write.mjs';
15
15
  import { projectRecord } from '../runtime/projects.mjs';
16
- import { projectDbPath } from '../runtime/paths.mjs';
17
- import { resolveProjectDatabase } from '../runtime/project-path.mjs';
16
+ import { prevalidateProjectDatabaseLocation } from '../runtime/project-path.mjs';
18
17
  import { writeRuntime } from '../runtime/state.mjs';
19
18
  import { normalizeStateResources } from '../ui/state-render.mjs';
20
19
  import { requestUrl, runtimeBaseUrl } from '../web/runtime.mjs';
@@ -31,7 +30,7 @@ import { scheduleResponseShutdown } from './shutdown-response.mjs';
31
30
  // even when the dispatcher ultimately returns 404. Null means the matching
32
31
  // dispatcher branch handles its own method error.
33
32
  const projectApiPaths = new Map([
34
- ['/api/agent-defaults', null],
33
+ ['/api/agent-defaults', ['GET', 'PUT']],
35
34
  ['/api/files/tree', ['GET']], ['/api/files/preview', ['GET']],
36
35
  ['/api/files/status', ['GET']], ['/api/files/content', ['PUT']], ['/api/files/upload', ['POST']],
37
36
  ['/api/context/files', ['GET']],
@@ -41,13 +40,18 @@ const projectApiPaths = new Map([
41
40
  ['/api/sessions', ['GET', 'POST']], ['/api/sessions/attach', ['POST']],
42
41
  ['/api/native/history', ['GET']], ['/api/codex/threads', ['GET']]
43
42
  ]);
43
+ const peerReadActions = new Set(['status', 'state', 'inbox']);
44
+ const peerWriteActions = new Set([
45
+ 'task-next', 'task-takeover', 'lock-acquire', 'lock-release', 'heartbeat', 'register'
46
+ ]);
44
47
  const projectApiPatterns = [
45
- [/^\/api\/peers\/[^/]+\/actions\/[^/]+$/, null],
46
- [/^\/api\/native\/history\/[^/]+(?:\/resume)?$/, null],
48
+ [/^\/api\/native\/history\/[^/]+$/, ['GET']],
49
+ [/^\/api\/native\/history\/[^/]+\/resume$/, ['POST']],
47
50
  [/^\/api\/sessions\/[^/]+\/native\/([^/]+)$/, null, {
48
51
  GET: ['state', 'account'], POST: ['send', 'interrupt', 'close', 'respond']
49
52
  }],
50
- [/^\/api\/codex\/threads\/[^/]+(?:\/fork)?$/, null],
53
+ [/^\/api\/codex\/threads\/[^/]+$/, ['GET']],
54
+ [/^\/api\/codex\/threads\/[^/]+\/fork$/, ['POST']],
51
55
  [/^\/api\/sessions\/[^/]+\/results$/, ['GET', 'POST']],
52
56
  [/^\/api\/sessions\/[^/]+\/codex\/([^/]+)$/, null, {
53
57
  GET: ['state', 'read', 'account'], POST: ['turn', 'steer', 'interrupt', 'approve']
@@ -56,11 +60,25 @@ const projectApiPatterns = [
56
60
  [/^\/api\/detected\/[^/]+\/(?:msg|stop|restart)$/, ['POST']]
57
61
  ];
58
62
 
63
+ function peerApiActionKind(pathname) {
64
+ const match = pathname.match(/^\/api\/peers\/[^/]+\/actions\/([^/]+)$/);
65
+ if (!match) return null;
66
+ try {
67
+ const action = decodeURIComponent(match[1]).replace(/_/g, '-');
68
+ if (peerReadActions.has(action)) return 'read';
69
+ if (peerWriteActions.has(action)) return 'write';
70
+ } catch {}
71
+ return 'unknown';
72
+ }
73
+
59
74
  function projectApiRouteExists(method, pathname) {
60
75
  if (projectApiPaths.has(pathname)) {
61
76
  const methods = projectApiPaths.get(pathname);
62
77
  return methods === null || methods.includes(method);
63
78
  }
79
+ const peerAction = peerApiActionKind(pathname);
80
+ if (peerAction !== null) return peerAction === 'read' ? method === 'GET' :
81
+ peerAction === 'write' && method === 'POST';
64
82
  return projectApiPatterns.some(([pattern, methods, actions]) => {
65
83
  const match = pathname.match(pattern);
66
84
  if (!match || methods !== null && !methods.includes(method)) return false;
@@ -240,10 +258,23 @@ export function createHttpRoutes(deps) {
240
258
  sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Route not found' } });
241
259
  return;
242
260
  }
243
- if (!projectApiRouteExists(req.method, url.pathname)) {
261
+ // Dynamic IDs can be malformed even when the route shape and action
262
+ // match. Validate the whole pathname before resolving a project.
263
+ let pathEncodingValid = true;
264
+ try { decodeURIComponent(url.pathname); } catch { pathEncodingValid = false; }
265
+ if (!pathEncodingValid || !projectApiRouteExists(req.method, url.pathname)) {
266
+ const peerAction = pathEncodingValid ? peerApiActionKind(url.pathname) : null;
267
+ if (peerAction === 'read' || peerAction === 'write') {
268
+ sendJson(res, 405, { ok: false, error: { code: 'METHOD_NOT_ALLOWED',
269
+ message: peerAction === 'read' ? 'Use GET for read-only peer actions' : 'Use POST for mutating peer actions' } });
270
+ return;
271
+ }
272
+ if (url.pathname === '/api/agent-defaults') {
273
+ throw new CliError('BAD_REQUEST', 'Agent defaults support GET and PUT only');
274
+ }
244
275
  // The legacy Codex account-control POSTs have a stable BAD_REQUEST
245
276
  // contract. Reject them here without resolving an arbitrary project.
246
- if (legacyForbiddenCodexAction(req.method, url.pathname)) {
277
+ if (pathEncodingValid && legacyForbiddenCodexAction(req.method, url.pathname)) {
247
278
  throw new CliError('BAD_REQUEST', 'Unsupported Codex action');
248
279
  }
249
280
  sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Route not found' } });
@@ -259,11 +290,10 @@ export function createHttpRoutes(deps) {
259
290
  // Reject an outside DB before resolving the body-selected root, which
260
291
  // can create its managed state directory and record registry activity.
261
292
  if (projectInput?.db) {
262
- resolveProjectDatabase({
293
+ prevalidateProjectDatabaseLocation({
263
294
  root: projectInput.root || url.searchParams.get('root') ||
264
295
  url.searchParams.get('project') || req.headers['x-hcc-root'] || ctx.root,
265
- db: projectInput.db,
266
- createStateDir: false
296
+ db: projectInput.db
267
297
  });
268
298
  }
269
299
  const projectUrl = projectInput?.root && !url.searchParams.has('root') &&
@@ -325,7 +355,8 @@ export function createHttpRoutes(deps) {
325
355
  const requestedRoot = input.root || reqCtx.root;
326
356
  const selected = resolveWebProjectContext(
327
357
  requestedRoot,
328
- input.db || projectDbPath(requestedRoot)
358
+ input.db || null,
359
+ { explicitSelection: true }
329
360
  );
330
361
  let projectCtx;
331
362
  try {
@@ -11,6 +11,7 @@ import { ensureNativeRuntime } from '../runtime/native/launcher.mjs';
11
11
  import { readNativeHistory, readNativeWorkerHistory } from '../runtime/native/history.mjs';
12
12
  import { parseNativeCwdIdentity } from '../runtime/native/store.mjs';
13
13
  import { captureSelectedCwdSnapshot, sameSelectedCwdIdentity } from '../process/selected-cwd-identity.mjs';
14
+ import { assertPinnedSessionLaunchAllowed } from '../process/pinned-cwd.mjs';
14
15
  import { createAgentDefaults } from './agent-defaults.mjs';
15
16
 
16
17
  const PROVIDERS = new Set(['codex', 'claude', 'dsh']);
@@ -148,6 +149,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
148
149
  typeof input.sessionId !== 'string' || !input.sessionId || input.sessionId.length > 512) {
149
150
  throw new CliError('BAD_REQUEST', 'Restore requires confirmed:true and the saved owner and sessionId');
150
151
  }
152
+ assertPinnedSessionLaunchAllowed();
151
153
  const key = sessionKey(ctx, peer);
152
154
  if (stopped) throw new CliError('NATIVE_WEB_DISCONNECTED', 'Web is stopping');
153
155
  if (creating.has(key)) throw new CliError('NATIVE_WORKER_EXISTS', 'This worker is already being opened', { peer });
@@ -206,6 +208,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
206
208
  input.transport !== 'native' || (input.kind !== undefined && !PROVIDERS.has(input.kind))) {
207
209
  throw new CliError('BAD_REQUEST', 'Native creation accepts only transport, kind, id, cwd and model; use provider codex, claude or dsh');
208
210
  }
211
+ assertPinnedSessionLaunchAllowed();
209
212
  const ctx = input.projectCtx;
210
213
  // Resolve before asynchronous daemon startup; explicit null clears a saved model.
211
214
  input = resolveNativeDefaults(ctx, input);
@@ -8,7 +8,8 @@ import path from 'node:path';
8
8
  import WebSocket from 'ws';
9
9
  import { CliError } from '../shared/errors.mjs';
10
10
  import { contextForProject, projectDbPath } from '../runtime/paths.mjs';
11
- import { resolveProjectDatabase } from '../runtime/project-path.mjs';
11
+ import { prevalidateProjectDatabaseLocation, resolveProjectDatabase } from '../runtime/project-path.mjs';
12
+ import { provisionPrivateProjectGeneration } from '../runtime/private-state.mjs';
12
13
  import { assertSelectedCwdSnapshot, captureSelectedCwdIdentity, sameSelectedCwdIdentity } from '../process/selected-cwd-identity.mjs';
13
14
  import {
14
15
  projectRecord,
@@ -33,6 +34,7 @@ export function createProjectContexts(deps) {
33
34
  } = deps;
34
35
 
35
36
  const projectContexts = new Map();
37
+ const retiredProjectContexts = new Set();
36
38
  const MAX_IDLE_PROJECT_DESCRIPTORS = 32;
37
39
 
38
40
  function changedProject() {
@@ -79,6 +81,11 @@ export function createProjectContexts(deps) {
79
81
  retainedIdle += 1;
80
82
  if (retainedIdle > maxIdle) binding.dropDescriptor();
81
83
  }
84
+ for (const project of retiredProjectContexts) {
85
+ if (hasLiveManagedSession(project.rootIdentity)) continue;
86
+ project.rootIdentity?.release();
87
+ retiredProjectContexts.delete(project);
88
+ }
82
89
  }
83
90
 
84
91
  function retainProjectContextDescriptor(projectCtx) {
@@ -156,7 +163,10 @@ export function createProjectContexts(deps) {
156
163
  dropIdleProjectContextDescriptors(retained);
157
164
  return existing;
158
165
  }
159
- if (previous && previous !== retained) previous.release();
166
+ if (previous && previous !== retained) {
167
+ if (hasLiveManagedSession(previous)) retiredProjectContexts.add(existing);
168
+ else previous.release();
169
+ }
160
170
  projectContexts.set(normalized.root, normalized);
161
171
  dropIdleProjectContextDescriptors(retained);
162
172
  return normalized;
@@ -185,7 +195,7 @@ export function createProjectContexts(deps) {
185
195
  return rows;
186
196
  }
187
197
 
188
- function resolveWebProjectContext(root, db) {
198
+ function resolveWebProjectContext(root, db, { explicitSelection = false } = {}) {
189
199
  const selected = captureSelectedCwdIdentity(root);
190
200
  let binding = selected;
191
201
  try {
@@ -194,6 +204,16 @@ export function createProjectContexts(deps) {
194
204
  // pathname. The alias must still be unchanged throughout selection.
195
205
  if (selected.requested !== selected.canonical) binding = captureSelectedCwdIdentity(selected.canonical);
196
206
  selected.assertUnchanged();
207
+ if (db) {
208
+ prevalidateProjectDatabaseLocation({ root: selected.requested, db });
209
+ selected.assertUnchanged();
210
+ binding.assertUnchanged();
211
+ }
212
+ if (explicitSelection) {
213
+ provisionPrivateProjectGeneration(binding.canonical, { expectedIdentity: binding });
214
+ selected.assertUnchanged();
215
+ binding.assertUnchanged();
216
+ }
197
217
  // Preserve explicit legacy DB spellings under a selected alias while
198
218
  // resolving state against the captured canonical directory only.
199
219
  const selectedLegacy = path.join(selected.requested, '.hello-cc');
@@ -202,7 +222,7 @@ export function createProjectContexts(deps) {
202
222
  !relativeDb.startsWith(`..${path.sep}`) && !path.isAbsolute(relativeDb);
203
223
  const stableDb = underSelectedLegacy
204
224
  ? path.join(binding.canonical, '.hello-cc', relativeDb)
205
- : db;
225
+ : db || projectDbPath(binding.canonical);
206
226
  const first = resolveProjectDatabase({ root: binding.canonical, db: stableDb, createStateDir: true });
207
227
  selected.assertUnchanged();
208
228
  binding.assertUnchanged();
@@ -281,7 +301,7 @@ export function createProjectContexts(deps) {
281
301
  }
282
302
  carried = captureSelectedCwdIdentity(requestedRoot, expected);
283
303
  }
284
- resolved = resolveWebProjectContext(requestedRoot, requestedDb || projectDbPath(requestedRoot));
304
+ resolved = resolveWebProjectContext(requestedRoot, requestedDb, { explicitSelection });
285
305
  if (carried && !sameSelectedCwdIdentity(carried, resolved.rootIdentity)) throw changedProject();
286
306
  const remembered = rememberProject(resolved, { activity: true });
287
307
  resolved = null;
@@ -346,7 +366,9 @@ export function createProjectContexts(deps) {
346
366
 
347
367
  function releaseProjectContexts() {
348
368
  for (const project of projectContexts.values()) project.rootIdentity?.release();
369
+ for (const project of retiredProjectContexts) project.rootIdentity?.release();
349
370
  projectContexts.clear();
371
+ retiredProjectContexts.clear();
350
372
  }
351
373
 
352
374
  function knownPeerIds(projectCtx) {