@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,268 @@
1
+ import fs from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
4
+ import path from 'node:path';
5
+ import { TextDecoder } from 'node:util';
6
+ import { CliError } from '../shared/errors.mjs';
7
+
8
+ export const PROJECT_FILE_LIMITS = Object.freeze({ entries: 500, scannedEntries: 5000,
9
+ textBytes: 1024 * 1024, mediaBytes: 10 * 1024 * 1024, sniffBytes: 4096 });
10
+
11
+ const ignored = new Set(['.git', '.hg', '.svn', '.hello-cc', '.hcc', '.codex', '.claude', '.dsh',
12
+ '.ssh', '.aws', '.azure', '.gcloud', '.gnupg', '.config', '.local', '.docker', '.kube', '.terraform',
13
+ 'node_modules', '.cache', '.npm', '.pnpm-store', '.yarn', '.next', '.nuxt', '.turbo', '.parcel-cache',
14
+ 'coverage', '__pycache__', '.venv', 'venv', '.pytest_cache', '.mypy_cache', '.ruff_cache', '.tox']);
15
+ const privateNames = new Set(['.envrc', '.npmrc', '.pypirc', '.netrc', '.git-credentials', '.gitconfig',
16
+ '.boto', '.bash_history', '.zsh_history', '.python_history', 'auth.json', 'accounts.json']);
17
+ const textExtensions = new Set(['.txt', '.log', '.json', '.jsonl', '.yaml', '.yml', '.toml', '.ini', '.cfg',
18
+ '.conf', '.xml', '.csv', '.tsv', '.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.css', '.scss', '.sass',
19
+ '.less', '.py', '.rb', '.go', '.rs', '.c', '.h', '.cc', '.cpp', '.hpp', '.java', '.kt', '.swift', '.sh',
20
+ '.bash', '.zsh', '.fish', '.ps1', '.sql', '.graphql', '.gql', '.vue', '.svelte', '.mdx', '.svg', '.tex',
21
+ '.rst', '.diff', '.patch', '.lock', '.properties', '.gradle', '.cmake']);
22
+ const markdownExtensions = new Set(['.md', '.markdown', '.mdown']);
23
+ const htmlExtensions = new Set(['.html', '.htm']);
24
+ const media = new Map([['.png', 'image/png'], ['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'],
25
+ ['.gif', 'image/gif'], ['.webp', 'image/webp'], ['.pdf', 'application/pdf']]);
26
+ const errorStatus = new Map([['PROJECT_FILE_BAD_PATH', 400], ['PROJECT_FILE_FORBIDDEN', 403],
27
+ ['PROJECT_FILE_NOT_FOUND', 404], ['PROJECT_FILE_CHANGED', 409], ['PROJECT_FILE_TOO_LARGE', 413],
28
+ ['PROJECT_FILE_CONFLICT', 409], ['PROJECT_FILE_EXISTS', 409],
29
+ ['PROJECT_FILE_WRITE_UNCONFIRMED', 503], ['PROJECT_FILE_WRITE_UNAVAILABLE', 503],
30
+ ['PROJECT_FILE_NOT_DIRECTORY', 400], ['PROJECT_FILE_NOT_REGULAR', 400], ['PROJECT_FILE_UNAVAILABLE', 500]]);
31
+
32
+ export function projectFileErrorStatus(error) { return errorStatus.get(error?.code); }
33
+
34
+ function privateComponent(name) {
35
+ const lower = name.toLowerCase();
36
+ return ignored.has(lower) || privateNames.has(lower) || /^\.hcc-file-write-/.test(lower) || /^(?:\.?env)(?:[.-]|$)/.test(lower) ||
37
+ /\.env(?:[.-]|$)/.test(lower) ||
38
+ /(?:^|[._-])(?:secrets?|credentials?|tokens?)(?:[._-]|$)/.test(lower) ||
39
+ /^(?:id_(?:rsa|dsa|ecdsa|ed25519)|terraform\.tfstate)(?:[.-]|$)/.test(lower) ||
40
+ /^service[-_]account(?:[._-]|$)/.test(lower) || /\.(?:pem|key|p12|pfx|jks|keystore|kdbx|ovpn)(?:[.-]|$)/.test(lower);
41
+ }
42
+
43
+ function relativePath(value, allowRoot) {
44
+ if (typeof value !== 'string' || value.length > 4096 || /[\\:\x00-\x1f\x7f]/.test(value) ||
45
+ path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) {
46
+ throw new CliError('PROJECT_FILE_BAD_PATH', 'Use a project-relative path with forward slashes');
47
+ }
48
+ if (allowRoot && value === '') return [];
49
+ const parts = value.split('/');
50
+ if (parts.length > 128 || parts.some(part => !part || part === '.' || part === '..')) {
51
+ throw new CliError('PROJECT_FILE_BAD_PATH', 'Use a project-relative path without traversal or empty components');
52
+ }
53
+ if (parts.some(privateComponent)) throw new CliError('PROJECT_FILE_FORBIDDEN', 'This private or cache path is excluded from project previews');
54
+ return parts;
55
+ }
56
+
57
+ export function projectFilePathParts(value, allowRoot) { return relativePath(value, allowRoot); }
58
+
59
+ export function isProjectTextPath(name) {
60
+ const extension = path.extname(name).toLowerCase();
61
+ return !extension || textExtensions.has(extension) || markdownExtensions.has(extension) || htmlExtensions.has(extension);
62
+ }
63
+
64
+ export function projectContentRevision(stat, bytes) {
65
+ const identity = [stat.dev, stat.ino, stat.mode, stat.size, stat.mtimeNs, stat.ctimeNs, stat.birthtimeNs, stat.nlink]
66
+ .map(value => value === null || value === undefined ? null : String(value));
67
+ return 'v1-' + createHash('sha256').update(JSON.stringify(identity)).update('\0').update(bytes).digest('hex');
68
+ }
69
+
70
+ function contentHash(bytes) { return createHash('sha256').update(bytes).digest('hex'); }
71
+
72
+ function sameIdentity(a, b) {
73
+ return a.dev === b.dev && a.ino === b.ino && a.mode === b.mode && a.size === b.size &&
74
+ a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs && a.nlink === b.nlink;
75
+ }
76
+
77
+ function changed() { return new CliError('PROJECT_FILE_CHANGED', 'The project path changed while being read; refresh and try again'); }
78
+
79
+ function assertBoundRoot(rootIdentity, stat = null) {
80
+ if (!rootIdentity) return;
81
+ rootIdentity.assertUnchanged();
82
+ if (stat && (rootIdentity.identity.dev !== stat.dev.toString() ||
83
+ rootIdentity.identity.ino !== stat.ino.toString() ||
84
+ rootIdentity.identity.birthtimeNs !== (stat.birthtimeNs?.toString() || null))) {
85
+ throw new CliError('PROJECT_PATH_CHANGED', 'Selected project directory changed; select its current location again');
86
+ }
87
+ }
88
+
89
+ function publicError(error) {
90
+ if (error instanceof CliError) return error;
91
+ if (['ENOENT', 'ENOTDIR'].includes(error.code)) return new CliError('PROJECT_FILE_NOT_FOUND', 'The requested project path was not found');
92
+ if (['EACCES', 'EPERM', 'ELOOP'].includes(error.code)) return new CliError('PROJECT_FILE_FORBIDDEN', 'The requested project path cannot be previewed');
93
+ return new CliError('PROJECT_FILE_UNAVAILABLE', 'The requested project path could not be read');
94
+ }
95
+
96
+ async function inspectPath(root, parts, directory, rootIdentity) {
97
+ assertBoundRoot(rootIdentity);
98
+ const base = await fs.realpath(root), chain = [];
99
+ let absolute = base;
100
+ for (let index = 0; index <= parts.length; index++) {
101
+ if (index) absolute = path.join(absolute, parts[index - 1]);
102
+ const stat = await fs.lstat(absolute, { bigint: true });
103
+ if (!index) assertBoundRoot(rootIdentity, stat);
104
+ if (stat.isSymbolicLink()) throw new CliError('PROJECT_FILE_FORBIDDEN', 'Symbolic links are excluded from project previews');
105
+ const needsDirectory = index < parts.length || directory;
106
+ if (needsDirectory && !stat.isDirectory()) throw new CliError('PROJECT_FILE_NOT_DIRECTORY', 'The requested project path is not a directory');
107
+ if (!needsDirectory && (!stat.isFile() || stat.nlink !== 1n)) {
108
+ throw new CliError('PROJECT_FILE_FORBIDDEN', 'Only ordinary files with one link can be previewed');
109
+ }
110
+ chain.push({ absolute, stat });
111
+ }
112
+ if (await fs.realpath(absolute) !== absolute) throw changed();
113
+ return { root, base, absolute, chain, stat: chain.at(-1).stat, rootIdentity };
114
+ }
115
+
116
+ async function verifyPath(snapshot, handle) {
117
+ assertBoundRoot(snapshot.rootIdentity, snapshot.chain[0].stat);
118
+ if (await fs.realpath(snapshot.root) !== snapshot.base || await fs.realpath(snapshot.absolute) !== snapshot.absolute) throw changed();
119
+ for (const item of snapshot.chain) {
120
+ const current = await fs.lstat(item.absolute, { bigint: true });
121
+ if (current.isSymbolicLink() || !sameIdentity(current, item.stat)) throw changed();
122
+ }
123
+ if (!sameIdentity(await handle.stat({ bigint: true }), snapshot.stat)) throw changed();
124
+ assertBoundRoot(snapshot.rootIdentity, snapshot.chain[0].stat);
125
+ }
126
+
127
+ export async function verifyProjectFileParent(snapshot, handle) { await verifyPath(snapshot, handle); }
128
+
129
+ /** Caller owns the returned parent FD and must close it after its operation. */
130
+ export async function prepareProjectFileParent(root, relative, { rootIdentity } = {}) {
131
+ try {
132
+ const parts = relativePath(relative, false);
133
+ const snapshot = await inspectPath(root, parts.slice(0, -1), true, rootIdentity);
134
+ const handle = await openChecked(snapshot, true);
135
+ return { snapshot, handle, name: parts.at(-1) };
136
+ } catch (error) { throw publicError(error); }
137
+ }
138
+
139
+ async function openChecked(snapshot, directory) {
140
+ const handle = await fs.open(snapshot.absolute, constants.O_RDONLY | constants.O_NOFOLLOW |
141
+ constants.O_NONBLOCK | (directory ? constants.O_DIRECTORY : 0));
142
+ try {
143
+ const stat = await handle.stat({ bigint: true });
144
+ if (!sameIdentity(stat, snapshot.stat) || (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1n)) throw changed();
145
+ await verifyPath(snapshot, handle);
146
+ return handle;
147
+ } catch (error) { await handle.close(); throw error; }
148
+ }
149
+
150
+ /** Single-level listing only. No project module or executable is loaded. */
151
+ export async function listProjectFiles(root, relative = '', { rootIdentity } = {}) {
152
+ let handle, directory;
153
+ try {
154
+ const parts = relativePath(relative, true), snapshot = await inspectPath(root, parts, true, rootIdentity);
155
+ handle = await openChecked(snapshot, true);
156
+ directory = await fs.opendir(snapshot.absolute);
157
+ const entries = [];
158
+ let scanned = 0, truncated = false;
159
+ for await (const item of directory) {
160
+ if (++scanned > PROJECT_FILE_LIMITS.scannedEntries) { truncated = true; break; }
161
+ if (privateComponent(item.name) || /[\\:\x00-\x1f\x7f]/.test(item.name) || item.isSymbolicLink()) continue;
162
+ if (!item.isDirectory() && !item.isFile()) continue;
163
+ let stat;
164
+ try { stat = await fs.lstat(path.join(snapshot.absolute, item.name), { bigint: true }); }
165
+ catch (error) { if (error.code === 'ENOENT') throw changed(); throw error; }
166
+ if (stat.isSymbolicLink() || (!stat.isDirectory() && (!stat.isFile() || stat.nlink !== 1n))) continue;
167
+ const entry = { name: item.name, path: relative ? relative + '/' + item.name : item.name,
168
+ type: stat.isDirectory() ? 'directory' : 'file' };
169
+ if (stat.isFile()) entry.size = Number(stat.size);
170
+ entries.push(entry);
171
+ }
172
+ directory = null; // for-await closes the handle on completion and break.
173
+ await verifyPath(snapshot, handle);
174
+ assertBoundRoot(rootIdentity, snapshot.chain[0].stat);
175
+ entries.sort((a, b) => a.type === b.type ? (a.name < b.name ? -1 : a.name > b.name ? 1 : 0) : a.type === 'directory' ? -1 : 1);
176
+ if (entries.length > PROJECT_FILE_LIMITS.entries) truncated = true;
177
+ return { path: relative, entries: entries.slice(0, PROJECT_FILE_LIMITS.entries), truncated };
178
+ } catch (error) { throw publicError(error); }
179
+ finally { await directory?.close().catch(() => {}); await handle?.close().catch(() => {}); }
180
+ }
181
+
182
+ async function readBytes(handle, length) {
183
+ const buffer = Buffer.alloc(length);
184
+ let offset = 0;
185
+ while (offset < length) {
186
+ const { bytesRead } = await handle.read(buffer, offset, length - offset, offset);
187
+ if (!bytesRead) throw changed();
188
+ offset += bytesRead;
189
+ }
190
+ return buffer;
191
+ }
192
+
193
+ function textContent(bytes, truncated) {
194
+ try {
195
+ // Streaming decode deliberately withholds an incomplete final UTF-8 code
196
+ // point when the file exceeds the byte budget.
197
+ const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes, { stream: truncated });
198
+ if (/[\x00-\x08\x0b\x0e-\x1f\x7f]/.test(text)) return null;
199
+ return text;
200
+ } catch { return null; }
201
+ }
202
+
203
+ export function projectTextContent(bytes, truncated) { return textContent(bytes, truncated); }
204
+
205
+ function newlineStyle(text) {
206
+ if (!/[\r\n]/.test(text)) return 'none';
207
+ const withoutCrlf = text.replace(/\r\n/g, '');
208
+ const hasBareNewline = /[\r\n]/.test(withoutCrlf);
209
+ if (text.includes('\r\n')) return hasBareNewline ? 'mixed' : 'crlf';
210
+ return text.includes('\r') ? 'mixed' : 'lf';
211
+ }
212
+
213
+ function validMedia(bytes, mime) {
214
+ if (mime === 'image/png') return bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
215
+ if (mime === 'image/jpeg') return bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255;
216
+ if (mime === 'image/gif') return ['GIF87a', 'GIF89a'].includes(bytes.subarray(0, 6).toString('ascii'));
217
+ if (mime === 'image/webp') return bytes.length >= 12 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP';
218
+ return mime === 'application/pdf' && /^%PDF-[12]\.\d/.test(bytes.subarray(0, 8).toString('ascii'));
219
+ }
220
+
221
+ /** Content is returned only as JSON data; callers must isolate HTML/media. */
222
+ export async function previewProjectFile(root, relative, { rootIdentity } = {}) {
223
+ let handle;
224
+ try {
225
+ const parts = relativePath(relative, false), snapshot = await inspectPath(root, parts, false, rootIdentity);
226
+ handle = await openChecked(snapshot, false);
227
+ const name = parts.at(-1), size = Number(snapshot.stat.size), extension = path.extname(name).toLowerCase();
228
+ const result = { path: relative, name, size, kind: 'unsupported', mime: 'application/octet-stream', encoding: 'base64', content: '', editable: false };
229
+ const mime = media.get(extension);
230
+ if (mime) {
231
+ if (size > PROJECT_FILE_LIMITS.mediaBytes) throw new CliError('PROJECT_FILE_TOO_LARGE', 'Image and PDF previews are limited to 10 MiB');
232
+ const bytes = await readBytes(handle, size);
233
+ if (validMedia(bytes, mime)) Object.assign(result, { kind: mime === 'application/pdf' ? 'pdf' : 'image', mime, content: bytes.toString('base64') });
234
+ } else if (isProjectTextPath(name)) {
235
+ const prefix = await readBytes(handle, Math.min(size, PROJECT_FILE_LIMITS.sniffBytes));
236
+ if (textContent(prefix, size > prefix.length) !== null) {
237
+ const bytes = size <= prefix.length ? prefix : await readBytes(handle, Math.min(size, PROJECT_FILE_LIMITS.textBytes));
238
+ const truncated = size > bytes.length, content = textContent(bytes, truncated);
239
+ if (content !== null) {
240
+ Object.assign(result, { kind: markdownExtensions.has(extension) ? 'markdown' : htmlExtensions.has(extension) ? 'html' : 'text',
241
+ mime: markdownExtensions.has(extension) ? 'text/markdown' : htmlExtensions.has(extension) ? 'text/html' : 'text/plain',
242
+ encoding: 'utf8', content, truncated });
243
+ if (!truncated) Object.assign(result, { editable: true, revision: projectContentRevision(snapshot.stat, bytes),
244
+ contentHash: contentHash(bytes), bom: content.startsWith('\ufeff'), newline: newlineStyle(content) });
245
+ }
246
+ }
247
+ }
248
+ await verifyPath(snapshot, handle);
249
+ assertBoundRoot(rootIdentity, snapshot.chain[0].stat);
250
+ return result;
251
+ } catch (error) { throw publicError(error); }
252
+ finally { await handle?.close().catch(() => {}); }
253
+ }
254
+
255
+ /** A bounded exact-byte status response; it never returns file content. */
256
+ export async function inspectProjectFileStatus(root, relative, { rootIdentity } = {}) {
257
+ let handle;
258
+ try {
259
+ const parts = relativePath(relative, false), snapshot = await inspectPath(root, parts, false, rootIdentity);
260
+ handle = await openChecked(snapshot, false);
261
+ const size = Number(snapshot.stat.size);
262
+ if (size > PROJECT_FILE_LIMITS.mediaBytes) throw new CliError('PROJECT_FILE_TOO_LARGE', 'File status reads are limited to 10 MiB');
263
+ const bytes = await readBytes(handle, size);
264
+ await verifyPath(snapshot, handle);
265
+ return { path: relative, size, contentHash: contentHash(bytes) };
266
+ } catch (error) { throw publicError(error); }
267
+ finally { await handle?.close().catch(() => {}); }
268
+ }
@@ -15,10 +15,12 @@ import { providerSessionPeerId } from '../core/peers/session.mjs';
15
15
  import { childSessionEnv } from '../core/sessions/launch.mjs';
