@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,1997 @@
1
+ import { createSessionSync } from './session-sync.mjs';
2
+ import { createProjectRequests, sharedProjectBroker, scheduleVisiblePoll } from './project-data.mjs';
3
+ import { installHandoff } from '../ui-handoff.mjs';
4
+ import { installWorkbench } from '../ui-workbench.mjs';
5
+ import { installCodexPanel } from '../ui-codex.mjs';
6
+ import { installHistory } from '../ui-history.mjs';
7
+ import { installReview } from '../ui-review.mjs';
8
+ import { installNativePanel } from '../ui-native.mjs';
9
+ import { installAgentStart } from '../ui-agent-start.mjs';
10
+ import { installAgentDefaults } from '../ui-agent-defaults.mjs';
11
+ import { installFiles } from '../ui-files.mjs';
12
+ import { installTerminalFind } from '../ui-terminal-find.mjs';
13
+ import { installCommandPalette } from '../ui-command-palette.mjs';
14
+ import { installWorkspace } from '../ui-workspace.mjs';
15
+ import { API_VERSION } from '../api-version.mjs';
16
+ import { TERMINAL_THEMES } from '../ui-preferences.mjs';
17
+
18
+ const auxiliaryPage = window.hccDraftScope === 'auxiliary';
19
+ const sameOriginFrame = (() => {
20
+ try { return window.parent !== window && window.parent.location.origin === location.origin; }
21
+ catch { return false; }
22
+ })();
23
+ // /pane is cookie-gated, but may still be visited as a top-level URL.
24
+ // Only an actual same-origin embed gets pane behavior and its draft scope.
25
+ if (auxiliaryPage && !sameOriginFrame) {
26
+ window.hccDraftScope = '';
27
+ document.documentElement.classList.remove('session-pane');
28
+ }
29
+ installHandoff();
30
+ installWorkbench();
31
+ const hccUi = window.hccUi;
32
+ const paneMode = auxiliaryPage && sameOriginFrame;
33
+ const handoffStore = window.hccHandoffStore;
34
+ const initialParams = new URLSearchParams(location.search);
35
+ let loggedOut = false;
36
+ try { sessionStorage.removeItem('hcc_logged_out'); } catch {}
37
+ const token = initialParams.get('token') || '';
38
+ const runtimeApiVersion = API_VERSION;
39
+ const headers = {
40
+ 'X-HCC-API-Version': String(runtimeApiVersion),
41
+ ...(token ? { Authorization: 'Bearer ' + token } : {})
42
+ };
43
+ const requestedInitialProject = initialParams.get('project') || initialParams.get('root') || '';
44
+ const initialProjectIdentity = initialParams.get('root_identity') || '';
45
+ // A navigation URL is not a project selection. In particular, following a
46
+ // link from another site must not make our same-origin browser code register
47
+ // the directory named in that link using the visitor's session cookie.
48
+ // root_identity is only an inode comparison, not an authenticated intent.
49
+ // A previous selection in this tab may be restored without a new click;
50
+ // workspace panes inherit only the active identity of their same-origin parent.
51
+ const trustedPaneProject = paneMode && (() => {
52
+ try {
53
+ return window.parent !== window && window.parent.location.origin === location.origin &&
54
+ window.parent.hccWorkspaceHost?.project() === requestedInitialProject &&
55
+ window.parent.hccWorkspaceHost?.projectIdentity() === initialProjectIdentity;
56
+ } catch { return false; }
57
+ })();
58
+ const trustedInitialProject = Boolean(requestedInitialProject && initialProjectIdentity && (trustedPaneProject || !paneMode && (() => {
59
+ try {
60
+ const selected = JSON.parse(sessionStorage.getItem('hcc.selectedProject') || 'null');
61
+ return selected?.root === requestedInitialProject && selected?.identity === initialProjectIdentity;
62
+ } catch { return false; }
63
+ })()));
64
+ let pendingInitialProject = !paneMode && !trustedInitialProject ? requestedInitialProject : '';
65
+ let currentProject = trustedInitialProject ? requestedInitialProject : '';
66
+ const projectRequests = createProjectRequests({ broker: sharedProjectBroker(), root: currentProject,
67
+ rootIdentity: trustedInitialProject ? initialProjectIdentity : '', headers, requireIdentity: true });
68
+ let projectSelectionVisit = 0;
69
+ let projects = [];
70
+ let sessionKindFilter = initialParams.get('kind') || 'all';
71
+ let sessionSearchQuery = '';
72
+ let sessionStatusFilter = 'all';
73
+ let sessions = []; // managed (PTY) sessions
74
+ let sessionsLoaded = false;
75
+ let detected = []; // coordination-only peers (from hooks/watcher)
76
+ // The current token belongs to one terminal WebSocket connection and is
77
+ // replaced on every reconnect.
78
+ const sessionActionTokens = new Map();
79
+ const sessionControls = new Map();
80
+ let activeConnectionState = 'offline';
81
+ let activeLocalClients = null;
82
+ let draftReceipt = false;
83
+ let lastControlNotice = 0;
84
+ let active = null; // active managed session id
85
+ let activeDetected = null; // active detected peer id
86
+ let activeType = 'managed'; // 'managed' | 'detected'
87
+ let showStaleDetected = hccUi.safeGet('hcc.showStaleDetected') === '1';
88
+ let activePeerTtl = 600;
89
+ let lastStateNow = 0;
90
+ let lastStateRoot = '';
91
+ let lastStateData = null;
92
+ let lastActionResult = null;
93
+ let ws = null;
94
+ let wsReconnectTimer = null;
95
+ let wsReconnectTarget = null;
96
+ let lastSentTerminalSize = null;
97
+ // ui-4: after logout (or repeated failed reconnects) the WS must not loop
98
+ // forever every 2s against a server that now rejects the upgrade.
99
+ let wsReconnectFailures = 0;
100
+ let terminalHasContent = false;
101
+ let terminalLastDataAt = 0;
102
+ let terminalLastResizeAt = 0;
103
+ let terminalLastReplaceAt = 0;
104
+ let autoPollInFlight = false;
105
+ let projectPollInFlight = false;
106
+ let lang = hccUi.language;
107
+
108
+ function tr(key, fallback = '') {
109
+ return hccUi.tr(key, fallback);
110
+ }
111
+
112
+ function setText(id, key) {
113
+ const el = document.getElementById(id);
114
+ if (el) el.textContent = tr(key);
115
+ }
116
+
117
+ function connText(key) {
118
+ const el = document.getElementById('connState');
119
+ if (el) {
120
+ el.dataset.stateKey = key;
121
+ el.textContent = tr('conn.' + key);
122
+ }
123
+ }
124
+
125
+ function applyLanguage() {
126
+ lang = hccUi.language;
127
+ document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en';
128
+ hccUi.translate();
129
+ if (!active && !activeDetected) {
130
+ setText('activeTitle', 'noSessionSelected');
131
+ setText('activeMeta', 'startOrSelect');
132
+ }
133
+ const stateKey = document.getElementById('connState')?.dataset.stateKey || 'offline';
134
+ connText(stateKey);
135
+ syncStartModeOptions();
136
+ renderSections();
137
+ renderHandoff();
138
+ if (activeType === 'detected' && activeDetected) {
139
+ const draft = document.getElementById('detMsg')?.value || '';
140
+ const peer = detected.find((p) => p.id === activeDetected) || { id: activeDetected };
141
+ renderDetectedHeader(peer);
142
+ renderDetectedPanel(peer);
143
+ const detMsg = document.getElementById('detMsg');
144
+ if (detMsg && draft) detMsg.value = draft;
145
+ } else if (activeType === 'managed' && active) {
146
+ const meta = sessions.find((s) => s.id === active);
147
+ renderActiveSession(meta || { id: active });
148
+ }
149
+ if (lastStateData) renderState(lastStateData);
150
+ if (lastActionResult && !document.getElementById('actionResult').hidden) showActionResult(lastActionResult);
151
+ if (!stopDialog.hidden) updateStopDialogTitle();
152
+ syncFocusButton();
153
+ syncToggleIcons();
154
+ }
155
+
156
+ function requestQuery(extra = {}) {
157
+ const params = new URLSearchParams();
158
+ if (token) params.set('token', token);
159
+ if (currentProject) params.set('root', currentProject);
160
+ params.set('browser', '1');
161
+ if (projectRequests.rootIdentity()) params.set('root_identity', projectRequests.rootIdentity());
162
+ for (const [key, value] of Object.entries(extra)) {
163
+ if (value !== undefined && value !== null && value !== '') params.set(key, value);
164
+ }
165
+ const text = params.toString();
166
+ return text ? '?' + text : '';
167
+ }
168
+
169
+ function updateLocationProject() {
170
+ const params = new URLSearchParams(location.search);
171
+ if (token) params.set('token', token);
172
+ if (currentProject) params.set('project', currentProject);
173
+ params.delete('root');
174
+ if (projectRequests.rootIdentity()) params.set('root_identity', projectRequests.rootIdentity());
175
+ else params.delete('root_identity');
176
+ if (!paneMode) params.delete('session');
177
+ if (sessionKindFilter && sessionKindFilter !== 'all') params.set('kind', sessionKindFilter);
178
+ history.replaceState(null, '', location.pathname + '?' + params.toString());
179
+ if (!paneMode && currentProject && projectRequests.rootIdentity()) {
180
+ try { sessionStorage.setItem('hcc.selectedProject', JSON.stringify({ root: currentProject, identity: projectRequests.rootIdentity() })); } catch {}
181
+ }
182
+ }
183
+
184
+ const term = new Terminal({
185
+ // Search match decorations use xterm's proposed decoration API.
186
+ allowProposedApi: true,
187
+ cursorBlink: true,
188
+ cursorInactiveStyle: 'outline',
189
+ cursorStyle: 'bar',
190
+ convertEol: true,
191
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
192
+ fontSize: hccUi.preferences.fontSize,
193
+ lineHeight: 1.15,
194
+ theme: TERMINAL_THEMES[hccUi.preferences.terminalTheme === 'system' ? hccUi.theme : hccUi.preferences.terminalTheme]
195
+ });
196
+ const fitAddon = new FitAddon.FitAddon();
197
+ term.loadAddon(fitAddon);
198
+ const searchAddon = new SearchAddon.SearchAddon({ highlightLimit: 1000 });
199
+ term.loadAddon(searchAddon);
200
+ term.open(document.getElementById('terminal'));
201
+
202
+ function resizeTerm() {
203
+ const el = document.getElementById('terminal');
204
+ if (!el.getClientRects().length || el.clientWidth <= 16 || el.clientHeight <= 16) return;
205
+ const size = fitAddon.proposeDimensions();
206
+ if (!size || !Number.isFinite(size.cols) || !Number.isFinite(size.rows)) return;
207
+ const { cols, rows } = size;
208
+ term.resize(cols, rows);
209
+ const actionToken = sessionActionTokens.get(active) || '';
210
+ if (ws && ws.readyState === WebSocket.OPEN && actionToken && canControl() &&
211
+ (!lastSentTerminalSize || lastSentTerminalSize.socket !== ws || lastSentTerminalSize.cols !== cols || lastSentTerminalSize.rows !== rows)) {
212
+ lastSentTerminalSize = { socket: ws, cols, rows };
213
+ terminalLastResizeAt = Date.now();
214
+ ws.send(JSON.stringify({ type: 'resize', cols, rows, action_token: actionToken, epoch: controlEpoch() }));
215
+ }
216
+ }
217
+ window.addEventListener('resize', resizeTerm);
218
+ new ResizeObserver(() => resizeTerm()).observe(document.getElementById('terminal'));
219
+ document.fonts?.ready.then(() => resizeTerm());
220
+ setTimeout(resizeTerm, 80);
221
+
222
+ function applyTerminalPreferences() {
223
+ const palette = TERMINAL_THEMES;
224
+ const theme = hccUi.preferences.terminalTheme === 'system' ? hccUi.theme : hccUi.preferences.terminalTheme;
225
+ term.options.theme = palette[theme];
226
+ term.options.fontSize = hccUi.preferences.fontSize;
227
+ document.getElementById('terminal').style.backgroundColor = palette[theme].background;
228
+ requestAnimationFrame(() => resizeTerm());
229
+ }
230
+
231
+ term.onData((data) => {
232
+ sendTerminalInput(data);
233
+ });
234
+
235
+ function sendTerminalInput(data, inputId = '') {
236
+ if (sessions.find(item => item.id === active)?.type === 'native' || sessions.find(item => item.id === active)?.type === 'app-server') return false;
237
+ if (!active || !ws || ws.readyState !== WebSocket.OPEN || !canControl()) {
238
+ if (Date.now() - lastControlNotice > 3000) {
239
+ lastControlNotice = Date.now();
240
+ document.getElementById('handoffStatus').textContent = tr('handoff.controlRequired');
241
+ }
242
+ return false;
243
+ }
244
+ const actionToken = sessionActionTokens.get(active) || '';
245
+ if (!actionToken) return false;
246
+ ws.send(JSON.stringify({ type: 'input', data, action_token: actionToken, epoch: controlEpoch(), ...(inputId ? { input_id: inputId } : {}) }));
247
+ return true;
248
+ }
249
+
250
+ async function api(path, options = {}) {
251
+ projectRequests.setRoot(currentProject);
252
+ try { return await projectRequests.request(path, options); }
253
+ catch (error) {
254
+ if (error.name === 'AbortError') throw error;
255
+ const translated = tr('error.' + error.code, '');
256
+ if (translated && translated !== 'error.' + error.code) error.message = translated;
257
+ throw error;
258
+ }
259
+ }
260
+
261
+ function controlEpoch() { return Number(sessionControls.get(active)?.epoch || 0); }
262
+ function canControl() {
263
+ return Boolean(active && sessions.find((item) => item.id === active)?.status === 'running' && ws?.readyState === WebSocket.OPEN && sessionActionTokens.get(active) && sessionControls.get(active)?.can_control);
264
+ }
265
+
266
+ function renderActiveSession(meta) {
267
+ document.getElementById('activeTitle').textContent = sessionDisplayTitle(meta) || active || '';
268
+ document.getElementById('activeMeta').textContent = [sessionProvider(meta), meta?.task?.id != null ? '#' + meta.task.id : '', meta?.task?.status ? statusText(meta.task.status) : ''].filter(Boolean).join(' · ');
269
+ const details = document.getElementById('activeDetails');
270
+ const subject = JSON.stringify([currentProject, 'managed', meta?.id || active]);
271
+ if (details.dataset.subject !== subject) details.open = false;
272
+ details.dataset.subject = subject;
273
+ const rows = [[tr('peer'), sessionPeerId(meta)], [tr('sessionId'), meta?.id], [tr('runtime'), sessionRuntimeTarget(meta)],
274
+ [tr('providerSession'), sessionProvider(meta) + ':' + (sessionProviderSessionValue(meta) || tr('unknown'))],
275
+ [tr('handoff.transport'), meta?.type || 'pty'], [tr('command'), meta?.command], [tr('cwd'), meta?.cwd]];
276
+ document.getElementById('activeIdentity').innerHTML = rows.map(([label, value]) => '<dt>' + esc(label) + '</dt><dd>' + esc(value || tr('unknown')) + '</dd>').join('');
277
+ const task = document.getElementById('activeTask');
278
+ task.hidden = !meta?.task?.title;
279
+ task.textContent = meta?.task?.title ? tr('handoff.task') + (meta.task.id != null ? ' #' + meta.task.id : '') + ': ' + meta.task.title + (meta.task.status ? ' · ' + statusText(meta.task.status) : '') : '';
280
+ }
281
+
282
+ function clearSessionIdentity() {
283
+ const details = document.getElementById('activeDetails');
284
+ details.open = false;
285
+ delete details.dataset.subject;
286
+ document.getElementById('activeIdentity').textContent = '';
287
+ document.getElementById('handoffDetail').textContent = '';
288
+ document.getElementById('activeTask').textContent = '';
289
+ document.getElementById('activeTask').hidden = true;
290
+ }
291
+
292
+ function renderDetectedHeader(peer) {
293
+ clearSessionIdentity();
294
+ document.getElementById('activeTitle').textContent = peer?.name || peer?.id || activeDetected || '';
295
+ document.getElementById('activeMeta').textContent = [peer?.provider || peer?.kind || tr('unknown'), tr('detected'), peer?.worktree || peer?.cwd || ''].filter(Boolean).join(' · ');
296
+ }
297
+
298
+ function renderDraftState() {
299
+ const value = active ? handoffStore.draft(currentProject, active) : { text: '', pending: null };
300
+ const note = document.getElementById('draftStatus');
301
+ note.textContent = tr(value.pending ? value.pending.status === 'uncertain' ? 'handoff.uncertain' : 'handoff.pending' : draftReceipt ? 'handoff.received' : 'handoff.saved');
302
+ note.title = tr('handoff.storageHint');
303
+ document.getElementById('reviewDraftBtn').hidden = value.pending?.status !== 'uncertain';
304
+ document.getElementById('sendDraftBtn').disabled = !canControl() || !value.text || Boolean(value.pending);
305
+ }
306
+
307
+ function renderHandoff() {
308
+ document.getElementById('handoffBar').hidden = !active;
309
+ const control = sessionControls.get(active) || {};
310
+ const writable = canControl();
311
+ term.options.disableStdin = !writable;
312
+ const copy = (en, zh) => lang === 'zh' ? zh : en;
313
+ const connection = document.getElementById('handoffConnection');
314
+ connection.textContent = copy('Web: ', '连接:') + (activeConnectionState === 'connected' ? copy('Connected', '已连接') : activeConnectionState === 'connecting' ? copy('Connecting…', '连接中…') : copy('Disconnected', '已断开'));
315
+ connection.dataset.tone = activeConnectionState === 'connected' ? 'ok' : 'warn';
316
+ const meta = sessions.find((item) => item.id === active);
317
+ const processState = document.getElementById('handoffProcess');
318
+ processState.textContent = copy('Process: ', '进程:') + statusText(meta?.status || 'unknown');
319
+ processState.dataset.tone = meta?.status === 'running' ? 'ok' : meta?.status === 'exited' ? 'danger' : '';
320
+ const access = document.getElementById('handoffAccess');
321
+ access.textContent = writable ? copy('You control', '你正在控制') : control.has_controller ? control.controller_connected === false ? copy('Control retained · Read only', '控制权暂时保留 · 只读') : copy('Other window controls · Read only', '其他窗口控制 · 只读') : copy('Read only', '只读');
322
+ access.dataset.tone = writable ? 'ok' : control.has_controller ? 'warn' : '';
323
+ document.getElementById('handoffHelp').textContent = tr('sessionDetails');
324
+ const claim = document.getElementById('claimControlBtn');
325
+ claim.hidden = writable;
326
+ claim.disabled = activeConnectionState !== 'connected' || !sessionActionTokens.get(active);
327
+ claim.textContent = tr(control.has_controller ? 'handoff.takeover' : 'handoff.claim');
328
+ document.getElementById('releaseControlBtn').hidden = !writable;
329
+ const reconnect = document.getElementById('reconnectBtn');
330
+ reconnect.disabled = !active || activeConnectionState === 'connecting';
331
+ reconnect.hidden = activeConnectionState === 'connected';
332
+ const clients = activeLocalClients ?? meta?.local_clients;
333
+ const count = typeof clients === 'number' ? clients : Array.isArray(clients) ? clients.length : Number(clients?.count || 0);
334
+ document.getElementById('handoffDetail').textContent = (meta?.type === 'tmux' ? (clients?.state === 'unknown' ? tr('handoff.localUnknown') : count ? tr('handoff.localClients') + ': ' + count : tr('handoff.noLocalClients')) + ' · ' + tr('handoff.localBoundary') + ' ' : '') + tr('handoff.leaveHelp');
335
+ const stop = document.getElementById('stopBtn');
336
+ stop.hidden = meta?.type === 'native';
337
+ stop.disabled = !writable;
338
+ stop.dataset.i18n = meta?.type === 'tmux' ? 'handoff.detach' : 'handoff.terminate';
339
+ stop.textContent = tr(stop.dataset.i18n);
340
+ document.querySelectorAll('[data-terminal-action]').forEach((button) => { button.disabled = !writable || meta?.type === 'app-server' || meta?.type === 'native'; });
341
+ document.querySelectorAll('[data-action]').forEach((button) => {
342
+ if (!['status', 'state', 'inbox'].includes(button.dataset.action)) button.disabled = !writable;
343
+ });
344
+ renderDraftState();
345
+ window.hccCodex?.sync();
346
+ window.hccNative?.sync();
347
+ window.hccHistory?.sync();
348
+ window.hccReview?.sync();
349
+ window.hccTerminalFind?.sync();
350
+ window.hccWorkspace?.sync();
351
+ window.hccAgentDefaults?.sync();
352
+ document.getElementById('openReviewDialog').disabled = !meta;
353
+ }
354
+
355
+ function disconnectWebSocket() {
356
+ clearTimeout(wsReconnectTimer);
357
+ wsReconnectTimer = null;
358
+ wsReconnectTarget = null;
359
+ const previous = ws;
360
+ ws = null;
361
+ if (active) {
362
+ handoffStore.uncertain(currentProject, active);
363
+ sessionActionTokens.delete(active);
364
+ sessionControls.delete(active);
365
+ }
366
+ activeConnectionState = 'offline';
367
+ lastSentTerminalSize = null;
368
+ previous?.close();
369
+ }
370
+
371
+ function restoreSelection() {
372
+ const explicit = initialParams.get('session');
373
+ initialParams.delete('session');
374
+ const selected = explicit ? { type: 'managed', id: explicit } : handoffStore.selected(currentProject);
375
+ if (selected) {
376
+ if (selected.type === 'managed' && sessions.some((item) => item.id === selected.id && item.status === 'running')) return connectManaged(selected.id);
377
+ if (selected.type === 'detected' && detected.some((item) => item.id === selected.id)) return connectDetected(selected.id);
378
+ document.getElementById('activeMeta').textContent = tr('handoff.missingSelection');
379
+ return;
380
+ }
381
+ const first = sessions.find((item) => item.status === 'running');
382
+ if (first) connectManaged(first.id);
383
+ }
384
+
385
+ window.hccHandoff = {
386
+ get active() { return active; },
387
+ get projectRoot() { return currentProject; },
388
+ get projectIdentity() { return projectRequests.rootIdentity(); },
389
+ get draftScope() { return window.hccDraftScope || ''; },
390
+ get actionToken() { return sessionActionTokens.get(active) || ''; },
391
+ get epoch() { return controlEpoch(); },
392
+ get canControl() { return canControl(); },
393
+ get session() { return sessions.find(item => item.id === active) || null; },
394
+ get sessions() { return sessions; },
395
+ refreshSessions: () => refreshSessions(), openManaged: id => connectManaged(id), openDialog, closeDialog,
396
+ api: (...args) => api(...args), tr, esc
397
+ };
398
+ installCodexPanel();
399
+ installHistory();
400
+ installReview();
401
+ installNativePanel();
402
+ installAgentDefaults();
403
+ installFiles();
404
+ document.getElementById('claimControlBtn').addEventListener('click', () => {
405
+ if (ws?.readyState !== WebSocket.OPEN) return;
406
+ const target = active, socket = ws, epoch = controlEpoch();
407
+ const takeover = Boolean(sessionControls.get(active)?.has_controller);
408
+ if (takeover && !confirm(lang === 'zh' ? '接管后,其他浏览器窗口将转为只读。当前任务继续运行。是否接管?' : 'Taking control makes the other browser window read only. The current task keeps running. Continue?')) return;
409
+ if (active !== target || ws !== socket) return;
410
+ socket.send(JSON.stringify({ type: 'control', action: 'claim', action_token: sessionActionTokens.get(target) || '', epoch, force: takeover }));
411
+ });
412
+ document.getElementById('releaseControlBtn').addEventListener('click', () => {
413
+ if (!canControl()) return;
414
+ ws.send(JSON.stringify({ type: 'control', action: 'release', action_token: sessionActionTokens.get(active) || '', epoch: controlEpoch() }));
415
+ });
416
+ document.getElementById('reconnectBtn').addEventListener('click', () => {
417
+ if (!active) return;
418
+ const id = active;
419
+ disconnectWebSocket();
420
+ wsReconnectFailures = 0;
421
+ openWs(id);
422
+ });
423
+ document.getElementById('terminalDraft').addEventListener('input', (event) => {
424
+ if (!active) return;
425
+ draftReceipt = false;
426
+ handoffStore.saveDraft(currentProject, active, event.target.value);
427
+ renderDraftState();
428
+ });
429
+ function submitTerminalDraft(event) {
430
+ event.preventDefault();
431
+ if (!active || !canControl()) return;
432
+ const textarea = document.getElementById('terminalDraft');
433
+ const text = textarea.value;
434
+ if (!text || handoffStore.draft(currentProject, active).pending) return;
435
+ const inputId = window.crypto?.randomUUID?.() || 'input-' + Date.now() + '-' + Math.random().toString(36).slice(2);
436
+ handoffStore.saveDraft(currentProject, active, text);
437
+ handoffStore.submit(currentProject, active, inputId, text);
438
+ try {
439
+ if (!sendTerminalInput(text + '\r', inputId)) handoffStore.uncertain(currentProject, active);
440
+ } catch {
441
+ handoffStore.uncertain(currentProject, active);
442
+ }
443
+ renderDraftState();
444
+ }
445
+ document.getElementById('terminalComposer').addEventListener('submit', submitTerminalDraft);
446
+ document.getElementById('terminalDraft').addEventListener('keydown', (event) => {
447
+ if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') submitTerminalDraft(event);
448
+ });
449
+ document.getElementById('reviewDraftBtn').addEventListener('click', () => {
450
+ if (!active) return;
451
+ handoffStore.review(currentProject, active);
452
+ draftReceipt = false;
453
+ renderDraftState();
454
+ });
455
+ window.addEventListener('pagehide', () => disconnectWebSocket());
456
+
457
+ function reportPollError(error) { if (error.name !== 'AbortError') console.error(error); }
458
+
459
+ function handleUiError(error, action = '') {
460
+ if (error.name === 'AbortError') return;
461
+ showActionResult({ action, peer: activePeerInfo()?.peerId || '', error: error.detail || error.message, errorCode: error.code });
462
+ }
463
+
464
+ function renderProjects() {
465
+ const select = document.getElementById('projectSelect');
466
+ select.innerHTML = projects.map((p) =>
467
+ '<option value="' + esc(p.root) + '">' + esc((p.name || p.root) + ' · ' + p.root) + '</option>'
468
+ ).join('');
469
+ if (currentProject) select.value = currentProject;
470
+ document.getElementById('sessionKindFilter').value = sessionKindFilter;
471
+ window.hccCommandPalette?.refresh();
472
+ }
473
+
474
+ function kindMatches(item) {
475
+ const kind = ['claude', 'codex', 'dsh', 'shell'].includes(item.kind) ? item.kind : 'other';
476
+ return sessionKindFilter === 'all' || kind === sessionKindFilter;
477
+ }
478
+
479
+ function syncStartModeOptions() { window.hccAgentStart?.sync(); }
480
+
481
+ async function loadProjects() {
482
+ const data = projectRequests.rootIdentity()
483
+ ? await api('/api/projects')
484
+ : await api('/api/projects/select',
485
+ { method: 'POST', explicitSelection: true });
486
+ if (!data.project_identity) throw Object.assign(new Error('Project selection has no directory identity'), { code: 'PROJECT_IDENTITY_REQUIRED' });
487
+ projects = data.projects || [];
488
+ // State-sync identities use the server's canonical project path, even
489
+ // when navigation supplied an alias or a path with a trailing slash.
490
+ currentProject = data.current?.root || currentProject || projects[0]?.root || '';
491
+ projectRequests.setRoot(currentProject, data.project_identity);
492
+ renderProjects();
493
+ updateLocationProject();
494
+ }
495
+
496
+ async function switchProject(root, selection = null) {
497
+ if (paneMode && root !== currentProject) return;
498
+ const visit = ++projectSelectionVisit;
499
+ const selected = selection || await api('/api/projects/select?root=' + encodeURIComponent(root),
500
+ { method: 'POST', explicitSelection: true });
501
+ if (visit !== projectSelectionVisit) return;
502
+ const selectedRoot = selected.current?.root || selected.project?.root;
503
+ if (!selectedRoot || !selected.project_identity) {
504
+ throw Object.assign(new Error('Project selection has no directory identity'), { code: 'PROJECT_IDENTITY_REQUIRED' });
505
+ }
506
+ window.hccAgentStart?.reset();
507
+ window.hccAgentDefaults?.reset();
508
+ window.hccFiles?.reset();
509
+ window.hccWorkspace?.reset();
510
+ disconnectWebSocket();
511
+ window.hccHistory?.reset();
512
+ window.hccReview?.reset();
513
+ currentProject = selectedRoot;
514
+ projectRequests.setRoot(selectedRoot, selected.project_identity);
515
+ projects = selected.projects || projects;
516
+ renderProjects();
517
+ updateLocationProject();
518
+ active = null;
519
+ activeDetected = null;
520
+ activeType = 'managed';
521
+ sessions = [];
522
+ sessionsLoaded = false;
523
+ detected = [];
524
+ renderSections();
525
+ document.getElementById('rootPath').textContent = selectedRoot;
526
+ lastStateData = null;
527
+ lastStateRoot = '';
528
+ window.hccWorkbench.reset();
529
+ term.reset();
530
+ document.getElementById('codexPanel').hidden = true;
531
+ document.getElementById('nativePanel').hidden = true;
532
+ document.getElementById('terminalComposer').hidden = true;
533
+ renderHandoff();
534
+ clearSessionIdentity();
535
+ document.getElementById('activeTitle').textContent = tr('noSessionSelected');
536
+ document.getElementById('activeMeta').textContent = tr('startOrSelect');
537
+ await Promise.all([refreshSessions(), refreshDetected(), refreshState()]);
538
+ if (currentProject !== selectedRoot) return;
539
+ restoreSelection();
540
+ }
541
+
542
+ function esc(text) {
543
+ return String(text ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]));
544
+ }
545
+
546
+ function fmtTime(ts) {
547
+ if (!ts) return '';
548
+ return new Date(ts * 1000).toLocaleTimeString(lang === 'zh' ? 'zh-CN' : 'en');
549
+ }
550
+
551
+ function badgeClass(status) {
552
+ return String(status || '').toLowerCase().replace(/[^a-z0-9_-]/g, '');
553
+ }
554
+
555
+ function fmtAge(age) {
556
+ const n = Number(age);
557
+ if (!Number.isFinite(n)) return '?';
558
+ return Math.max(0, Math.round(n)) + (lang === 'zh' ? '秒' : 's');
559
+ }
560
+
561
+ function sessionPeerId(session) {
562
+ return session?.peer_id || session?.id || '';
563
+ }
564
+
565
+ function sessionDisplayTitle(session) {
566
+ return session?.task?.title || session?.name || sessionPeerId(session);
567
+ }
568
+
569
+ function sessionRuntimeNote(session) {
570
+ const target = sessionRuntimeTarget(session);
571
+ return target ? tr('runtime') + '=' + target : '';
572
+ }
573
+
574
+ function sessionBinding(session) {
575
+ return session?.binding || {};
576
+ }
577
+
578
+ function sessionRuntimeTarget(session) {
579
+ const binding = sessionBinding(session);
580
+ return binding.runtime_target || session?.pane || session?.id || '';
581
+ }
582
+
583
+ function runtimeTargetText(session) {
584
+ return tr('runtime') + '=' + (sessionRuntimeTarget(session) || tr('unknown'));
585
+ }
586
+
587
+ function sessionProvider(session) {
588
+ const binding = sessionBinding(session);
589
+ return binding.provider || session?.kind || 'other';
590
+ }
591
+
592
+ function sessionProviderSessionValue(session) {
593
+ const binding = sessionBinding(session);
594
+ return session?.provider_session_label || binding.provider_session_id || binding.provider_session_name || '';
595
+ }
596
+
597
+ function providerSessionKnown(session) {
598
+ return session?.provider_session_known === true || Boolean(sessionProviderSessionValue(session));
599
+ }
600
+
601
+ function sessionProviderSessionText(session) {
602
+ const value = sessionProviderSessionValue(session);
603
+ return tr('providerSession') + '=' + sessionProvider(session) + ':' + (value || tr('unknown'));
604
+ }
605
+
606
+ function sessionCardDetailText(session) {
607
+ return [
608
+ runtimeTargetText(session),
609
+ sessionProviderSessionText(session),
610
+ session?.cwd || ''
611
+ ].filter(Boolean).join(' · ');
612
+ }
613
+
614
+ function sessionMetaText(session) {
615
+ return [
616
+ runtimeTargetText(session),
617
+ sessionProviderSessionText(session),
618
+ session?.capabilities?.structured_turns ? 'App Server' : '',
619
+ session?.command ? tr('command') + '=' + session.command : '',
620
+ session?.cwd || ''
621
+ ].filter(Boolean).join(' · ');
622
+ }
623
+
624
+ function statusText(status) {
625
+ const value = String(status || 'unknown');
626
+ return tr('status.' + value.toLowerCase(), value);
627
+ }
628
+
629
+ function lockLabel(lock) {
630
+ const base = lock.base_resource || lock.resource || '';
631
+ const scope = lock.scope || '*';
632
+ return scope === '*' ? base : base + ' [' + scope + ']';
633
+ }
634
+
635
+ function managedPeerId(id) {
636
+ return sessionPeerId(sessions.find((s) => s.id === id)) || id;
637
+ }
638
+
639
+ function peerIsActive(peer, basisNow = lastStateNow) {
640
+ const age = Number(peer?.age_sec);
641
+ if (Number.isFinite(age)) return age <= activePeerTtl;
642
+ const seen = Number(peer?.last_seen_at || 0);
643
+ const t = Number(basisNow || 0) || Math.floor(Date.now() / 1000);
644
+ return seen > 0 && (t - seen) <= activePeerTtl;
645
+ }
646
+
647
+ function dshCoordinationPeer(peer) {
648
+ return (peer?.provider || peer?.kind) === 'dsh' && ['hook', 'cordis'].includes(peer?.transport);
649
+ }
650
+
651
+ function detectedPeerCanStop(peer) {
652
+ if (dshCoordinationPeer(peer)) return false;
653
+ const status = String(peer?.status || '').toLowerCase();
654
+ if (['exited', 'detached'].includes(status)) return false;
655
+ return peerIsActive(peer);
656
+ }
657
+
658
+ function peerStateView(peer, runtime = null, basisNow = lastStateNow) {
659
+ const activity = peer?.status || 'unknown';
660
+ const liveness = peerIsActive(peer, basisNow) ? 'active' : 'stale';
661
+ const age = fmtAge(peer?.age_sec);
662
+ const branch = peer?.branch ? ' ' + tr('branch') + '=' + peer.branch : '';
663
+ if (runtime) {
664
+ return {
665
+ label: runtime.status || 'running',
666
+ detail: tr('peer') + '=' + statusText(activity) + ' ' + statusText(liveness) + ' ' + tr('age') + '=' + age + branch
667
+ };
668
+ }
669
+ if (liveness === 'stale') {
670
+ return {
671
+ label: 'stale',
672
+ detail: tr('lastSeen') + '=' + statusText(activity) + ' ' + tr('age') + '=' + age + branch
673
+ };
674
+ }
675
+ return {
676
+ label: activity,
677
+ detail: statusText(liveness) + ' ' + tr('age') + '=' + age + branch
678
+ };
679
+ }
680
+
681
+ function taskOwnerStateText(task) {
682
+ if (!task?.owner) return '';
683
+ if (task.owner_stale) {
684
+ if (task.takeover_ready) return tr('ownerStaleNoLock');
685
+ const locks = Number(task.related_lock_count || 0);
686
+ return locks ? tr('ownerStaleLocks') + ': ' + locks : tr('ownerStale');
687
+ }
688
+ if (task.owner_active) return tr('ownerActive');
689
+ return '';
690
+ }
691
+
692
+ function bodyPinned(el) {
693
+ if (!el) return false;
694
+ return el.scrollTop + el.clientHeight >= el.scrollHeight - 8;
695
+ }
696
+
697
+ function stateCardCollapsed(section) {
698
+ return hccUi.safeGet('hcc.stateCard.' + section + '.collapsed') === '1';
699
+ }
700
+
701
+ function stateCardHtml(section, title, count, bodyHtml) {
702
+ const collapsed = stateCardCollapsed(section);
703
+ return `
704
+ <div class="card state-card ${collapsed ? 'state-card-collapsed' : ''}" data-section="${esc(section)}">
705
+ <button class="state-card-toggle" type="button" aria-expanded="${collapsed ? 'false' : 'true'}">
706
+ <span class="state-card-toggle-title"><strong>${esc(title)}</strong> <span class="badge">${esc(count)}</span></span>
707
+ <span class="state-card-chevron">⌄</span>
708
+ </button>
709
+ <div class="body">${bodyHtml}</div>
710
+ </div>`;
711
+ }
712
+
713
+ function bindStateCardToggles() {
714
+ document.querySelectorAll('.state-card[data-section] .state-card-toggle').forEach((button) => {
715
+ button.addEventListener('click', () => {
716
+ const card = button.closest('.state-card[data-section]');
717
+ if (!card) return;
718
+ const section = card.dataset.section || '';
719
+ const collapsed = !card.classList.contains('state-card-collapsed');
720
+ card.classList.toggle('state-card-collapsed', collapsed);
721
+ button.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
722
+ hccUi.safeSet('hcc.stateCard.' + section + '.collapsed', collapsed ? '1' : '0');
723
+ });
724
+ });
725
+ }
726
+
727
+ function renderTimelineItem(item) {
728
+ const meta = [
729
+ item.source + ':' + item.source_id,
730
+ item.task_id ? tr('task') + ' #' + item.task_id : '',
731
+ item.thread_id ? tr('thread') + ' #' + item.thread_id : '',
732
+ item.direction || '',
733
+ fmtTime(item.ts)
734
+ ].filter(Boolean).join(' · ');
735
+ return `
736
+ <div class="item timeline-item">
737
+ <strong>${esc(item.title || item.kind || item.source)} <span class="badge">${esc(item.kind || item.source)}</span></strong>
738
+ <span>${esc(meta)}</span>
739
+ ${item.text ? '<span>' + esc(item.text) + '</span>' : ''}
740
+ </div>`;
741
+ }
742
+
743
+ document.getElementById('projectSelect').addEventListener('change', (event) => {
744
+ switchProject(event.target.value).catch(handleUiError);
745
+ });
746
+ document.getElementById('sessionKindFilter').addEventListener('change', (event) => {
747
+ sessionKindFilter = event.target.value || 'all';
748
+ updateLocationProject();
749
+ renderSections();
750
+ });
751
+ document.getElementById('sessionSearch').addEventListener('input', (event) => {
752
+ sessionSearchQuery = event.target.value;
753
+ renderSections();
754
+ });
755
+ document.getElementById('sessionStatusFilter').addEventListener('change', (event) => {
756
+ sessionStatusFilter = event.target.value || 'all';
757
+ renderSections();
758
+ });
759
+ document.getElementById('projectForm').addEventListener('submit', async (event) => {
760
+ event.preventDefault();
761
+ const button = document.getElementById('addProjectBtn');
762
+ if (button.disabled) return;
763
+ button.disabled = true;
764
+ document.getElementById('openProjectDialog').disabled = true;
765
+ const errorElement = document.getElementById('projectDialogError');
766
+ const originalProject = currentProject;
767
+ errorElement.hidden = true;
768
+ try {
769
+ const input = document.getElementById('projectPath');
770
+ const root = input.value.trim();
771
+ if (!root) return;
772
+ const added = await api('/api/projects?root=' + encodeURIComponent(root),
773
+ { method: 'POST', explicitSelection: true, body: JSON.stringify({ root }) });
774
+ if (input.value.trim() === root) input.value = '';
775
+ if (projectDialog.hidden || currentProject !== originalProject) return;
776
+ await switchProject(added.project.root, added);
777
+ if (!projectDialog.hidden) closeDialog(projectDialog);
778
+ } catch (error) {
779
+ errorElement.textContent = tr('error.' + error.code, error.detail || error.message);
780
+ errorElement.hidden = false;
781
+ } finally { button.disabled = false; document.getElementById('openProjectDialog').disabled = false; }
782
+ });
783
+
784
+ function clearSessionFilters() {
785
+ sessionSearchQuery = '';
786
+ sessionStatusFilter = 'all';
787
+ sessionKindFilter = 'all';
788
+ document.getElementById('sessionSearch').value = '';
789
+ document.getElementById('sessionStatusFilter').value = 'all';
790
+ document.getElementById('sessionKindFilter').value = 'all';
791
+ updateLocationProject();
792
+ renderSections();
793
+ document.getElementById('sessionSearch').focus();
794
+ }
795
+
796
+ function sessionMatchesSearch(item) {
797
+ const words = sessionSearchQuery.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
798
+ if (!words.length) return true;
799
+ const searchable = [item.name, item.task?.title, item.id, item.peer_id, item.kind, item.provider, item.cwd, item.worktree, item.command,
800
+ item.type, item.transport, item.branch, item.provider_session_label, item.binding?.runtime_target,
801
+ item.binding?.provider, item.binding?.provider_session_id, item.binding?.provider_session_name]
802
+ .filter(Boolean).join(' ').toLocaleLowerCase();
803
+ return words.every((word) => searchable.includes(word));
804
+ }
805
+
806
+ function sessionDetailsHtml(key, rows) {
807
+ return '<details class="session-details" data-detail-key="' + esc(key) + '"><summary data-focus-key="detail:' + esc(key) + '">' + esc(tr('sessionDetails')) + '</summary><dl>' +
808
+ rows.filter(([, value]) => value !== undefined && value !== null && value !== '').map(([label, value]) =>
809
+ '<dt>' + esc(label) + '</dt><dd>' + esc(value) + '</dd>').join('') + '</dl></details>';
810
+ }
811
+
812
+ // ── Sessions rendering (managed + detected) ──────────────────────────
813
+ function renderSections() {
814
+ const box = document.getElementById('sessions');
815
+ const filtered = Boolean(sessionSearchQuery.trim() || sessionStatusFilter !== 'all' || sessionKindFilter !== 'all');
816
+ const searching = Boolean(sessionSearchQuery.trim());
817
+ const visibleSessions = sessions.filter((s) => kindMatches(s) && sessionMatchesSearch(s) &&
818
+ (sessionStatusFilter === 'all' || s.status === 'running'))
819
+ .sort((a, b) => Number(b.status === 'running') - Number(a.status === 'running') ||
820
+ String(a.name || sessionPeerId(a)).localeCompare(String(b.name || sessionPeerId(b))));
821
+ const visibleDetected = detected.filter((p) => kindMatches(p) && sessionMatchesSearch(p) &&
822
+ (sessionStatusFilter === 'all' || peerIsActive(p)))
823
+ .sort((a, b) => Number(b.last_seen_at || 0) - Number(a.last_seen_at || 0) || String(a.id).localeCompare(String(b.id)));
824
+ const activeDetectedPeers = visibleDetected.filter((p) => peerIsActive(p));
825
+ const staleDetectedPeers = visibleDetected.filter((p) => !peerIsActive(p));
826
+ const noMatches = '<div class="empty session-empty"><p>' + esc(tr('noMatchingSessions')) + '</p><button type="button" data-navigation="clear-session-filters">' + esc(tr('clearSessionFilters')) + '</button></div>';
827
+ const startEmpty = '<div class="empty session-empty"><p>' + esc(tr('noActiveSessions')) + '</p><button class="primary" type="button" data-navigation="open-start">' + esc(tr('createFirstSession')) + '</button></div>';
828
+ const manHtml = visibleSessions.length
829
+ ? visibleSessions.map((s) => {
830
+ const peerId = sessionPeerId(s);
831
+ const title = sessionDisplayTitle(s);
832
+ const path = s.cwd || '';
833
+ return `
834
+ <div class="session ${active === s.id && activeType === 'managed' ? 'active' : ''}" data-id="${esc(s.id)}" data-type="managed">
835
+ <div class="row"><button class="session-select session-title" type="button" data-focus-key="managed:${esc(s.id)}" aria-label="${esc(title)}" aria-pressed="${active === s.id && activeType === 'managed'}" title="${esc(title)}"><strong>${esc(title)}</strong></button><span class="badge ${badgeClass(s.status)}">${esc(statusText(s.status))}</span></div>
836
+ <div class="session-subtitle"><span class="session-provider">${esc(sessionProvider(s))}</span><span class="path" title="${esc(path)}">${esc(path.split('/').filter(Boolean).slice(-2).join('/'))}</span></div>
837
+ ${sessionDetailsHtml('managed:' + s.id, [[tr('peer'), peerId], [tr('sessionId'), s.id], [tr('runtime'), sessionRuntimeTarget(s)], [tr('providerSession'), sessionProvider(s) + ':' + (sessionProviderSessionValue(s) || tr('unknown'))], [tr('handoff.transport'), s.type || 'pty'], [tr('command'), s.command], [tr('cwd'), path]])}
838
+ </div>`;
839
+ }).join('')
840
+ : filtered ? '' : startEmpty;
841
+
842
+ const renderDetectedPeer = (p) => {
843
+ const state = peerStateView(p);
844
+ const canStop = detectedPeerCanStop(p);
845
+ return `
846
+ <div class="session ${activeDetected === p.id && activeType === 'detected' ? 'active' : ''}" data-id="${esc(p.id)}" data-type="detected">
847
+ <div class="row">
848
+ <button class="session-select session-title" type="button" data-focus-key="detected:${esc(p.id)}" aria-label="${esc(p.name || p.id)}" aria-pressed="${activeDetected === p.id && activeType === 'detected'}" title="${esc(p.name || p.id)}"><strong>${esc(p.name || p.id)}</strong></button>
849
+ <div style="display:flex;gap:6px;align-items:center">
850
+ <span class="badge ${badgeClass(state.label)}" title="${esc(state.detail)}">${esc(statusText(state.label))}</span>
851
+ ${dshCoordinationPeer(p) ? '' : canStop ? `
852
+ <button class="session-action stop-detected-btn" data-action="stop-detected" data-id="${esc(p.id)}" data-focus-key="stop:${esc(p.id)}" title="${esc(tr('action.stopPeer'))}" aria-label="${esc(tr('action.stopPeer')) + ' ' + esc(p.id)}" type="button">✕</button>
853
+ ` : `
854
+ <button class="session-action" data-action="restart-detected" data-id="${esc(p.id)}" data-focus-key="restart:${esc(p.id)}" title="${esc(tr('action.restartPeer'))}" aria-label="${esc(tr('action.restartPeer')) + ' ' + esc(p.id)}" type="button">↻</button>
855
+ `}
856
+ </div>
857
+ </div>
858
+ <div class="session-subtitle"><span class="session-provider">${esc(p.provider || p.kind || 'other')}</span><span class="path" title="${esc(p.worktree || p.cwd || '')}">${esc((p.worktree || p.cwd || '').split('/').filter(Boolean).slice(-2).join('/'))}</span></div>
859
+ ${sessionDetailsHtml('detected:' + p.id, [[tr('peer'), p.id], [tr('kind'), p.kind], [tr('status'), state.detail], [tr('cwd'), p.worktree || p.cwd], [tr('command'), p.command], [tr('branch'), p.branch], [tr('pid'), p.pid]])}
860
+ </div>`;
861
+ };
862
+ const activeDetectedHtml = activeDetectedPeers.length
863
+ ? activeDetectedPeers.map(renderDetectedPeer).join('')
864
+ : filtered ? '' : '<div class="empty">' + esc(tr('noActiveDetectedPeers')) + '</div>';
865
+ const staleToggleLabel = showStaleDetected ? tr('hideStale') : tr('showStale');
866
+ const staleDetectedHtml = (showStaleDetected || searching)
867
+ ? (staleDetectedPeers.length ? staleDetectedPeers.map(renderDetectedPeer).join('') : '<div class="empty">' + esc(tr('noDetectedPeers')) + '</div>')
868
+ : '';
869
+
870
+ const savedScroll = box.scrollTop;
871
+ const restoreFocus = preserveFocus(box);
872
+ const summaryFocus = document.activeElement?.tagName === 'SUMMARY' && box.contains(document.activeElement) ? document.activeElement.dataset.focusKey : '';
873
+ const openDetails = new Set([...box.querySelectorAll('details[open]')].map((el) => el.dataset.detailKey));
874
+ document.getElementById('sessionListCount').textContent = tr('sessionListSummary').replace('{count}', visibleSessions.length + activeDetectedPeers.length + ((showStaleDetected || searching) ? staleDetectedPeers.length : 0));
875
+ box.innerHTML = filtered && !visibleSessions.length && !visibleDetected.length ? noMatches : `
876
+ <div class="sec-label">${esc(tr('managed'))} <span class="badge">${visibleSessions.filter(s=>s.status==='running').length} ${esc(tr('running'))}</span></div>
877
+ ${manHtml}
878
+ <div class="sec-label" style="margin-top:10px">${esc(tr('activeDetected'))} <span class="badge" style="color:var(--warn)">${activeDetectedPeers.length}</span></div>
879
+ ${activeDetectedHtml}
880
+ <div class="sec-label" style="margin-top:10px">${esc(tr('staleDetected'))} <span class="badge">${staleDetectedPeers.length}</span><span class="sec-spacer"></span><button id="toggleStaleDetected" type="button" ${searching ? 'hidden' : ''}>${esc(staleToggleLabel)}</button></div>
881
+ ${staleDetectedHtml}
882
+ `;
883
+ box.querySelectorAll('details').forEach((el) => { el.open = openDetails.has(el.dataset.detailKey); });
884
+ box.scrollTop = savedScroll;
885
+ restoreFocus();
886
+ if (summaryFocus) [...box.querySelectorAll('summary')].find((el) => el.dataset.focusKey === summaryFocus)?.focus({ preventScroll: true });
887
+ box.querySelectorAll('[data-navigation="clear-session-filters"]').forEach((button) => button.addEventListener('click', clearSessionFilters));
888
+ window.hccCommandPalette?.refresh();
889
+ box.querySelectorAll('[data-navigation="open-start"]').forEach((button) => button.addEventListener('click', openStartDialog));
890
+ const staleToggle = document.getElementById('toggleStaleDetected');
891
+ if (staleToggle) {
892
+ staleToggle.addEventListener('click', () => {
893
+ showStaleDetected = !showStaleDetected;
894
+ hccUi.safeSet('hcc.showStaleDetected', showStaleDetected ? '1' : '0');
895
+ renderSections();
896
+ });
897
+ }
898
+ box.querySelectorAll('.session[data-type="managed"]').forEach((el) => {
899
+ el.addEventListener('click', (event) => {
900
+ if (event.target.closest('details, [data-action]')) return;
901
+ connectManaged(el.dataset.id);
902
+ });
903
+ });
904
+ box.querySelectorAll('.session[data-type="detected"]').forEach((el) => {
905
+ el.addEventListener('click', (e) => {
906
+ if (e.target.closest('details, [data-action]')) return;
907
+ connectDetected(el.dataset.id);
908
+ });
909
+ });
910
+ box.querySelectorAll('[data-action="stop-detected"]').forEach((btn) => {
911
+ btn.addEventListener('click', (e) => {
912
+ e.stopPropagation();
913
+ const peerId = btn.dataset.id;
914
+ document.getElementById('stopDialogTitle').textContent = tr('stop') + ' ' + peerId + '?';
915
+ stopKillCb.checked = false;
916
+ stopKillCb.parentElement.parentElement.hidden = false;
917
+ stopConfirmBtn.textContent = tr('stop');
918
+ document.getElementById('stopDialogMeta').textContent = tr('detectedPeer');
919
+ stopDialog._action = 'detected';
920
+ stopDialog._peerId = peerId;
921
+ openDialog(stopDialog, stopCancelBtn);
922
+ });
923
+ });
924
+ box.querySelectorAll('[data-action="restart-detected"]').forEach((btn) => {
925
+ btn.addEventListener('click', async (e) => {
926
+ try {
927
+ e.stopPropagation();
928
+ const peerId = btn.dataset.id;
929
+ await api('/api/detected/' + encodeURIComponent(peerId) + '/restart', { method: 'POST', body: '{}' });
930
+ await Promise.all([refreshSessions(), refreshDetected()]);
931
+ } catch (error) { handleUiError(error); }
932
+ });
933
+ });
934
+ }
935
+
936
+ async function refreshSessions() {
937
+ const root = currentProject;
938
+ const data = await api('/api/sessions');
939
+ if (currentProject !== root) return;
940
+ sessions = data.sessions || [];
941
+ sessionsLoaded = true;
942
+ renderSections();
943
+ if (active && activeType === 'managed') {
944
+ const meta = sessions.find((s) => s.id === active);
945
+ renderActiveSession(meta || { id: active });
946
+ if (meta) {
947
+ activeLocalClients = meta.local_clients ?? activeLocalClients;
948
+ }
949
+ }
950
+ connText(ws?.readyState === WebSocket.OPEN ? 'attached' : activeDetected ? 'coordinationOnly' : active ? (activeConnectionState === 'connecting' || wsReconnectTimer !== null ? 'reconnecting' : 'offline') : 'online');
951
+ renderHandoff();
952
+ scheduleWsReconnect();
953
+ }
954
+
955
+ async function refreshDetected() {
956
+ const root = currentProject;
957
+ try {
958
+ const data = await api('/api/detected');
959
+ if (currentProject !== root) return;
960
+ activePeerTtl = Number(data.active_peer_ttl || activePeerTtl);
961
+ lastStateNow = Number(data.now || lastStateNow);
962
+ detected = data.detected || [];
963
+ renderSections();
964
+ if (activeType === 'detected' && activeDetected) renderDetectedHeader(detected.find((peer) => peer.id === activeDetected) || { id: activeDetected });
965
+ } catch {}
966
+ }
967
+
968
+ // ── Project state panel ───────────────────────────────────────────────
969
+ function renderState(data) {
970
+ lastStateData = data;
971
+ const stateRoot = data.root || '';
972
+ document.getElementById('rootPath').textContent = stateRoot;
973
+ activePeerTtl = Number(data.active_peer_ttl || activePeerTtl);
974
+ lastStateNow = Number(data.now || lastStateNow);
975
+ const state = document.getElementById('state');
976
+ const restoreFocus = preserveFocus(state);
977
+ const preserveScroll = lastStateRoot === stateRoot;
978
+ const savedStateScroll = preserveScroll ? state.scrollTop : 0;
979
+ const savedCardScroll = preserveScroll
980
+ ? new Map(
981
+ [...state.querySelectorAll('.state-card[data-section]')].map((card) => [
982
+ card.dataset.section,
983
+ {
984
+ top: card.querySelector('.body')?.scrollTop || 0,
985
+ pinned: bodyPinned(card.querySelector('.body'))
986
+ }
987
+ ])
988
+ )
989
+ : new Map();
990
+ const runtimeById = new Map();
991
+ for (const session of sessions || []) {
992
+ runtimeById.set(session.id, session);
993
+ const peerId = sessionPeerId(session);
994
+ if (peerId) runtimeById.set(peerId, session);
995
+ }
996
+ const tasksData = data.tasks || [];
997
+ const peersData = data.peers || [];
998
+ const locksData = data.locks || [];
999
+ const messagesData = data.messages || [];
1000
+ const timelineData = data.timeline || [];
1001
+ const automation = data.automation || {};
1002
+ const nextAction = automation.next_action || {};
1003
+ const tasks = tasksData.map((t) => `
1004
+ <div class="item"><strong>#${t.id} ${esc(t.title)}</strong><span>${esc(statusText(t.status))} ${esc(tr('owner'))}=${esc(t.owner || '')} ${esc(tr('assignee'))}=${esc(t.assignee || '')}${taskOwnerStateText(t) ? ' · ' + esc(taskOwnerStateText(t)) : ''}</span></div>
1005
+ `).join('') || '<div class="empty">' + esc(tr('noTasks')) + '</div>';
1006
+ const peers = peersData.map((a) => {
1007
+ const peerRuntime = runtimeById.get(a.id);
1008
+ const peerState = peerStateView(a, peerRuntime, data.now);
1009
+ return `
1010
+ <div class="item"><strong>${esc(a.id)} <span class="badge">${esc(a.kind)}</span> <span class="badge ${badgeClass(peerState.label)}">${esc(statusText(peerState.label))}</span></strong><span>${esc(peerState.detail)}</span></div>
1011
+ `;
1012
+ }).join('') || '<div class="empty">' + esc(tr('noPeers')) + '</div>';
1013
+ const locks = locksData.map((l) => `
1014
+ <div class="item"><strong>${esc(lockLabel(l))}</strong><span>${esc(tr('owner'))}=${esc(l.owner)} ${esc(tr('task'))}=${l.task_id ? '#' + l.task_id : ''}</span></div>
1015
+ `).join('') || '<div class="empty">' + esc(tr('noActiveLocks')) + '</div>';
1016
+ const messages = messagesData.map((m) => `
1017
+ <div class="item"><strong>#${m.id} ${esc(m.sender)} → ${esc(m.recipient || tr('all'))}${m.reply_to ? ' ' + esc(tr('reply')) + ' #' + m.reply_to : ''}</strong><span>${esc(m.body)}</span></div>
1018
+ `).join('') || '<div class="empty">' + esc(tr('noMessages')) + '</div>';
1019
+ const timeline = timelineData.map(renderTimelineItem).join('') || '<div class="empty">' + esc(tr('noTimelineItems')) + '</div>';
1020
+ const actionLines = [
1021
+ '<div class="item"><strong>' + esc(statusText(automation.phase || 'idle')) + '</strong><span>' + esc(nextAction.reason || tr('noImmediateAction')) + '</span></div>',
1022
+ nextAction.command ? '<div class="item"><strong>' + esc(tr('next')) + '</strong><span class="mono">' + esc(nextAction.command) + '</span></div>' : '',
1023
+ (automation.finish_actions || []).length ? '<div class="item"><strong>' + esc(tr('finish')) + '</strong><span>' + esc(automation.finish_actions.map((a) => a.command).join(' | ')) + '</span></div>' : '',
1024
+ (automation.warnings || []).length ? '<div class="item"><strong>' + esc(tr('warnings')) + '</strong><span>' + esc(automation.warnings.join(' | ')) + '</span></div>' : ''
1025
+ ].filter(Boolean).join('');
1026
+ state.innerHTML = [
1027
+ stateCardHtml('automation', tr('nextAction'), statusText(automation.phase || 'idle'), actionLines),
1028
+ stateCardHtml('timeline', tr('timeline'), timelineData.length, timeline),
1029
+ stateCardHtml('messages', tr('messages'), messagesData.length, messages),
1030
+ stateCardHtml('peers', tr('peers'), peersData.length, peers),
1031
+ stateCardHtml('tasks', tr('tasks'), tasksData.length, tasks),
1032
+ stateCardHtml('locks', tr('locks'), locksData.length, locks)
1033
+ ].join('');
1034
+ state.scrollTop = savedStateScroll;
1035
+ for (const [section, saved] of savedCardScroll) {
1036
+ const body = state.querySelector('.state-card[data-section="' + section + '"] .body');
1037
+ if (!body) continue;
1038
+ if (section === 'timeline' && saved.pinned) body.scrollTop = body.scrollHeight;
1039
+ else body.scrollTop = saved.top;
1040
+ }
1041
+ bindStateCardToggles();
1042
+ window.hccWorkbench.apply();
1043
+ restoreFocus();
1044
+ lastStateRoot = stateRoot;
1045
+ }
1046
+
1047
+ async function refreshState() {
1048
+ const root = currentProject;
1049
+ const selected = active;
1050
+ const selectionType = activeType;
1051
+ const peer = active ? managedPeerId(active) : null;
1052
+ const p = peer ? '/api/state?peer=' + encodeURIComponent(peer) : '/api/state';
1053
+ try {
1054
+ const data = await api(p);
1055
+ if (currentProject !== root || active !== selected || activeType !== selectionType) return;
1056
+ renderState(data);
1057
+ window.hccWorkbench.fresh();
1058
+ } catch (error) {
1059
+ if (currentProject === root && active === selected && activeType === selectionType) window.hccWorkbench.stale(error);
1060
+ throw error;
1061
+ }
1062
+ }
1063
+
1064
+ async function refreshDetectedState() {
1065
+ if (!activeDetected) return;
1066
+ const root = currentProject, selected = activeDetected;
1067
+ try {
1068
+ const data = await api('/api/state?peer=' + encodeURIComponent(selected));
1069
+ if (currentProject !== root || activeDetected !== selected || activeType !== 'detected') return;
1070
+ renderState(data);
1071
+ window.hccWorkbench.fresh();
1072
+ } catch (error) {
1073
+ if (currentProject === root && activeDetected === selected && activeType === 'detected') window.hccWorkbench.stale(error);
1074
+ throw error;
1075
+ }
1076
+ }
1077
+
1078
+ async function refreshCurrentState() {
1079
+ if (activeType === 'detected') return refreshDetectedState();
1080
+ return refreshState();
1081
+ }
1082
+
1083
+ async function refreshVisibleData() {
1084
+ if (autoPollInFlight) return;
1085
+ autoPollInFlight = true;
1086
+ window.hccWorkbench.loading();
1087
+ try {
1088
+ await Promise.all([
1089
+ refreshSessions(),
1090
+ refreshDetected(),
1091
+ refreshCurrentState()
1092
+ ]);
1093
+ } finally {
1094
+ autoPollInFlight = false;
1095
+ }
1096
+ }
1097
+
1098
+ async function refreshProjectsQuietly() {
1099
+ if (projectPollInFlight) return;
1100
+ projectPollInFlight = true;
1101
+ try {
1102
+ await loadProjects();
1103
+ } finally {
1104
+ projectPollInFlight = false;
1105
+ }
1106
+ }
1107
+
1108
+ // ── Connect to managed (PTY) session ─────────────────────────────────
1109
+ function connectManaged(id) {
1110
+ if (!paneMode) window.hccWorkspace?.primary();
1111
+ const meta = sessions.find((s) => s.id === id);
1112
+ window.hccReview?.reset();
1113
+ disconnectWebSocket();
1114
+ active = id;
1115
+ activeDetected = null;
1116
+ activeType = 'managed';
1117
+ if (paneMode) { const url = new URL(location.href); url.searchParams.set('session', id); history.replaceState(null, '', url); }
1118
+ handoffStore.select(currentProject, 'managed', id);
1119
+ activeLocalClients = meta?.local_clients ?? null;
1120
+ renderSections();
1121
+ renderActiveSession(meta || { id });
1122
+ const isCodex = meta?.type === 'app-server';
1123
+ const isNative = meta?.type === 'native';
1124
+ document.getElementById('terminal').style.display = isCodex || isNative ? 'none' : '';
1125
+ document.getElementById('codexPanel').hidden = !isCodex;
1126
+ document.getElementById('codexPanel').style.display = isCodex ? '' : 'none';
1127
+ document.getElementById('nativePanel').hidden = !isNative;
1128
+ document.getElementById('terminalComposer').hidden = isCodex || isNative;
1129
+ document.getElementById('detectedPanel').style.display = 'none';
1130
+ document.getElementById('quickBar').style.display = '';
1131
+ setMobileView('terminal');
1132
+ draftReceipt = false;
1133
+ handoffStore.uncertain(currentProject, id);
1134
+ document.getElementById('terminalDraft').value = handoffStore.draft(currentProject, id).text;
1135
+ window.hccTerminalFind?.sync();
1136
+ term.reset();
1137
+ terminalHasContent = false;
1138
+ terminalLastDataAt = 0;
1139
+ terminalLastReplaceAt = 0;
1140
+ terminalLastResizeAt = Date.now();
1141
+ openWs(id);
1142
+ refreshState().catch(reportPollError);
1143
+ }
1144
+
1145
+ function terminalPinned() {
1146
+ try {
1147
+ const b = term.buffer.active;
1148
+ return b.viewportY >= b.baseY;
1149
+ } catch {
1150
+ return true;
1151
+ }
1152
+ }
1153
+
1154
+ function writeTerminalSnapshot(data, pinned = true) {
1155
+ term.reset();
1156
+ terminalHasContent = Boolean(data);
1157
+ terminalLastReplaceAt = Date.now();
1158
+ term.write(data || '', pinned ? () => { term.scrollToBottom(); } : undefined);
1159
+ }
1160
+
1161
+ function shouldApplyTerminalReplace() {
1162
+ const t = Date.now();
1163
+ if (!terminalHasContent) return true;
1164
+ if (t - terminalLastResizeAt <= 1500) return true;
1165
+ // Server fallback replace frames are useful for recovery, but applying
1166
+ // them during normal idle chat causes visible rollback/flicker.
1167
+ return document.visibilityState !== 'visible' &&
1168
+ t - terminalLastDataAt > 30000 &&
1169
+ t - terminalLastReplaceAt > 15000;
1170
+ }
1171
+
1172
+ function openWs(id) {
1173
+ clearTimeout(wsReconnectTimer);
1174
+ wsReconnectTimer = null;
1175
+ wsReconnectTarget = null;
1176
+ if (!projectRequests.rootIdentity()) { connText('error'); return; }
1177
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
1178
+ sessionActionTokens.delete(id);
1179
+ sessionControls.delete(id);
1180
+ activeConnectionState = 'connecting';
1181
+ renderHandoff();
1182
+ const socket = new WebSocket(proto + '://' + location.host + '/ws/terminal/' + encodeURIComponent(id) + requestQuery({ api_version: runtimeApiVersion, state_sync: 1 }));
1183
+ ws = socket;
1184
+ const connectionRoot = currentProject;
1185
+ const receiver = createSessionSync({
1186
+ root: connectionRoot, sessionId: id,
1187
+ onState(state, channel) {
1188
+ if (ws !== socket || active !== id || currentProject !== connectionRoot) return;
1189
+ if (channel === 'native') window.hccNative?.render(state);
1190
+ if (channel === 'codex') window.hccCodex?.render(state);
1191
+ },
1192
+ requestSnapshot(frame) { if (ws === socket && socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(frame)); }
1193
+ });
1194
+ socket.onopen = () => {
1195
+ if (ws !== socket || active !== id) return;
1196
+ activeConnectionState = 'connected';
1197
+ renderHandoff();
1198
+ connText('attached');
1199
+ };
1200
+ socket.onmessage = (event) => {
1201
+ if (ws !== socket || active !== id) return;
1202
+ let msg;
1203
+ try { msg = JSON.parse(event.data); } catch { return; }
1204
+ if (msg.type === 'state_sync') { receiver.receive(msg); return; }
1205
+ const pinned = terminalPinned();
1206
+ // The server streams the tmux pane's raw output, so xterm renders
1207
+ // incrementally (no reset/redraw → no flicker) and the program's own
1208
+ // escape sequences carry the cursor.
1209
+ if (msg.type === 'snapshot') {
1210
+ wsReconnectFailures = 0;
1211
+ if (msg.action_token) sessionActionTokens.set(id, msg.action_token);
1212
+ if (msg.control) sessionControls.set(id, msg.control);
1213
+ if (msg.local_clients != null) activeLocalClients = msg.local_clients;
1214
+ const selected = sessions.find(item => item.id === id);
1215
+ if (selected?.type === 'native' && msg.native_state) window.hccNative?.render(msg.native_state);
1216
+ else if (selected?.type === 'app-server' && msg.state) window.hccCodex?.render(msg.state);
1217
+ renderHandoff();
1218
+ writeTerminalSnapshot(msg.data || '', true);
1219
+ resizeTerm();
1220
+ // Connection recovery never replays terminal input or a draft.
1221
+ }
1222
+ if (msg.type === 'data') {
1223
+ terminalHasContent = true;
1224
+ terminalLastDataAt = Date.now();
1225
+ term.write(msg.data || '', pinned ? () => { term.scrollToBottom(); } : undefined);
1226
+ }
1227
+ if (msg.type === 'replace' && shouldApplyTerminalReplace()) {
1228
+ writeTerminalSnapshot(msg.data || '', pinned);
1229
+ }
1230
+ if (msg.type === 'control') {
1231
+ sessionControls.set(id, msg.control || {});
1232
+ lastSentTerminalSize = null;
1233
+ renderHandoff();
1234
+ resizeTerm();
1235
+ window.hccCodex?.sync();
1236
+ }
1237
+ if (msg.type === 'codex_state') window.hccCodex?.render(msg.state);
1238
+ if (msg.type === 'native_state') window.hccNative?.render(msg.state);
1239
+ if (msg.type === 'input_ack' && handoffStore.acknowledge(currentProject, id, msg.input_id)) {
1240
+ draftReceipt = true;
1241
+ document.getElementById('terminalDraft').value = handoffStore.draft(currentProject, id).text;
1242
+ renderDraftState();
1243
+ }
1244
+ if (msg.type === 'error') {
1245
+ if (msg.control) sessionControls.set(id, msg.control);
1246
+ renderHandoff();
1247
+ handoffStore.uncertain(currentProject, id);
1248
+ renderDraftState();
1249
+ handleUiError({ code: msg.error?.code, message: msg.error?.message || tr('requestFailed') });
1250
+ }
1251
+ if (msg.type === 'exit') { refreshSessions().catch(reportPollError); }
1252
+ };
1253
+ socket.onclose = (event) => {
1254
+ if (ws !== socket) return;
1255
+ sessionActionTokens.delete(id);
1256
+ sessionControls.delete(id);
1257
+ activeConnectionState = 'disconnected';
1258
+ handoffStore.uncertain(currentProject, id);
1259
+ renderHandoff();
1260
+ window.hccCodex?.sync();
1261
+ // ui-4: no reconnect after logout, and stop after repeated failures
1262
+ // (e.g. the session cookie was revoked in another tab) instead of
1263
+ // looping every 2s forever.
1264
+ receiver.reset();
1265
+ if (event.code === 4001 || loggedOut) {
1266
+ connText('signed out');
1267
+ return;
1268
+ }
1269
+ if (navigator.onLine === false) { connText('offline'); return; }
1270
+ wsReconnectFailures += 1;
1271
+ if (wsReconnectFailures > 5) {
1272
+ connText('offline');
1273
+ return;
1274
+ }
1275
+ connText('reconnecting');
1276
+ wsReconnectTarget = { id, root: connectionRoot, socket };
1277
+ scheduleWsReconnect();
1278
+ };
1279
+ }
1280
+
1281
+ function canReconnectWs(target) {
1282
+ return Boolean(target && target === wsReconnectTarget && !loggedOut && navigator.onLine !== false &&
1283
+ wsReconnectFailures > 0 && wsReconnectFailures <= 5 && currentProject === target.root &&
1284
+ activeType === 'managed' && active === target.id && ws === target.socket && ws.readyState === WebSocket.CLOSED);
1285
+ }
1286
+
1287
+ function scheduleWsReconnect() {
1288
+ const target = wsReconnectTarget;
1289
+ if (wsReconnectTimer !== null || !canReconnectWs(target) ||
1290
+ sessions.find(session => session.id === target.id)?.status !== 'running') return;
1291
+ connText('reconnecting');
1292
+ wsReconnectTimer = setTimeout(() => {
1293
+ // A cancelled callback must not clear a newer connection's timer.
1294
+ if (target !== wsReconnectTarget) return;
1295
+ wsReconnectTimer = null;
1296
+ if (!canReconnectWs(target)) return;
1297
+ // Keep this target when the list is temporarily disconnected. A later
1298
+ // successful poll resumes the same bounded backoff without replaying work.
1299
+ if (sessions.find(session => session.id === target.id)?.status === 'running') openWs(target.id);
1300
+ else connText('offline');
1301
+ }, Math.min(12000, 750 * 2 ** (wsReconnectFailures - 1)) * (0.8 + Math.random() * 0.4));
1302
+ }
1303
+
1304
+ // ── Connect to detected (coordination-only) peer ──────────────────────
1305
+ function connectDetected(id) {
1306
+ if (!paneMode) window.hccWorkspace?.primary();
1307
+ const peer = detected.find((p) => p.id === id);
1308
+ window.hccReview?.reset();
1309
+ disconnectWebSocket();
1310
+ active = null;
1311
+ activeDetected = id;
1312
+ activeType = 'detected';
1313
+ handoffStore.select(currentProject, 'detected', id);
1314
+ renderHandoff();
1315
+ renderSections();
1316
+ renderDetectedHeader(peer || { id });
1317
+ document.getElementById('terminal').style.display = 'none';
1318
+ document.getElementById('codexPanel').hidden = true;
1319
+ document.getElementById('nativePanel').hidden = true;
1320
+ document.getElementById('codexPanel').style.display = 'none';
1321
+ document.getElementById('terminalComposer').hidden = true;
1322
+ document.getElementById('detectedPanel').style.display = '';
1323
+ document.getElementById('quickBar').style.display = 'none';
1324
+ setMobileView('terminal');
1325
+ connText('coordinationOnly');
1326
+ renderDetectedPanel(peer || { id });
1327
+ refreshDetectedState().catch(reportPollError);
1328
+ }
1329
+
1330
+ function renderDetectedPanel(peer) {
1331
+ const dp = document.getElementById('detectedPanel');
1332
+ dp.innerHTML = `
1333
+ <div style="padding:16px;display:grid;gap:12px">
1334
+ <div class="card">
1335
+ <h2>${esc(tr('detectedSession'))}</h2>
1336
+ <div class="body">
1337
+ <div class="item"><strong>${esc(tr('peer'))}</strong><span class="mono">${esc(peer.id)}</span></div>
1338
+ <div class="item"><strong>${esc(tr('kind'))}</strong><span>${esc(peer.kind || '')}</span></div>
1339
+ ${dshCoordinationPeer(peer) ? `
1340
+ <div class="item"><strong>${esc(tr('providerSession'))}</strong><span class="mono" style="overflow-wrap:anywhere">${esc(peer.provider_session_id || tr('unknown'))}</span></div>
1341
+ <p style="font-size:12px;color:var(--muted)">${esc(tr('dshCoordinationHelp'))}</p>
1342
+ ` : ''}
1343
+ <div class="item"><strong>${esc(tr('status'))}</strong><span>${esc(statusText(peer.status))}</span></div>
1344
+ <div class="item"><strong>${esc(tr('cwd'))}</strong><span class="mono" style="font-size:11px">${esc(peer.worktree || '')}</span></div>
1345
+ <div class="item"><strong>${esc(tr('pid'))}</strong><span>${esc(peer.pid || tr('unknown'))}</span></div>
1346
+ <div class="item"><strong>${esc(tr('lastSeen'))}</strong><span>${peer.age_sec != null ? esc(peer.age_sec + tr('secondsAgo')) : ''}</span></div>
1347
+ </div>
1348
+ </div>
1349
+ <div class="card">
1350
+ <h2>${esc(tr('sendMessage'))}</h2>
1351
+ <div class="body" style="gap:8px">
1352
+ <div style="font-size:12px;color:var(--muted)">${tr(dshCoordinationPeer(peer) && peer.transport === 'cordis' ? 'dshCordisMessageHelp' : 'messageHelp')}</div>
1353
+ <textarea id="detMsg" rows="3" style="width:100%;background:var(--input-bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:8px;font:inherit;resize:vertical" placeholder="${esc(tr('messageBodyPlaceholder'))}"></textarea>
1354
+ <button class="primary" id="sendDetMsg">${esc(tr('send'))}</button>
1355
+ </div>
1356
+ </div>
1357
+ </div>
1358
+ `;
1359
+ const draftProject = currentProject;
1360
+ const draftKey = 'detected:' + peer.id;
1361
+ document.getElementById('detMsg').value = handoffStore.draft(draftProject, draftKey).text;
1362
+ document.getElementById('detMsg').addEventListener('input', (event) => handoffStore.saveDraft(draftProject, draftKey, event.target.value));
1363
+ document.getElementById('sendDetMsg').addEventListener('click', async () => {
1364
+ try {
1365
+ const body = document.getElementById('detMsg').value.trim();
1366
+ if (!body) return;
1367
+ await api('/api/detected/' + encodeURIComponent(peer.id) + '/msg', {
1368
+ method: 'POST',
1369
+ body: JSON.stringify({ body })
1370
+ });
1371
+ const textarea = document.getElementById('detMsg');
1372
+ if (currentProject === draftProject && activeDetected === peer.id && textarea?.value.trim() === body) {
1373
+ textarea.value = '';
1374
+ handoffStore.saveDraft(draftProject, draftKey, '');
1375
+ }
1376
+ await refreshDetectedState();
1377
+ } catch (error) { handleUiError(error); }
1378
+ });
1379
+ }
1380
+
1381
+ // ── Helpers ───────────────────────────────────────────────────────────
1382
+ function sendLine(text) {
1383
+ sendTerminalInput(text + '\r');
1384
+ }
1385
+
1386
+ function activePeerInfo() {
1387
+ if (!active) return null;
1388
+ const session = sessions.find((s) => s.id === active) || { id: active, kind: 'other', role: 'peer' };
1389
+ return {
1390
+ session,
1391
+ peerId: sessionPeerId(session) || active,
1392
+ // The action token is delivered via the terminal WS snapshot frame, not
1393
+ // the session list (net-05); read it from the per-session store.
1394
+ actionToken: sessionActionTokens.get(active) || ''
1395
+ };
1396
+ }
1397
+
1398
+ function terminalShellQuote(value) {
1399
+ return "'" + String(value).split("'").join("'\"'\"'") + "'";
1400
+ }
1401
+
1402
+ function terminalCommandForAction(action, info) {
1403
+ const session = info.session || {};
1404
+ const peerId = terminalShellQuote(info.peerId);
1405
+ const lines = {
1406
+ register: `hcc register --peer ${peerId} --kind ${terminalShellQuote(session.kind || 'other')} --role ${terminalShellQuote(session.role || 'peer')}`,
1407
+ inbox: `hcc msg inbox --peer ${peerId}`,
1408
+ 'task-next': `hcc task next --peer ${peerId}`,
1409
+ state: `hcc state --peer ${peerId}`,
1410
+ status: `hcc status --peer ${peerId}`,
1411
+ heartbeat:`hcc heartbeat --peer ${peerId} --renew-locks`
1412
+ };
1413
+ return lines[action] || lines.status;
1414
+ }
1415
+
1416
+ function formatActionResult(result) {
1417
+ if (!result) return '';
1418
+ const data = result.data || {};
1419
+ const error = result.error || data.error;
1420
+ if (error) {
1421
+ const code = String(result.errorCode || '').toLowerCase();
1422
+ return [
1423
+ tr('error.' + code, tr('requestFailed')),
1424
+ result.errorCode ? '[' + result.errorCode + ']' : '',
1425
+ tr('details') + ': ' + error
1426
+ ].join('\n');
1427
+ }
1428
+ if (result.action === 'status') {
1429
+ const tasks = (data.tasks || []).map((row) => statusText(row.status) + ': ' + row.n).join(', ') || tr('none');
1430
+ return [
1431
+ data.root ? tr('root') + ': ' + data.root : '',
1432
+ tr('peers') + ' · ' + tr('active') + ': ' + (data.active_peers ?? 0) + ' · ' + tr('stale') + ': ' + (data.stale_peers ?? 0),
1433
+ tr('tasks') + ': ' + tasks,
1434
+ tr('locks') + ' · ' + tr('active') + ': ' + (data.active_locks ?? 0),
1435
+ tr('unread') + ': ' + (data.unread ?? 0)
1436
+ ].filter(Boolean).join('\n');
1437
+ }
1438
+ if (result.action === 'state') {
1439
+ const automation = data.automation || {};
1440
+ const next = automation.next_action || {};
1441
+ return [
1442
+ tr('phase') + ': ' + statusText(automation.phase || 'idle'),
1443
+ tr('next') + ': ' + (next.command || (next.kind && next.kind !== 'none' ? tr('action.' + next.kind, next.kind) : tr('none'))),
1444
+ tr('why') + ': ' + (next.reason || tr('noImmediateAction')),
1445
+ ...(automation.warnings || []).map((w) => tr('warnings') + ': ' + w)
1446
+ ].join('\n');
1447
+ }
1448
+ if (result.action === 'inbox') {
1449
+ const messages = data.messages || [];
1450
+ return messages.length
1451
+ ? messages.map((m) => '#' + m.id + ' ' + m.sender + ' -> ' + (m.recipient || tr('all')) + ': ' + m.body).join('\n')
1452
+ : tr('noMessages');
1453
+ }
1454
+ if (result.action === 'task-next') {
1455
+ return data.task
1456
+ ? tr(data.current ? 'current' : 'claimed') + ': #' + data.task.id + ' ' + data.task.title + ' (' + statusText(data.task.status) + ')'
1457
+ : tr('noPendingTasks');
1458
+ }
1459
+ if (result.action === 'task-takeover') {
1460
+ return data.task ? '#' + data.task.id + ' ' + data.task.title + ' · ' + tr('owner') + ': ' + data.task.owner : tr('noTasks');
1461
+ }
1462
+ if (result.action === 'lock-acquire') {
1463
+ return data.lock ? tr('locked') + ': ' + lockLabel(data.lock) + ' · ' + tr('by') + ': ' + data.lock.owner : tr('noActiveLocks');
1464
+ }
1465
+ if (result.action === 'lock-release') {
1466
+ return data.result ? tr(data.result.released ? 'lockReleased' : 'noLock') + ': ' + lockLabel(data.result) : tr('noActiveLocks');
1467
+ }
1468
+ if (result.action === 'heartbeat') {
1469
+ return [
1470
+ tr('peer') + ': ' + (data.peer || result.peer || tr('unknown')),
1471
+ data.status ? tr('status') + ': ' + statusText(data.status) : '',
1472
+ tr('renewedLocks') + ': ' + (data.renewed ?? 0)
1473
+ ].filter(Boolean).join('\n');
1474
+ }
1475
+ if (result.action === 'register') {
1476
+ const peer = data.peer || {};
1477
+ return [
1478
+ tr('registered') + ': ' + (peer.id || result.peer || tr('unknown')),
1479
+ tr('kind') + ': ' + (peer.kind || tr('unknown')),
1480
+ tr('status') + ': ' + statusText(peer.status),
1481
+ peer.worktree ? tr('cwd') + ': ' + peer.worktree : ''
1482
+ ].filter(Boolean).join('\n');
1483
+ }
1484
+ return result.summary || JSON.stringify(result, null, 2);
1485
+ }
1486
+
1487
+ function showActionResult(result) {
1488
+ lastActionResult = result;
1489
+ const panel = document.getElementById('actionResult');
1490
+ const action = String(result.action || '');
1491
+ const label = action ? tr('action.' + action, tr(action, action)) : tr('actionResult');
1492
+ document.getElementById('actionResultTitle').textContent = label + (result.peer ? ' · ' + result.peer : '');
1493
+ document.getElementById('actionResultBody').textContent = formatActionResult(result);
1494
+ panel.hidden = false;
1495
+ }
1496
+
1497
+ async function runPeerAction(action) {
1498
+ const info = activePeerInfo();
1499
+ if (!info) return;
1500
+ const readOnly = ['status', 'state', 'inbox'].includes(action);
1501
+ const payload = action === 'register'
1502
+ ? { kind: info.session.kind || 'other', role: info.session.role || 'peer', worktree: info.session.cwd || currentProject }
1503
+ : action === 'heartbeat'
1504
+ ? { renew_locks: true }
1505
+ : {};
1506
+ if (!readOnly) { payload.action_token = info.actionToken; payload.epoch = controlEpoch(); }
1507
+ const result = await api('/api/peers/' + encodeURIComponent(info.peerId) + '/actions/' + encodeURIComponent(action), readOnly
1508
+ ? {}
1509
+ : { method: 'POST', body: JSON.stringify(payload) });
1510
+ showActionResult(result);
1511
+ await Promise.all([refreshSessions(), refreshDetected(), refreshCurrentState()]);
1512
+ }
1513
+
1514
+ document.querySelectorAll('[data-action]').forEach((button) => {
1515
+ button.addEventListener('click', () => {
1516
+ closeActionsMenu();
1517
+ runPeerAction(button.dataset.action).catch((err) => {
1518
+ handleUiError(err, button.dataset.action);
1519
+ });
1520
+ });
1521
+ });
1522
+ document.querySelectorAll('[data-terminal-action]').forEach((button) => {
1523
+ button.addEventListener('click', () => {
1524
+ const info = activePeerInfo();
1525
+ if (!info) return;
1526
+ sendLine(terminalCommandForAction(button.dataset.terminalAction, info));
1527
+ closeActionsMenu();
1528
+ });
1529
+ });
1530
+ document.getElementById('logoutBtn').addEventListener('click', async () => {
1531
+ closeActionsMenu();
1532
+ try {
1533
+ const res = await fetch('/logout', { method: 'POST', headers });
1534
+ if (!res.ok) throw new Error(tr('requestFailed'));
1535
+ // Stop reconnect only after the server confirms revocation.
1536
+ loggedOut = true;
1537
+ window.hccWorkspace?.destroy();
1538
+ try { sessionStorage.setItem('hcc_logged_out', '1'); } catch {}
1539
+ location.replace('/');
1540
+ } catch (error) { handleUiError(error, 'logout'); }
1541
+ });
1542
+ document.getElementById('actionResultClose').addEventListener('click', () => {
1543
+ document.getElementById('actionResult').hidden = true;
1544
+ });
1545
+
1546
+ // ── Stop confirmation dialog ───────────────────────────────────────
1547
+ const stopDialog = document.getElementById('stopDialog');
1548
+ const stopConfirmBtn = document.getElementById('stopConfirmBtn');
1549
+ const stopCancelBtn = document.getElementById('stopCancelBtn');
1550
+ const stopKillCb = document.getElementById('stopKillCb');
1551
+ const settingsDialog = document.getElementById('settingsDialog');
1552
+ const projectDialog = document.getElementById('projectDialog');
1553
+ const startDialog = document.getElementById('startDialog');
1554
+ let dialogReturnFocus = null;
1555
+ function openDialog(dialog, initialFocus) {
1556
+ closeActionsMenu();
1557
+ dialogReturnFocus = document.activeElement;
1558
+ dialog.hidden = false;
1559
+ document.querySelector('.app').inert = true;
1560
+ document.querySelector('.global-header').inert = true;
1561
+ document.querySelector('.mobile-nav').inert = true;
1562
+ initialFocus?.focus();
1563
+ }
1564
+ function offerInitialProject() {
1565
+ const requested = pendingInitialProject;
1566
+ pendingInitialProject = '';
1567
+ if (!requested || requested === currentProject ||
1568
+ requested.replace(/[\\/]+$/, '') === currentProject.replace(/[\\/]+$/, '')) return;
1569
+ const input = document.getElementById('projectPath');
1570
+ input.value = requested;
1571
+ document.getElementById('projectDialogError').hidden = true;
1572
+ openDialog(projectDialog, input);
1573
+ }
1574
+ function closeDialog(dialog) {
1575
+ if (dialog.id === 'startDialog') window.hccAgentStart?.closed();
1576
+ if (dialog.id === 'filesDialog') window.hccFiles?.closed();
1577
+ if (dialog.id === 'historyDialog') window.hccHistory?.closed();
1578
+ if (dialog.id === 'settingsDialog') window.hccAgentDefaults?.closed();
1579
+ dialog.hidden = true;
1580
+ document.querySelector('.app').inert = false;
1581
+ document.querySelector('.global-header').inert = false;
1582
+ document.querySelector('.mobile-nav').inert = false;
1583
+ if (dialogReturnFocus?.isConnected && !dialogReturnFocus.disabled && dialogReturnFocus.getClientRects().length) dialogReturnFocus.focus();
1584
+ else document.getElementById('settingsBtn').focus();
1585
+ dialogReturnFocus = null;
1586
+ }
1587
+ function updateStopDialogTitle() {
1588
+ const peerId = stopDialog._action === 'detected' ? stopDialog._peerId : (sessionPeerId(sessions.find((s) => s.id === active)) || active);
1589
+ document.getElementById('stopDialogTitle').textContent = tr('stop') + ' ' + (peerId || '') + '?';
1590
+ if (stopDialog._action === 'detected') {
1591
+ document.getElementById('stopDialogMeta').textContent = tr('detectedPeer');
1592
+ stopConfirmBtn.textContent = tr('stop');
1593
+ } else {
1594
+ const session = sessions.find((item) => item.id === active) || {};
1595
+ document.getElementById('stopDialogTitle').textContent = tr(session.type === 'tmux' ? 'handoff.detach' : 'handoff.terminate') + ' ' + (peerId || '') + '?';
1596
+ stopConfirmBtn.textContent = tr(session.type === 'tmux' ? 'handoff.detach' : 'handoff.terminate');
1597
+ }
1598
+ }
1599
+ installAgentStart();
1600
+ function openStartDialog() { window.hccAgentStart.open(); }
1601
+ document.getElementById('openStartDialog').addEventListener('click', openStartDialog);
1602
+ document.getElementById('openHistoryDialog').addEventListener('click', () => window.hccHistory.open());
1603
+ document.getElementById('openReviewDialog').addEventListener('click', () => window.hccReview.open());
1604
+ document.getElementById('startDialogClose').addEventListener('click', () => closeDialog(startDialog));
1605
+ document.getElementById('startCancelBtn').addEventListener('click', () => closeDialog(startDialog));
1606
+ document.getElementById('openProjectDialog').addEventListener('click', () => {
1607
+ document.getElementById('projectDialogError').hidden = true;
1608
+ openDialog(projectDialog, document.getElementById('projectPath'));
1609
+ });
1610
+ document.getElementById('projectDialogClose').addEventListener('click', () => closeDialog(projectDialog));
1611
+ document.getElementById('projectCancelBtn').addEventListener('click', () => closeDialog(projectDialog));
1612
+ document.getElementById('settingsBtn').addEventListener('click', () => { openDialog(settingsDialog, document.getElementById('langSelect')); window.hccAgentDefaults.open(); });
1613
+ document.getElementById('settingsClose').addEventListener('click', () => closeDialog(settingsDialog));
1614
+ [settingsDialog, stopDialog, projectDialog, startDialog, document.getElementById('historyDialog'), document.getElementById('reviewDialog'), document.getElementById('commandDialog'), document.getElementById('filesDialog')].forEach((dialog) => {
1615
+ dialog.addEventListener('click', (event) => { if (event.target === dialog) closeDialog(dialog); });
1616
+ dialog.addEventListener('keydown', (event) => {
1617
+ if (event.isComposing) return;
1618
+ if (event.key === 'Escape') { event.preventDefault(); closeDialog(dialog); return; }
1619
+ if (event.key !== 'Tab') return;
1620
+ const controls = [...dialog.querySelectorAll('button, select, input, textarea, a[href], [tabindex="0"]')].filter((el) => !el.disabled && el.tabIndex >= 0 && el.getClientRects().length);
1621
+ const first = controls[0], last = controls[controls.length - 1];
1622
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
1623
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
1624
+ });
1625
+ });
1626
+ function preserveFocus(root) {
1627
+ const focused = document.activeElement;
1628
+ if (!root.contains(focused)) return () => {};
1629
+ const key = focused.dataset.focusKey;
1630
+ const id = focused.id;
1631
+ const section = focused.closest('[data-section]')?.dataset.section;
1632
+ const action = focused.dataset.action;
1633
+ const peerId = focused.dataset.id;
1634
+ return () => {
1635
+ const target = [...root.querySelectorAll('button, select, input')].find((el) =>
1636
+ key ? el.dataset.focusKey === key : id ? el.id === id : section ? el.closest('[data-section]')?.dataset.section === section : action && el.dataset.action === action && el.dataset.id === peerId);
1637
+ target?.focus({ preventScroll: true });
1638
+ };
1639
+ }
1640
+
1641
+ // ── Actions dropdown (declutters the toolbar) ─────────────────────────
1642
+ const actionsBtn = document.getElementById('actionsBtn');
1643
+ const actionsMenu = document.getElementById('actionsMenu');
1644
+ function closeActionsMenu() {
1645
+ actionsMenu.hidden = true;
1646
+ actionsBtn.setAttribute('aria-expanded', 'false');
1647
+ }
1648
+ actionsBtn.addEventListener('click', (e) => {
1649
+ e.stopPropagation();
1650
+ const open = actionsMenu.hidden;
1651
+ actionsMenu.hidden = !open;
1652
+ actionsBtn.setAttribute('aria-expanded', String(open));
1653
+ if (open) {
1654
+ // Position as a fixed layer so it escapes the toolbar/main overflow clip.
1655
+ const r = actionsBtn.getBoundingClientRect();
1656
+ actionsMenu.style.top = (r.bottom + 6) + 'px';
1657
+ actionsMenu.style.left = Math.max(8, r.right - actionsMenu.offsetWidth) + 'px';
1658
+ }
1659
+ });
1660
+ document.addEventListener('click', (e) => {
1661
+ if (!actionsMenu.hidden && !e.target.closest('.menu-wrap')) closeActionsMenu();
1662
+ });
1663
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeActionsMenu(); });
1664
+
1665
+ // ── Collapsible side panels ───────────────────────────────────────────
1666
+ const appEl = document.querySelector('.app');
1667
+ const narrowViewport = window.matchMedia('(max-width: 1099px)');
1668
+ const focusBtn = document.getElementById('focusBtn');
1669
+ function syncFocusButton() {
1670
+ const enabled = appEl.classList.contains('focus-mode');
1671
+ focusBtn.textContent = tr(enabled ? 'exitFocus' : 'focusMode');
1672
+ focusBtn.setAttribute('aria-pressed', String(enabled));
1673
+ }
1674
+ function syncPanelAccess() {
1675
+ const focus = appEl.classList.contains('focus-mode') && !narrowViewport.matches;
1676
+ for (const [selector, view, side] of [['.sidebar', 'sessions', 'left'], ['.main', 'terminal', null], ['.inspector', 'state', 'right']]) {
1677
+ appEl.querySelector(selector).inert = paneMode ? view !== 'terminal' : narrowViewport.matches
1678
+ ? appEl.dataset.view !== view || view === 'terminal' && appEl.classList.contains('workspace-open') && document.getElementById('workspace').dataset.activePane === 'secondary'
1679
+ : Boolean(side && (focus || sideIsCollapsed(side) || side === 'right' && appEl.classList.contains('workspace-open')));
1680
+ }
1681
+ document.getElementById('workspaceSecondPane').inert = narrowViewport.matches && (appEl.dataset.view !== 'terminal' || document.getElementById('workspace').dataset.activePane !== 'secondary');
1682
+ }
1683
+ function setMobileView(view) {
1684
+ if (paneMode) view = 'terminal';
1685
+ if (!['sessions', 'terminal', 'state'].includes(view)) return;
1686
+ appEl.dataset.view = view;
1687
+ document.querySelectorAll('.mobile-nav [data-view]').forEach((button) => {
1688
+ if (button.dataset.view === view) button.setAttribute('aria-current', 'page');
1689
+ else button.removeAttribute('aria-current');
1690
+ });
1691
+ syncPanelAccess();
1692
+ closeActionsMenu();
1693
+ requestAnimationFrame(() => resizeTerm());
1694
+ }
1695
+ document.querySelectorAll('.mobile-nav [data-view]').forEach((button) => button.addEventListener('click', () => setMobileView(button.dataset.view)));
1696
+ focusBtn.addEventListener('click', () => {
1697
+ appEl.classList.toggle('focus-mode');
1698
+ syncFocusButton();
1699
+ syncPanelAccess();
1700
+ setTimeout(resizeTerm, 200);
1701
+ });
1702
+ const toggleLeftBtn = document.getElementById('toggleLeft');
1703
+ const toggleRightBtn = document.getElementById('toggleRight');
1704
+ const resizeLeftHandle = document.getElementById('resizeLeft');
1705
+ const resizeRightHandle = document.getElementById('resizeRight');
1706
+ const sideDefaults = { left: 320, right: 360 };
1707
+ const sideMin = { left: 220, right: 240 };
1708
+ const sideMax = { left: 560, right: 640 };
1709
+ const sideWidthKey = { left: 'hcc.sidebar.left.width', right: 'hcc.sidebar.right.width' };
1710
+ const dragState = { side: null, startX: 0, startWidth: 0, moved: false, suppressClick: false };
1711
+ function sideIsCollapsed(side) {
1712
+ return appEl.classList.contains(side + '-collapsed') || hccUi.safeGet('hcc.collapse.' + side) === '1';
1713
+ }
1714
+ function storedSideWidth(side) {
1715
+ const raw = Number(hccUi.safeGet(sideWidthKey[side]));
1716
+ return Number.isFinite(raw) && raw > 0 ? raw : sideDefaults[side];
1717
+ }
1718
+ function effectiveOppositeWidth(side) {
1719
+ const opposite = side === 'left' ? 'right' : 'left';
1720
+ return sideIsCollapsed(opposite) ? 0 : storedSideWidth(opposite);
1721
+ }
1722
+ function clampSideWidth(side, width) {
1723
+ const viewport = Math.max(640, window.innerWidth || appEl.clientWidth || 0);
1724
+ const opposite = effectiveOppositeWidth(side);
1725
+ const maxByViewport = Math.max(sideMin[side], viewport - opposite - 280);
1726
+ return Math.max(sideMin[side], Math.min(sideMax[side], maxByViewport, Math.round(width)));
1727
+ }
1728
+ function readSideWidth(side) {
1729
+ return clampSideWidth(side, storedSideWidth(side));
1730
+ }
1731
+ function setSideWidth(side, width, persist = true) {
1732
+ const value = clampSideWidth(side, width);
1733
+ appEl.style.setProperty('--' + side + '-width', value + 'px');
1734
+ if (persist) hccUi.safeSet(sideWidthKey[side], String(value));
1735
+ const handle = document.getElementById(side === 'left' ? 'resizeLeft' : 'resizeRight');
1736
+ handle.setAttribute('aria-valuemin', String(sideMin[side]));
1737
+ handle.setAttribute('aria-valuemax', String(clampSideWidth(side, sideMax[side])));
1738
+ handle.setAttribute('aria-valuenow', String(value));
1739
+ return value;
1740
+ }
1741
+ function applySideWidths() {
1742
+ setSideWidth('left', readSideWidth('left'), false);
1743
+ setSideWidth('right', readSideWidth('right'), false);
1744
+ }
1745
+ function syncToggleIcons() {
1746
+ const l = appEl.classList.contains('left-collapsed');
1747
+ const r = appEl.classList.contains('right-collapsed');
1748
+ toggleLeftBtn.textContent = l ? '⟩' : '⟨';
1749
+ toggleLeftBtn.title = (l ? tr('show') : tr('collapse')) + ' ' + tr('sidebar') + '; ' + tr('dragToResize');
1750
+ toggleRightBtn.textContent = r ? '⟨' : '⟩';
1751
+ toggleRightBtn.title = (r ? tr('show') : tr('collapse')) + ' ' + tr('statePanel') + '; ' + tr('dragToResize');
1752
+ }
1753
+ function applyCollapseState() {
1754
+ appEl.classList.toggle('left-collapsed', hccUi.safeGet('hcc.collapse.left') === '1');
1755
+ appEl.classList.toggle('right-collapsed', hccUi.safeGet('hcc.collapse.right') === '1');
1756
+ applySideWidths();
1757
+ syncToggleIcons();
1758
+ syncPanelAccess();
1759
+ }
1760
+ function toggleSide(side) {
1761
+ const cls = side + '-collapsed';
1762
+ const on = appEl.classList.toggle(cls);
1763
+ hccUi.safeSet('hcc.collapse.' + side, on ? '1' : '0');
1764
+ applySideWidths();
1765
+ syncToggleIcons();
1766
+ syncPanelAccess();
1767
+ // Refit the terminal after the grid transition so xterm uses the new width.
1768
+ setTimeout(() => { try { resizeTerm(); } catch {} }, 200);
1769
+ }
1770
+ function beginSideDrag(side, event) {
1771
+ if (event.button !== undefined && event.button !== 0) return;
1772
+ dragState.side = side;
1773
+ dragState.startX = event.clientX;
1774
+ dragState.startWidth = readSideWidth(side);
1775
+ dragState.moved = false;
1776
+ dragState.suppressClick = false;
1777
+ appEl.classList.add('resizing');
1778
+ event.currentTarget.setPointerCapture?.(event.pointerId);
1779
+ event.preventDefault();
1780
+ }
1781
+ function moveSideDrag(event) {
1782
+ if (!dragState.side) return;
1783
+ const delta = event.clientX - dragState.startX;
1784
+ if (!dragState.moved && Math.abs(delta) <= 3) return;
1785
+ dragState.moved = true;
1786
+ const width = dragState.side === 'left'
1787
+ ? dragState.startWidth + delta
1788
+ : dragState.startWidth - delta;
1789
+ setSideWidth(dragState.side, width);
1790
+ if (dragState.side === 'left') {
1791
+ appEl.classList.remove('left-collapsed');
1792
+ hccUi.safeSet('hcc.collapse.left', '0');
1793
+ } else {
1794
+ appEl.classList.remove('right-collapsed');
1795
+ hccUi.safeSet('hcc.collapse.right', '0');
1796
+ }
1797
+ syncToggleIcons();
1798
+ syncPanelAccess();
1799
+ try { resizeTerm(); } catch {}
1800
+ event.preventDefault();
1801
+ }
1802
+ function endSideDrag(event) {
1803
+ if (!dragState.side) return;
1804
+ dragState.suppressClick = dragState.moved;
1805
+ event.currentTarget.releasePointerCapture?.(event.pointerId);
1806
+ dragState.side = null;
1807
+ appEl.classList.remove('resizing');
1808
+ setTimeout(() => { dragState.suppressClick = false; }, 0);
1809
+ try { resizeTerm(); } catch {}
1810
+ }
1811
+ function bindSideHandle(handle, side, clickAction = null) {
1812
+ if (handle.getAttribute('role') === 'separator') handle.addEventListener('keydown', (event) => {
1813
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
1814
+ event.preventDefault();
1815
+ const step = event.shiftKey ? 40 : 16;
1816
+ const delta = (event.key === 'ArrowRight' ? 1 : -1) * (side === 'left' ? 1 : -1) * step;
1817
+ const width = event.key === 'Home' ? sideMin[side] : event.key === 'End' ? sideMax[side] : readSideWidth(side) + delta;
1818
+ appEl.classList.remove(side + '-collapsed');
1819
+ hccUi.safeSet('hcc.collapse.' + side, '0');
1820
+ setSideWidth(side, width);
1821
+ syncToggleIcons();
1822
+ syncPanelAccess();
1823
+ setTimeout(resizeTerm, 200);
1824
+ });
1825
+ handle.addEventListener('pointerdown', (event) => beginSideDrag(side, event));
1826
+ handle.addEventListener('pointermove', moveSideDrag);
1827
+ handle.addEventListener('pointerup', endSideDrag);
1828
+ handle.addEventListener('pointercancel', endSideDrag);
1829
+ handle.addEventListener('click', (event) => {
1830
+ if (dragState.suppressClick) {
1831
+ event.preventDefault();
1832
+ return;
1833
+ }
1834
+ if (clickAction) clickAction(side);
1835
+ });
1836
+ }
1837
+ bindSideHandle(resizeLeftHandle, 'left');
1838
+ bindSideHandle(resizeRightHandle, 'right');
1839
+ bindSideHandle(toggleLeftBtn, 'left', toggleSide);
1840
+ bindSideHandle(toggleRightBtn, 'right', toggleSide);
1841
+ window.addEventListener('resize', () => {
1842
+ applySideWidths();
1843
+ syncPanelAccess();
1844
+ closeActionsMenu();
1845
+ setTimeout(() => { try { resizeTerm(); } catch {} }, 50);
1846
+ });
1847
+ applyCollapseState();
1848
+
1849
+ document.getElementById('stopBtn').addEventListener('click', () => {
1850
+ if (!active || !canControl()) return;
1851
+ const session = sessions.find((s) => s.id === active) || {};
1852
+ if (session.type === 'native') return;
1853
+ document.getElementById('stopDialogTitle').textContent = tr(session.type === 'tmux' ? 'handoff.detach' : 'handoff.terminate') + ' ' + (sessionPeerId(session) || active) + '?';
1854
+ stopConfirmBtn.textContent = tr(session.type === 'tmux' ? 'handoff.detach' : 'handoff.terminate');
1855
+ stopKillCb.checked = false;
1856
+ const meta = sessionMetaText(session);
1857
+ document.getElementById('stopDialogMeta').textContent = [meta, tr(session.type === 'tmux' ? 'handoff.detachHelp' : 'handoff.terminateHelp')].filter(Boolean).join(' · ');
1858
+ stopKillCb.parentElement.parentElement.hidden = session.type !== 'tmux';
1859
+ stopDialog._action = null; stopDialog._peerId = null;
1860
+ stopDialog._sessionId = active;
1861
+ stopDialog._projectRoot = currentProject;
1862
+ openDialog(stopDialog, stopCancelBtn);
1863
+ });
1864
+ document.getElementById('refreshBtn').addEventListener('click', () => {
1865
+ refreshVisibleData().catch(handleUiError);
1866
+ });
1867
+ document.getElementById('stateRetry').addEventListener('click', () => {
1868
+ refreshVisibleData().catch(handleUiError);
1869
+ });
1870
+
1871
+ function terminalFindAvailable() {
1872
+ const session = sessions.find(item => item.id === active);
1873
+ return Boolean(active && activeType === 'managed' && session && session.type !== 'app-server' && session.type !== 'native');
1874
+ }
1875
+ function focusSessionsSearch() {
1876
+ if (paneMode) { window.hccCommandPalette?.open(); return; }
1877
+ if (narrowViewport.matches) setMobileView('sessions');
1878
+ else {
1879
+ appEl.classList.remove('focus-mode');
1880
+ hccUi.safeSet('hcc.collapse.left', '0');
1881
+ applyCollapseState(); syncFocusButton();
1882
+ }
1883
+ document.getElementById('sessionSearch').focus();
1884
+ }
1885
+ const shortcutPrefix = /Mac|iPhone|iPad/.test(navigator.platform || '') ? '⌘ ⇧ ' : 'Ctrl Shift ';
1886
+ window.hccTerminalFindHost = {
1887
+ term, addon: searchAddon, available: terminalFindAvailable,
1888
+ subject: () => currentProject + ':' + (active || ''),
1889
+ showTerminal: () => { if (!paneMode) window.hccWorkspace?.primary(); setMobileView('terminal'); }
1890
+ };
1891
+ installTerminalFind();
1892
+ window.hccCommandHost = {
1893
+ openDialog, closeDialog, error: handleUiError,
1894
+ modalOpen: () => Boolean(document.querySelector('.dialog-overlay:not([hidden])')),
1895
+ terminalAvailable: terminalFindAvailable, focusSessions: focusSessionsSearch,
1896
+ commands() {
1897
+ const page = tr('commands.pages');
1898
+ const commands = [
1899
+ { id:'new', label:tr('newSession'), group:page, enabled:!window.hccAgentStart.pending, run:openStartDialog },
1900
+ { id:'files', label:tr('files.title'), group:page, run:() => window.hccFiles.open() },
1901
+ { id:'sessions', label:tr('sessionSearch'), group:page, shortcut:shortcutPrefix+'L', run:focusSessionsSearch },
1902
+ { id:'find', label:tr('find.title'), group:page, shortcut:shortcutPrefix+'F', enabled:terminalFindAvailable(), run:() => window.hccTerminalFind.open() },
1903
+ { id:'settings', label:tr('settings'), group:page, run:() => document.getElementById('settingsBtn').click() },
1904
+ { id:'project-add', label:tr('addProject'), group:page, enabled:!document.getElementById('openProjectDialog').disabled, run:() => document.getElementById('openProjectDialog').click() },
1905
+ { id:'refresh', label:tr('commands.refresh'), group:page, run:refreshVisibleData },
1906
+ { id:'focus', label:tr(appEl.classList.contains('focus-mode') ? 'exitFocus' : 'focusMode'), group:page, enabled:!narrowViewport.matches, run:() => focusBtn.click() },
1907
+ { id:'history', label:tr('aux.history'), group:page, run:() => document.getElementById('openHistoryDialog').click() },
1908
+ { id:'review', label:tr('aux.review'), group:page, enabled:!document.getElementById('openReviewDialog').disabled, run:() => document.getElementById('openReviewDialog').click() }
1909
+ ];
1910
+ for (const view of ['overview','tasks','messages','events']) {
1911
+ commands.push({ id:'view:'+view, label:tr('commands.view.'+view), group:page, run:() => {
1912
+ if (narrowViewport.matches) setMobileView('state');
1913
+ else { if (window.hccWorkspace?.isOpen) window.hccWorkspace.close(); appEl.classList.remove('focus-mode'); hccUi.safeSet('hcc.collapse.right','0'); applyCollapseState(); syncFocusButton(); }
1914
+ document.getElementById('stateTab-'+view).click(); document.getElementById('stateTab-'+view).focus();
1915
+ }});
1916
+ }
1917
+ for (const project of projects) {
1918
+ commands.push({ id:'project:'+project.root, label:project.name || project.root, detail:project.root, group:tr('commands.projects'), enabled:project.root!==currentProject, run:() => {
1919
+ document.getElementById('projectSelect').value = project.root;
1920
+ return switchProject(project.root);
1921
+ }});
1922
+ }
1923
+ const root = currentProject;
1924
+ for (const session of sessions) {
1925
+ commands.push({ id:'session:'+root+':'+session.id, label:session.name || session.task?.title || sessionPeerId(session), detail:sessionProvider(session)+' · '+statusText(session.status)+' · '+(session.cwd || ''), keywords:[session.id,sessionPeerId(session),session.task?.title,sessionRuntimeTarget(session),sessionProviderSessionValue(session),session.command].join(' '), group:tr('sessions'), enabled:session.status==='running', run:() => { if (currentProject===root) connectManaged(session.id); }});
1926
+ }
1927
+ for (const peer of detected) {
1928
+ commands.push({ id:'detected:'+root+':'+peer.id, label:peer.name || peer.id, detail:(peer.provider || peer.kind || '')+' · '+(peer.worktree || peer.cwd || ''), group:tr('detected'), run:() => { if (currentProject===root) connectDetected(peer.id); }});
1929
+ }
1930
+ if (paneMode) return commands.filter(command => ['new','files','sessions','find','settings','history','review','refresh'].includes(command.id) || command.id.startsWith('session:'));
1931
+ commands.splice(3,0,{id:'workspace',label:tr(window.hccWorkspace?.isOpen ? 'workspace.close' : 'workspace.split'),group:page,enabled:!document.getElementById('splitBtn').disabled,run:() => document.getElementById('splitBtn').click()});
1932
+ return commands;
1933
+ }
1934
+ };
1935
+ installCommandPalette();
1936
+ window.hccWorkspaceHost = {
1937
+ embedded:paneMode, app:appEl, primary:document.getElementById('workspacePrimaryPane'),
1938
+ project:() => currentProject, projectIdentity:() => projectRequests.rootIdentity(),
1939
+ active:() => active, sessions:() => sessions, ready:() => sessionsLoaded,
1940
+ esc, showTerminal:() => setMobileView('terminal'), layoutChanged:() => { syncPanelAccess(); requestAnimationFrame(resizeTerm); }
1941
+ };
1942
+ installWorkspace();
1943
+ term.attachCustomKeyEventHandler(event => !event.defaultPrevented && !window.hccCommandPalette.captures(event));
1944
+ hccUi.bindControls();
1945
+ window.addEventListener('hcc:preferences', (event) => {
1946
+ if (event.detail.changed.some((key) => key === 'language' || key === 'resolvedLanguage')) applyLanguage();
1947
+ syncFocusButton();
1948
+ syncToggleIcons();
1949
+ applyTerminalPreferences();
1950
+ });
1951
+ applyTerminalPreferences();
1952
+ applyLanguage();
1953
+ loadProjects().then(() => {
1954
+ offerInitialProject();
1955
+ return Promise.all([refreshSessions(), refreshDetected(), refreshState()]);
1956
+ }).then(() => {
1957
+ if (!active && !activeDetected) restoreSelection();
1958
+ }).catch((err) => {
1959
+ if (err.name === 'AbortError') return;
1960
+ connText('error');
1961
+ console.error(err);
1962
+ });
1963
+ const stopDataPoll = scheduleVisiblePoll(refreshVisibleData, { intervalMs: 3000 });
1964
+ const stopProjectPoll = scheduleVisiblePoll(refreshProjectsQuietly, { intervalMs: 8000 });
1965
+ window.addEventListener('online', () => {
1966
+ if (!loggedOut && active && (!ws || ws.readyState === WebSocket.CLOSED)) {
1967
+ clearTimeout(wsReconnectTimer); wsReconnectFailures = 0; openWs(active);
1968
+ }
1969
+ });
1970
+ window.addEventListener('pagehide', () => { stopDataPoll(); stopProjectPoll(); projectRequests.dispose(); });
1971
+ window.addEventListener('pageshow', (event) => {
1972
+ // pagehide disposes sockets, polling and embedded panes. A bfcache restore
1973
+ // skips module initialization, so reload the complete lifecycle. Saved
1974
+ // drafts and selection survive; initialization never replays submissions.
1975
+ if (event.persisted) location.reload();
1976
+ });
1977
+
1978
+ stopCancelBtn.addEventListener('click', () => closeDialog(stopDialog));
1979
+
1980
+ stopConfirmBtn.addEventListener('click', async () => {
1981
+ closeDialog(stopDialog);
1982
+ try {
1983
+ const killTmux = stopKillCb.checked;
1984
+ if (stopDialog._action === 'detected') {
1985
+ const peerId = stopDialog._peerId;
1986
+ stopDialog._action = null; stopDialog._peerId = null;
1987
+ if (!peerId) return;
1988
+ await api('/api/detected/' + encodeURIComponent(peerId) + '/stop', { method: 'POST', body: JSON.stringify({ kill_tmux: killTmux }) });
1989
+ await Promise.all([refreshSessions(), refreshDetected()]);
1990
+ } else {
1991
+ if (!active || !canControl()) return;
1992
+ if (stopDialog._sessionId !== active || stopDialog._projectRoot !== currentProject) throw new Error(tr('error.subject_changed'));
1993
+ await api('/api/sessions/' + encodeURIComponent(active) + '/stop', { method: 'POST', body: JSON.stringify({ kill_tmux: killTmux, action_token: sessionActionTokens.get(active) || '', epoch: controlEpoch() }) });
1994
+ await refreshSessions();
1995
+ }
1996
+ } catch (error) { handleUiError(error); }
1997
+ });