@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,158 @@
1
+ /** Read-only project data broker. Pane drafts, selection and control tokens never enter it. */
2
+ const SHARED_PATHS = new Set(['/api/projects', '/api/sessions', '/api/detected', '/api/state']);
3
+ const aborted = () => Object.assign(new Error('Request superseded'), { name: 'AbortError', code: 'REQUEST_SUPERSEDED' });
4
+
5
+ export function createReadBroker({ fetchJson, now = Date.now, ttlMs = 2400, maxEntries = 64 } = {}) {
6
+ if (typeof fetchJson !== 'function' || !Number.isSafeInteger(maxEntries) || maxEntries < 1 || !Number.isFinite(ttlMs) || ttlMs < 0) throw new TypeError('Invalid read broker options');
7
+ const entries = new Map();
8
+ const pending = new Set();
9
+ function read(url, options = {}) {
10
+ const parsed = new URL(url, 'http://hcc.local');
11
+ if (!SHARED_PATHS.has(parsed.pathname)) throw new TypeError('Only project read endpoints can be shared');
12
+ const { signal, headers = {} } = options;
13
+ if (signal?.aborted) return Promise.reject(aborted());
14
+ const requestHeaders = Object.fromEntries(new globalThis.Headers(headers));
15
+ const key = JSON.stringify([url, Object.entries(requestHeaders).sort(([a], [b]) => a.localeCompare(b))]);
16
+ let entry = entries.get(key);
17
+ if (entry && entry.done && now() - entry.completedAt >= ttlMs) { entries.delete(key); entry = null; }
18
+ if (!entry) {
19
+ // Never forget a pending request and silently create unlimited work.
20
+ // Saturation rejects new distinct reads; existing shared consumers finish.
21
+ if (pending.size >= maxEntries) return Promise.reject(Object.assign(new Error('Too many pending project reads'), { code: 'READ_BROKER_BUSY' }));
22
+ if (entries.size >= maxEntries) {
23
+ const completed = [...entries].find(([, value]) => value.done);
24
+ if (completed) entries.delete(completed[0]);
25
+ }
26
+ const controller = new globalThis.AbortController();
27
+ entry = { controller, users: 0, done: false, completedAt: 0 };
28
+ const owned = entry;
29
+ entries.set(key, entry);
30
+ pending.add(entry);
31
+ entry.promise = Promise.resolve().then(() => fetchJson(url, { headers: requestHeaders, signal: controller.signal }))
32
+ .then(value => { pending.delete(owned); owned.done = true; owned.completedAt = now(); return value; }, error => {
33
+ pending.delete(owned);
34
+ owned.done = true;
35
+ if (entries.get(key) === owned) entries.delete(key);
36
+ throw error;
37
+ });
38
+ }
39
+ entry.users++;
40
+ const owned = entry;
41
+ return new Promise((resolve, reject) => {
42
+ let finished = false;
43
+ function release() {
44
+ if (finished) return false;
45
+ finished = true; signal?.removeEventListener('abort', cancel); owned.controller.signal.removeEventListener('abort', cancel); owned.users--;
46
+ if (!owned.done && owned.users === 0) {
47
+ owned.controller.abort();
48
+ if (entries.get(key) === owned) entries.delete(key);
49
+ }
50
+ return true;
51
+ }
52
+ function cancel() { if (release()) reject(aborted()); }
53
+ signal?.addEventListener('abort', cancel, { once: true });
54
+ owned.controller.signal.addEventListener('abort', cancel, { once: true });
55
+ owned.promise.then(value => { if (release()) resolve(structuredClone(value)); }, error => { if (release()) reject(error); });
56
+ });
57
+ }
58
+ return Object.freeze({ read, invalidate() {
59
+ // Include completed cache entries: their current consumers may still be
60
+ // waiting for a promise microtask when a write invalidates the read.
61
+ const stale = new Set([...entries.values(), ...pending]);
62
+ entries.clear();
63
+ for (const entry of stale) entry.controller.abort();
64
+ } });
65
+ }
66
+
67
+ export async function fetchJson(url, options = {}, { fetchImpl = globalThis.fetch, timeoutMs = 30000 } = {}) {
68
+ const controller = new globalThis.AbortController();
69
+ let timedOut = false;
70
+ const cancel = () => controller.abort();
71
+ if (options.signal?.aborted) throw aborted();
72
+ options.signal?.addEventListener('abort', cancel, { once: true });
73
+ const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
74
+ try {
75
+ const response = await fetchImpl(url, { ...options, signal: controller.signal });
76
+ const json = await response.json();
77
+ if (!response.ok) throw Object.assign(new Error(json.error?.message || json.message || 'Request failed'), {
78
+ code: json.error?.code, detail: json.error?.message, status: response.status,
79
+ ...(json.error?.extra && typeof json.error.extra === 'object' && !Array.isArray(json.error.extra) ? { extra: json.error.extra } : {})
80
+ });
81
+ return json;
82
+ } catch (error) {
83
+ if (timedOut) throw Object.assign(new Error('Request timed out; check the session before retrying a write.'), { code: 'REQUEST_TIMEOUT' });
84
+ if (options.signal?.aborted) throw aborted();
85
+ throw error;
86
+ } finally { clearTimeout(timer); options.signal?.removeEventListener('abort', cancel); }
87
+ }
88
+
89
+ export function sharedProjectBroker(surface = globalThis.window) {
90
+ // Only the existing same-origin parent may lend its broker. No BroadcastChannel
91
+ // or postMessage credential transfer, and no write/lease APIs are exposed.
92
+ try { if (surface.parent !== surface && surface.parent.location.origin === surface.location.origin && surface.parent.hccProjectReads) return surface.parent.hccProjectReads; } catch {}
93
+ if (surface.hccProjectReads) return surface.hccProjectReads;
94
+ const broker = createReadBroker({ fetchJson });
95
+ Object.defineProperty(surface, 'hccProjectReads', { value: broker, configurable: true });
96
+ return broker;
97
+ }
98
+
99
+ export function createProjectRequests({ broker, fetcher = fetchJson, root = '', rootIdentity = '', headers = {}, requireIdentity = false }) {
100
+ let scope = root, selectedIdentity = rootIdentity, generation = 0;
101
+ const pending = new Set();
102
+ const cancel = () => { generation++; for (const controller of pending) controller.abort(); pending.clear(); };
103
+ return {
104
+ rootIdentity() { return selectedIdentity; },
105
+ setRoot(value, identity = scope === value ? selectedIdentity : '') {
106
+ if (scope !== value || selectedIdentity !== identity) { cancel(); scope = value; selectedIdentity = identity; }
107
+ },
108
+ dispose: cancel,
109
+ async request(path, options = {}) {
110
+ const visit = generation, controller = new globalThis.AbortController();
111
+ const url = new URL(path, 'http://hcc.local');
112
+ const { explicitSelection = false, ...fetchOptions } = options;
113
+ if (requireIdentity && !explicitSelection && !selectedIdentity) {
114
+ throw Object.assign(new Error('Select a project before requesting its data'), { code: 'PROJECT_IDENTITY_REQUIRED' });
115
+ }
116
+ if (scope && !explicitSelection) url.searchParams.set('root', scope);
117
+ const target = url.pathname + url.search;
118
+ const read = !fetchOptions.method || fetchOptions.method.toUpperCase() === 'GET';
119
+ const externalAbort = () => controller.abort();
120
+ if (options.signal?.aborted) throw aborted();
121
+ options.signal?.addEventListener('abort', externalAbort, { once: true });
122
+ pending.add(controller);
123
+ const requestHeaders = { 'Content-Type': 'application/json', ...headers, ...options.headers };
124
+ // Never let an auxiliary caller replace the selection attached to this
125
+ // visit. Explicit selection has no prior project identity by design.
126
+ for (const key of Object.keys(requestHeaders)) {
127
+ if (['x-hcc-root-identity', 'x-hcc-browser'].includes(key.toLowerCase())) delete requestHeaders[key];
128
+ }
129
+ if (requireIdentity) requestHeaders['X-HCC-Browser'] = '1';
130
+ if (!explicitSelection && selectedIdentity) requestHeaders['X-HCC-Root-Identity'] = selectedIdentity;
131
+ const requestOptions = { ...fetchOptions, signal: controller.signal, headers: requestHeaders };
132
+ if (!read) broker.invalidate();
133
+ try {
134
+ const data = await (read && SHARED_PATHS.has(url.pathname) ? broker.read(target, requestOptions) : fetcher(target, requestOptions));
135
+ if (visit !== generation || controller.signal.aborted) throw aborted();
136
+ return data;
137
+ } finally {
138
+ if (!read) broker.invalidate();
139
+ pending.delete(controller); options.signal?.removeEventListener('abort', externalAbort);
140
+ }
141
+ }
142
+ };
143
+ }
144
+
145
+ /** No overlapping polls; hidden tabs reduce their refresh rate and wake immediately. */
146
+ export function scheduleVisiblePoll(run, { intervalMs, hiddenMs = 30000, document: doc = globalThis.document, surface = globalThis.window, onError = console.error }) {
147
+ let timer, stopped = false, running = false;
148
+ const schedule = () => { clearTimeout(timer); if (!stopped) timer = setTimeout(tick, doc.hidden ? hiddenMs : intervalMs); };
149
+ async function tick() {
150
+ if (stopped || running) return;
151
+ running = true;
152
+ try { if (surface.navigator?.onLine !== false) await run(); } catch (error) { if (error.name !== 'AbortError') onError(error); }
153
+ finally { running = false; schedule(); }
154
+ }
155
+ function wake() { if (!doc.hidden) { clearTimeout(timer); void tick(); } else schedule(); }
156
+ doc.addEventListener('visibilitychange', wake); surface.addEventListener('online', wake); schedule();
157
+ return () => { stopped = true; clearTimeout(timer); doc.removeEventListener('visibilitychange', wake); surface.removeEventListener('online', wake); };
158
+ }
@@ -0,0 +1,82 @@
1
+ /** Structured session wire protocol. This module is shared by Node and browsers. */
2
+ export const SESSION_SYNC_PROTOCOL = 1;
3
+ const forbidden = new Set(['__proto__', 'prototype', 'constructor']);
4
+
5
+ /** @typedef {{op:'set'|'delete'|'append'|'splice', path:(string|number)[], value?:unknown, start?:number, deleteCount?:number, values?:unknown[]}} StateOperation */
6
+
7
+ /** Copy only changed ancestors. Never mutate a previously delivered UI state. */
8
+ export function applyStateOperations(state, operations) {
9
+ if (!Array.isArray(operations) || operations.length > 2048) throw new Error('Invalid operations');
10
+ let result = state;
11
+ for (const operation of operations) {
12
+ const path = operation.path;
13
+ if (!Array.isArray(path) || path.length > 32 || path.some(key =>
14
+ !(typeof key === 'string' || Number.isSafeInteger(key) && key >= 0) || forbidden.has(String(key)))) throw new Error('Invalid path');
15
+ const update = (value, depth) => {
16
+ if (depth === path.length) {
17
+ if (operation.op === 'set') return structuredClone(operation.value);
18
+ if (operation.op === 'append' && typeof value === 'string' && typeof operation.value === 'string') return value + operation.value;
19
+ if (operation.op === 'splice' && Array.isArray(value) && Array.isArray(operation.values) &&
20
+ Number.isSafeInteger(operation.start) && operation.start >= 0 && operation.start <= value.length &&
21
+ Number.isSafeInteger(operation.deleteCount) && operation.deleteCount >= 0 && operation.deleteCount <= value.length - operation.start) {
22
+ const copy = value.slice(); copy.splice(operation.start, operation.deleteCount, ...structuredClone(operation.values)); return copy;
23
+ }
24
+ throw new Error('Invalid operation');
25
+ }
26
+ if (!value || typeof value !== 'object') throw new Error('Missing path');
27
+ const key = path[depth], last = depth === path.length - 1;
28
+ if (Array.isArray(value) && (!Number.isSafeInteger(key) || key >= value.length)) throw new Error('Invalid array path');
29
+ if (!Object.hasOwn(value, key) && !(last && operation.op === 'set' && !Array.isArray(value))) throw new Error('Missing path');
30
+ const copy = Array.isArray(value) ? value.slice() : { ...value };
31
+ if (last && operation.op === 'delete' && !Array.isArray(value)) delete copy[key];
32
+ else copy[key] = update(value[key], depth + 1);
33
+ return copy;
34
+ };
35
+ result = update(result, 0);
36
+ }
37
+ return result;
38
+ }
39
+
40
+ /**
41
+ * One receiver per WebSocket. Reconnection must create a receiver or reset it.
42
+ * onState receives immutable-by-convention state; recovery never sends input.
43
+ * @param {{root:string,sessionId:string,onState?:(state:object,channel:string)=>void,requestSnapshot?:(frame:object)=>void}} options
44
+ */
45
+ export function createSessionSync({ root, sessionId, onState = () => {}, requestSnapshot = () => {} }) {
46
+ let state = null, generation = null, executorId = null, revision = -1, channel = null, requestId = null, sequence = 0;
47
+ function recover(reason) {
48
+ if (!requestId) {
49
+ requestId = 'sync-' + ++sequence;
50
+ requestSnapshot({ type: 'state_sync_request', protocol: SESSION_SYNC_PROTOCOL, requestId });
51
+ }
52
+ return { accepted: false, recovery: true, reason };
53
+ }
54
+ function receive(frame) {
55
+ if (frame?.type !== 'state_sync') return { accepted: false, ignored: true };
56
+ if (frame.protocol !== SESSION_SYNC_PROTOCOL || frame.root !== root || frame.sessionId !== sessionId ||
57
+ !['codex', 'native'].includes(frame.channel) || typeof frame.generation !== 'string' || !frame.generation ||
58
+ typeof frame.executorId !== 'string' || !Number.isSafeInteger(frame.revision) || frame.revision < 0) return recover('scope-or-version');
59
+ if (generation && (frame.generation !== generation || frame.executorId !== executorId || frame.channel !== channel)) return recover('executor-or-generation');
60
+ if (frame.mode === 'snapshot') {
61
+ if (!frame.state || typeof frame.state !== 'object' || Array.isArray(frame.state)) return recover('invalid-snapshot');
62
+ if (frame.state.executorId != null && frame.state.executorId !== frame.executorId) return recover('executor-state-mismatch');
63
+ if (generation && (!requestId || frame.requestId !== requestId || frame.revision < revision)) return recover('unsolicited-or-stale-snapshot');
64
+ if (requestId && frame.requestId !== requestId) return { accepted: false, recovery: true, reason: 'waiting-for-recovery' };
65
+ state = structuredClone(frame.state); generation = frame.generation; executorId = frame.executorId; channel = frame.channel;
66
+ } else if (frame.mode === 'patch') {
67
+ if (requestId) return { accepted: false, recovery: true, reason: 'waiting-for-recovery' };
68
+ if (!generation || frame.baseRevision !== revision || frame.revision !== revision + 1) return recover('revision-gap');
69
+ try {
70
+ const next = applyStateOperations(state, frame.operations);
71
+ if (next.executorId != null && next.executorId !== executorId) return recover('executor-state-mismatch');
72
+ state = next;
73
+ } catch { return recover('invalid-patch'); }
74
+ } else return recover('unknown-mode');
75
+ revision = frame.revision; requestId = null;
76
+ onState(state, channel);
77
+ return { accepted: true, revision, generation };
78
+ }
79
+ const api = { receive, reset() { state = null; generation = null; executorId = null; revision = -1; channel = null; requestId = null; } };
80
+ Object.defineProperties(api, { state: { get: () => state }, revision: { get: () => revision }, recovering: { get: () => Boolean(requestId) } });
81
+ return api;
82
+ }
@@ -0,0 +1,19 @@
1
+ import { fileURLToPath } from 'node:url';
2
+
3
+ // Explicit public module graph. Never resolve a request path against lib/.
4
+ const names = [
5
+ 'browser/core.mjs', 'browser/project-data.mjs', 'browser/session-sync.mjs',
6
+ 'api-version.mjs', 'ui-agent-start.mjs', 'ui-agent-defaults.mjs', 'ui-command-palette.mjs', 'ui-files.mjs', 'ui-file-writes.mjs',
7
+ 'ui-codex.mjs', 'ui-codex-account.mjs', 'ui-handoff.mjs', 'ui-history.mjs',
8
+ 'ui-interactions.mjs', 'ui-i18n.mjs', 'ui-native.mjs', 'ui-native-timeline.mjs',
9
+ 'ui-preferences.mjs', 'ui-review.mjs', 'ui-safe-markdown.mjs',
10
+ 'ui-session-tools.mjs', 'ui-terminal-find.mjs', 'ui-workbench.mjs', 'ui-workspace.mjs'
11
+ ];
12
+ const assets = new Map(names.map(name => ['/assets/web/' + name, fileURLToPath(new URL(name, import.meta.url))]));
13
+ // These shared validators are pure JavaScript and imported by the interaction
14
+ // panel. Keep the two integration paths explicit; other integrations stay private.
15
+ for (const name of ['mcp-elicitation.mjs', 'mcp-url-elicitation.mjs']) {
16
+ assets.set('/assets/integrations/' + name, fileURLToPath(new URL('../integrations/' + name, import.meta.url)));
17
+ }
18
+ export function browserAssetPath(pathname) { return assets.get(pathname) || null; }
19
+ export const BROWSER_ASSET_PATHS = Object.freeze([...assets.keys()]);
@@ -12,7 +12,7 @@ import { bufferPlanGcCutoffs, planBufferFiles } from '../runtime/buffer-gc.mjs';
12
12
 