16
16
  import { detectBranch } from '../project-context.mjs';
17
17
  import { tx } from '../db/schema.mjs';
18
+ import { spawnPinnedCwdPty } from '../process/pinned-cwd.mjs';
19
+ import { captureSelectedCwdSnapshot } from '../process/selected-cwd-identity.mjs';
18
20
 
19
21
  export function createPtySessions(deps) {
20
22
  const {
21
- pty, ctx, sessions,
23
+ pty, ctx, sessions, isStopping = () => false,
22
24
  broadcast, closeSessionClients, sessionKey,
23
25
  connectWebProject, nextProjectSessionId,
24
26
  startTmuxManagedSession,
@@ -40,6 +42,7 @@ export function createPtySessions(deps) {
40
42
  }
41
43
 
42
44
  function startPtySession(input) {
45
+ if (isStopping()) throw new CliError('RUNTIME_STOPPING', 'Web runtime is stopping');
43
46
  const pctx = input.projectCtx || ctx;
44
47
  const kind = input.kind || 'shell';
45
48
  const id = input.id || nextProjectSessionId(pctx, kind);
@@ -54,6 +57,9 @@ export function createPtySessions(deps) {
54
57
  const role = input.role || 'peer';
55
58
  const command = input.command || defaultSessionCommand(kind);
56
59
  const cwd = path.resolve(input.cwd || pctx.root);
60
+ const expectedCwdIdentity = input.cwdIdentity ||
61
+ (cwd === pctx.root ? pctx.rootIdentity || pctx.initialRootIdentity : null) ||
62
+ captureSelectedCwdSnapshot(cwd);
57
63
  const callerEnv = input.env && typeof input.env === 'object' ? input.env : process.env;
58
64
  const shell = callerEnv.SHELL || process.env.SHELL || 'bash';
59
65
  const env = childSessionEnv({
@@ -61,14 +67,15 @@ export function createPtySessions(deps) {
61
67
  HCC_ROOT: pctx.root,
62
68
  HCC_DB: pctx.dbPath,
63
69
  TERM: 'xterm-256color'
64
- }, callerEnv);
70
+ }, callerEnv, { rootIdentity: pctx.rootIdentity || pctx.initialRootIdentity });
65
71
  const size = input.size || { cols: 100, rows: 30 };
66
72
  const previousSession = sessions.get(key);
67
- const child = pty.spawn(shell, ['-c', command], {
73
+ const child = spawnPinnedCwdPty(pty, shell, ['-c', command], {
68
74
  name: 'xterm-256color',
69
75
  cols: size.cols,
70
76
  rows: size.rows,
71
77
  cwd,
78
+ expectedIdentity: expectedCwdIdentity,
72
79
  env
73
80
  });
74
81
  const session = {
@@ -218,6 +225,7 @@ export function createPtySessions(deps) {
218
225
  }
219
226
 
220
227
  function startSession(input) {
228
+ if (isStopping()) throw new CliError('RUNTIME_STOPPING', 'Web runtime is stopping');
221
229
  const normalized = normalizeWebSessionInput(input);
222
230
  if (normalized.backend === 'pty') return startPtySession(normalized);
223
231
  return startTmuxManagedSession(normalized);