13
13
  export function createBufferGcRuntime(deps) {
14
14
  const {
15
- sessions,
15
+ sessions, isStopping = () => false,
16
16
  connectWebProject, runtimeProjectContexts,
17
17
  now, runGc, bufferDirectory,
18
18
  canonicalRoot, observePeerEvidence, redactedLogText, sameResolvedPath
@@ -136,7 +136,7 @@ export function createBufferGcRuntime(deps) {
136
136
  }
137
137
 
138
138
  function runAutoGc() {
139
- if (autoGcInFlight) return;
139
+ if (isStopping() || autoGcInFlight) return;
140
140
  autoGcInFlight = true;
141
141
  try {
142
142
  for (const projectCtx of runtimeProjectContexts()) {
@@ -1,5 +1,8 @@
1
- import { spawn } from 'node:child_process';
1
+ import { spawnPinnedCwdProcess } from '../process/pinned-cwd.mjs';
2
+ import { captureSelectedCwdIdentity } from '../process/selected-cwd-identity.mjs';
3
+ import { createCodexAccountState } from '../integrations/codex-account.mjs';
2
4
  import { randomUUID } from 'node:crypto';
5
+ import { redactMcpUrlInteraction } from '../integrations/mcp-url-elicitation.mjs';
3
6
  import path from 'node:path';
4
7
  import { CODEX_INTERACTION_METHODS, codexInteractionKind, codexInteractionResponse, cancelCodexInteraction, codexInteractiveConfig } from '../integrations/codex-interactions.mjs';
5
8
 
@@ -52,13 +55,18 @@ function rpcKey(id) {
52
55
 
53
56
  export function createCodexAppServer({
54
57
  cwd, env = process.env, onChange = () => {},
58
+ expectedCwdIdentity = null,
59
+ // Internal synchronous observers may consume a borrowed read-only view.
60
+ // Public callers retain isolated snapshots by default; never retain a view.
61
+ changeView = false,
55
62
  onSpawn = async () => null,
56
63
  requestTimeoutMs = 30000, closeTimeoutMs = 1000, clientVersion = '1.0.1',
57
64
  // Test seam. The production command and its arguments are always fixed below.
58
- spawnProcess = spawn
65
+ spawnProcess = spawnPinnedCwdProcess
59
66
  } = {}) {
60
67
  const root = path.resolve(cwd || process.cwd());
61
68
  if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) throw new TypeError('requestTimeoutMs must be positive');
69
+ const cwdBinding = captureSelectedCwdIdentity(root, expectedCwdIdentity);
62
70
  const executorId = randomUUID();
63
71
  let status = 'new';
64
72
  let child = null;
@@ -73,6 +81,11 @@ export function createCodexAppServer({
73
81
  let closePromise = null;
74
82
  let streamTimer = null;
75
83
  let streamEvent = null;
84
+ let updateSequence = 0;
85
+ const textItems = new Map();
86
+ let textHintsComplete = true;
87
+ const turnSizes = new WeakMap();
88
+ let runtimeMetadata = {}, metadataThreadId = null, metrics = null, metricsThreadId = null;
76
89
  const threads = new Map();
77
90
  const pending = new Map();
78
91
  const approvals = new Map();
@@ -80,6 +93,8 @@ export function createCodexAppServer({
80
93
  const submittingThreads = new Set();
81
94
  const threadNotificationSequences = new Map();
82
95
  const events = [];
96
+ const account = createCodexAccountState({ request: (method, params) => call(method, params),
97
+ onChange: value => changed('account.status', { account: value }) });
83
98
 
84
99
  function bounded(value, limit = MAX_TEXT, depth = 0) {
85
100
  if (typeof value === 'string') {
@@ -100,9 +115,9 @@ export function createCodexAppServer({
100
115
  return value;
101
116
  }
102
117
 
103
- function snapshot() {
118
+ function stateView() {
104
119
  const current = threads.get(currentThreadId);
105
- return structuredClone({
120
+ return {
106
121
  executorId, backend: 'codex-app-server', status, cwd: root, pid: child?.pid || null,
107
122
  processExited: Boolean(child && (child.exitCode != null || child.signalCode != null)),
108
123
  metadata, currentThreadId, threadId: currentThreadId, turnId: current?.activeTurnId || null,
@@ -110,17 +125,24 @@ export function createCodexAppServer({
110
125
  pendingApprovals: [...approvals.values()].filter((approval) => approval.status === 'pending'),
111
126
  uncertainSubmissions: [...uncertain.values()].map(({ params, ...entry }) => entry),
112
127
  uncertainRecovery: uncertain.size ? 'stop-executor-and-resume-history' : null,
113
- events, truncated
114
- });
128
+ account: account.snapshot(), events, truncated, updateSequence,
129
+ ...(metadataThreadId === currentThreadId && Object.keys(runtimeMetadata).length ? { runtimeMetadata } : {}),
130
+ ...(metrics && metricsThreadId === currentThreadId ? { metrics } : {})
131
+ };
115
132
  }
133
+ function snapshot() { return structuredClone(stateView()); }
116
134
 
117
135
  function publishChange(event) {
118
136
  clearTimeout(streamTimer); streamTimer = null; streamEvent = null;
119
- try { onChange(snapshot(), structuredClone(event)); } catch { /* UI observers do not own transport. */ }
137
+ const change = { textOnly: textHintsComplete && event?.method === 'item/agentMessage/delta', textItems: [...textItems.values()] };
138
+ textItems.clear();
139
+ textHintsComplete = true;
140
+ try { onChange(changeView ? stateView() : snapshot(), structuredClone(event), change); } catch { /* UI observers do not own transport. */ }
120
141
  }
121
142
 
122
143
  function changed(method, params = {}) {
123
- const event = { method, params: bounded(params, 8192), at: Date.now() };
144
+ updateSequence++;
145
+ const event = { method, params: bounded(redactMcpUrlInteraction(params), 8192), at: Date.now() };
124
146
  events.push(event);
125
147
  if (events.length > 100) events.shift();
126
148
  // Keep protocol state current on every token, but copy/broadcast its complete
@@ -128,6 +150,9 @@ export function createCodexAppServer({
128
150
  // Approval, completion, error and connection changes publish immediately;
129
151
  // their snapshot includes all pending text and cancels the trailing timer.
130
152
  if (method === 'item/agentMessage/delta') {
153
+ const identity = { threadId: params.threadId, turnId: params.turnId, itemId: params.itemId };
154
+ if (textItems.size < 1024) textItems.set(JSON.stringify(identity), identity);
155
+ else textHintsComplete = false;
131
156
  streamEvent = event;
132
157
  if (!streamTimer) {
133
158
  streamTimer = setTimeout(() => publishChange(streamEvent), STREAM_UPDATE_MS);
@@ -172,9 +197,21 @@ export function createCodexAppServer({
172
197
  }
173
198
  }
174
199
 
175
- function boundItems(turn) {
176
- while (turn.items.length > 100 || JSON.stringify(turn.items).length > 1024 * 1024) {
177
- turn.items.shift(); truncated = true;
200
+ function boundItems(turn, changedItem = null) {
201
+ let cache = turnSizes.get(turn);
202
+ if (!cache || !changedItem || cache.array !== turn.items) {
203
+ const sizes = new Map(turn.items.map(item => [item, JSON.stringify(item).length]));
204
+ cache = { array: turn.items, sizes, total: 2 + Math.max(0, turn.items.length - 1) + [...sizes.values()].reduce((sum, size) => sum + size, 0) };
205
+ turnSizes.set(turn, cache);
206
+ } else {
207
+ const old = cache.sizes.get(changedItem), size = JSON.stringify(changedItem).length;
208
+ cache.total += size - (old || 0) + (old == null && cache.sizes.size ? 1 : 0);
209
+ cache.sizes.set(changedItem, size);
210
+ }
211
+ while (turn.items.length > 100 || cache.total > 1024 * 1024) {
212
+ const removed = turn.items.shift();
213
+ cache.total -= (cache.sizes.get(removed) || 0) + (turn.items.length ? 1 : 0);
214
+ cache.sizes.delete(removed); truncated = true;
178
215
  }
179
216
  }
180
217
 
@@ -235,6 +272,12 @@ export function createCodexAppServer({
235
272
  }
236
273
 
237
274
  function applyResult(method, params, result, requestSequence) {
275
+ if (['thread/start', 'thread/resume'].includes(method)) {
276
+ const policy = result?.approvalPolicy;
277
+ metadataThreadId = result?.thread?.id || null;
278
+ runtimeMetadata = { ...(typeof result?.model === 'string' ? { model: result.model } : {}),
279
+ ...(policy != null ? { permissionMode: typeof policy === 'string' ? policy : JSON.stringify(policy) } : {}) };
280
+ }
238
281
  if (['thread/start', 'thread/resume', 'thread/read'].includes(method)) absorbThread(result?.thread, requestSequence);
239
282
  if (method === 'turn/start') absorbTurn(params.threadId, result?.turn);
240
283
  }
@@ -251,6 +294,7 @@ export function createCodexAppServer({
251
294
  function disconnect(code, message) {
252
295
  if (status === 'disconnected' || status === 'closed') return;
253
296
  status = 'disconnected';
297
+ account.close();
254
298
  for (const entry of pending.values()) {
255
299
  clearTimeout(entry.timer);
256
300
  const isUncertain = entry.mutation && entry.sent;
@@ -333,7 +377,8 @@ export function createCodexAppServer({
333
377
  if (approvals.has(key)) throw new CodexAppServerError('CODEX_PROTOCOL_ERROR', 'server reused an unresolved request id');
334
378
  if (approvals.size >= 64) throw new CodexAppServerError('CODEX_PROTOCOL_ERROR', 'too many unresolved approvals');
335
379
  thread.activeTurnId = turnId;
336
- thread.status = { type: 'active', activeFlags: [codexInteractionKind(message.method) === 'userInput' ? 'waitingOnUserInput' : 'waitingOnApproval'] };
380
+ const inputRequired = codexInteractionKind(message.method) === 'userInput' || (message.method === 'mcpServer/elicitation/request' && params.mode === 'form' && Object.keys(params.requestedSchema?.properties || {}).length > 0);
381
+ thread.status = { type: 'active', activeFlags: [inputRequired ? 'waitingOnUserInput' : 'waitingOnApproval'] };
337
382
  const preview = bounded(params);
338
383
  approvals.set(key, {
339
384
  executorId, requestId: message.id, threadId, turnId, itemId: params.itemId || null,
@@ -346,9 +391,22 @@ export function createCodexAppServer({
346
391
 
347
392
  function notification(method, params) {
348
393
  notificationSequence += 1;
394
+ if (account.notification(method, params)) return;
349
395
  const notifiedThread = params.threadId || params.thread?.id;
350
396
  if (notifiedThread) threadNotificationSequences.set(notifiedThread, notificationSequence);
351
397
  if (method === 'thread/started') absorbThread(params.thread);
398
+ if (method === 'thread/tokenUsage/updated' && params.threadId === currentThreadId) {
399
+ const usage = params.tokenUsage?.total;
400
+ if (usage && typeof usage === 'object') {
401
+ const values = {};
402
+ for (const name of ['inputTokens', 'outputTokens', 'cachedInputTokens', 'totalTokens']) {
403
+ if (Number.isFinite(usage[name]) && usage[name] >= 0) values[name] = usage[name];
404
+ }
405
+ if (Number.isFinite(params.tokenUsage.modelContextWindow) && params.tokenUsage.modelContextWindow > 0) values.contextWindow = params.tokenUsage.modelContextWindow;
406
+ metrics = { source: 'codex thread/tokenUsage/updated', scope: 'session', observedAt: Date.now(), ...values };
407
+ metricsThreadId = params.threadId;
408
+ }
409
+ }
352
410
  if (method === 'thread/status/changed') {
353
411
  const thread = ensureThread(params.threadId);
354
412
  thread.status = bounded(params.status);
@@ -374,7 +432,7 @@ export function createCodexAppServer({
374
432
  if (!item) { item = { id: itemId, type: 'agentMessage', text: '' }; turn.items.push(item); }
375
433
  if (params.item) Object.assign(item, bounded(params.item));
376
434
  else item.text = bounded((item.text || '') + (params.delta || ''));
377
- boundItems(turn);
435
+ boundItems(turn, item);
378
436
  }
379
437
  changed(method, params);
380
438
  }
@@ -408,7 +466,8 @@ export function createCodexAppServer({
408
466
  entry?.resolve(message.result);
409
467
  }
410
468
  uncertain.delete(key);
411
- changed(late ? 'request.reconciled' : 'request.completed', { requestId: message.id, method: operation.method, error: message.error || null });
469
+ changed(late ? 'request.reconciled' : 'request.completed', { requestId: message.id, method: operation.method,
470
+ error: operation.method.startsWith('account/') ? (message.error ? { code: message.error.code } : null) : message.error || null });
412
471
  }
413
472
 
414
473
  function initialize() {
@@ -417,8 +476,10 @@ export function createCodexAppServer({
417
476
  initialization = (async () => {
418
477
  status = 'connecting';
419
478
  try {
479
+ cwdBinding.assertUnchanged();
420
480
  child = spawnProcess('codex', ['app-server', '--listen', 'stdio://'], {
421
- cwd: root, env: { ...env }, stdio: ['pipe', 'pipe', 'pipe']
481
+ cwd: root, expectedIdentity: cwdBinding,
482
+ env: { ...env }, stdio: ['pipe', 'pipe', 'pipe']
422
483
  });
423
484
  if (!child?.stdin || !child.stdout) throw new CodexAppServerError('CODEX_START_FAILED', 'Codex process has no stdio transport');
424
485
  child.stdout.setEncoding('utf8');
@@ -442,8 +503,8 @@ export function createCodexAppServer({
442
503
  child.stdin.on('error', () => disconnect('CODEX_DISCONNECTED', 'Codex input closed'));
443
504
  // Drain diagnostics, but never publish environment/auth data or stderr in UI state.
444
505
  child.stderr?.on('data', () => {});
445
- child.on('error', () => disconnect('CODEX_START_FAILED', 'Could not start the installed codex executable'));
446
- child.on('exit', () => disconnect('CODEX_DISCONNECTED', 'Codex App Server exited'));
506
+ child.on('error', () => { cwdBinding.release(); disconnect('CODEX_START_FAILED', 'Could not start the installed codex executable'); });
507
+ child.on('exit', () => { cwdBinding.release(); disconnect('CODEX_DISCONNECTED', 'Codex App Server exited'); });
447
508
  child.stdout.on('end', () => disconnect('CODEX_DISCONNECTED', 'Codex App Server output ended'));
448
509
  changed('transport.connecting');
449
510
  metadata = bounded(await request('initialize', {
@@ -473,9 +534,15 @@ export function createCodexAppServer({
473
534
 
474
535
  async function call(method, params, mutation = false, absorb = true) {
475
536
  await initialize();
537
+ if (method === 'thread/list') assertCwdHandoff();
476
538
  return request(method, params, mutation, absorb);
477
539
  }
478
540
 
541
+ function assertCwdHandoff() {
542
+ if (status !== 'ready') throw new CodexAppServerError('CODEX_DISCONNECTED', 'Executor is not ready for a directory handoff');
543
+ cwdBinding.assertUnchanged();
544
+ }
545
+
479
546
  function assertActiveTurn(threadId, turnId) {
480
547
  requiredId(threadId, 'threadId'); requiredId(turnId, 'turnId');
481
548
  if (threads.get(threadId)?.activeTurnId !== turnId) {
@@ -486,33 +553,38 @@ export function createCodexAppServer({
486
553
  async function close() {
487
554
  if (closePromise) return closePromise;
488
555
  closePromise = (async () => {
489
- disconnect('CODEX_CLOSED', 'Codex App Server was closed');
490
- status = 'closed'; approvals.clear();
491
- changed('transport.closed');
492
- if (!child || child.exitCode != null || child.signalCode != null) return;
493
- await new Promise((resolve) => {
494
- let timer;
495
- const done = () => { clearTimeout(timer); child.removeListener('exit', done); resolve(); };
496
- child.once('exit', done);
497
- timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} done(); }, closeTimeoutMs);
498
- try { child.stdin?.end(); child.kill('SIGTERM'); } catch { done(); }
499
- });
556
+ try {
557
+ disconnect('CODEX_CLOSED', 'Codex App Server was closed');
558
+ status = 'closed'; approvals.clear();
559
+ changed('transport.closed');
560
+ if (!child || child.exitCode != null || child.signalCode != null) return;
561
+ await new Promise((resolve) => {
562
+ let timer;
563
+ const done = () => { clearTimeout(timer); child.removeListener('exit', done); resolve(); };
564
+ child.once('exit', done);
565
+ timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} done(); }, closeTimeoutMs);
566
+ try { child.stdin?.end(); child.kill('SIGTERM'); } catch { done(); }
567
+ });
568
+ } finally { cwdBinding.release(); }
500
569
  })();
501
570
  return closePromise;
502
571
  }
503
572
 
504
573
  return {
505
574
  initialize, snapshot, close,
575
+ async readAccount() { await initialize(); return account.refresh(); },
506
576
  async startThread(params = {}) {
507
577
  await initialize();
508
- return request('thread/start', { cwd: root, sandbox: 'workspace-write', approvalPolicy: 'on-request', approvalsReviewer: 'user',
509
- ...objectParams(params), config: codexInteractiveConfig({ ...objectParams(params).config, ...threadConfig }) }, true);
578
+ assertCwdHandoff();
579
+ return request('thread/start', { sandbox: 'workspace-write', approvalPolicy: 'on-request', approvalsReviewer: 'user',
580
+ ...objectParams(params), cwd: root, config: codexInteractiveConfig({ ...objectParams(params).config, ...threadConfig }) }, true);
510
581
  },
511
582
  async resumeThread(threadId, params = {}) {
512
583
  await initialize();
513
- return request('thread/resume', { cwd: root, approvalPolicy: 'on-request', approvalsReviewer: 'user',
584
+ assertCwdHandoff();
585
+ return request('thread/resume', { approvalPolicy: 'on-request', approvalsReviewer: 'user',
514
586
  ...objectParams(params), config: codexInteractiveConfig({ ...objectParams(params).config, ...threadConfig }),
515
- threadId: requiredId(threadId, 'threadId') }, true);
587
+ cwd: root, threadId: requiredId(threadId, 'threadId') }, true);
516
588
  },
517
589
  readThread(threadId, params = {}) {
518
590
  return call('thread/read', { includeTurns: true, ...objectParams(params), threadId: requiredId(threadId, 'threadId') });
@@ -523,11 +595,12 @@ export function createCodexAppServer({
523
595
  async forkThread(threadId, params = {}, beforeSubmit = () => {}) {
524
596
  await initialize();
525
597
  beforeSubmit();
526
- return request('thread/fork', { cwd: root, approvalPolicy: 'on-request', approvalsReviewer: 'user', sandbox: 'workspace-write',
598
+ assertCwdHandoff();
599
+ return request('thread/fork', { approvalPolicy: 'on-request', approvalsReviewer: 'user', sandbox: 'workspace-write',
527
600
  ...objectParams(params), config: codexInteractiveConfig({ ...objectParams(params).config, ...threadConfig }),
528
- threadId: requiredId(threadId, 'threadId') }, true, false);
601
+ cwd: root, threadId: requiredId(threadId, 'threadId') }, true, false);
529
602
  },
530
- listThreads(params = {}) { return call('thread/list', { limit: 50, cwd: root, ...objectParams(params) }); },
603
+ listThreads(params = {}) { return call('thread/list', { limit: 50, ...objectParams(params), cwd: root }); },
531
604
  async startTurn(threadId, input, params = {}) {
532
605
  requiredId(threadId, 'threadId');
533
606
  const normalized = normalizeInput(input);
@@ -551,7 +624,7 @@ export function createCodexAppServer({
551
624
  assertActiveTurn(threadId, turnId);
552
625
  return call('turn/interrupt', { threadId, turnId }, true).then(result => { expireApprovals(threadId, turnId); changed('approval.expired', { threadId, turnId }); return result; });
553
626
  },
554
- async approve({ executorId: submittedExecutorId, threadId, turnId, requestId, decision, permissions, scope, answers } = {}) {
627
+ async approve({ executorId: submittedExecutorId, threadId, turnId, requestId, decision, permissions, scope, answers, content } = {}) {
555
628
  if (submittedExecutorId !== executorId) throw new CodexAppServerError('CODEX_APPROVAL_MISMATCH', 'approval identity or decision is invalid');
556
629
  assertActiveTurn(threadId, turnId);
557
630
  const approval = approvals.get(rpcKey(requestId));
@@ -561,13 +634,14 @@ export function createCodexAppServer({
561
634
  if (approval.truncated && decision === 'accept') {
562
635
  throw new CodexAppServerError('CODEX_APPROVAL_TRUNCATED', 'approval details exceed the preview limit; decline or cancel instead');
563
636
  }
564
- const result = codexInteractionResponse({ ...approval, kind: approval.kind || codexInteractionKind(approval.method) }, { decision, permissions, scope, answers });
637
+ const result = codexInteractionResponse({ ...approval, kind: approval.kind || codexInteractionKind(approval.method) }, { decision, permissions, scope, answers, content });
565
638
  await initialize();
566
639
  // No await between checking a live request and replying to its exact RPC id.
567
640
  assertActiveTurn(threadId, turnId);
568
641
  if (approvals.get(rpcKey(requestId)) !== approval) throw new CodexAppServerError('CODEX_APPROVAL_MISMATCH', 'approval was resolved');
569
642
  write({ id: requestId, result });
570
643
  approval.status = 'submitted';
644
+ approval.params = redactMcpUrlInteraction(approval).params;
571
645
  changed('approval.submitted', { executorId, threadId, turnId, requestId, decision });
572
646
  return { executorId, threadId, turnId, requestId, decision, status: 'submitted' };
573
647
  }