@logicseek/hello-cc 1.0.1 → 1.0.2

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 (97) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +4 -0
  3. package/README.zh-CN.md +4 -0
  4. package/docs/README.md +9 -0
  5. package/docs/README.zh-CN.md +9 -0
  6. package/docs/commands.md +53 -0
  7. package/docs/commands.zh-CN.md +48 -0
  8. package/docs/dsh.md +137 -0
  9. package/docs/dsh.zh-CN.md +139 -0
  10. package/docs/mcp.md +9 -0
  11. package/docs/mcp.zh-CN.md +23 -0
  12. package/docs/native.md +295 -0
  13. package/docs/native.zh-CN.md +253 -0
  14. package/docs/web-handoff.zh-CN.md +53 -0
  15. package/lib/cli/app.mjs +25 -6
  16. package/lib/cli/commands/doctor.mjs +17 -2
  17. package/lib/cli/commands/dsh.mjs +79 -0
  18. package/lib/cli/commands/hook.mjs +241 -90
  19. package/lib/cli/commands/lock.mjs +34 -19
  20. package/lib/cli/commands/mcp.mjs +28 -0
  21. package/lib/cli/commands/native.mjs +130 -0
  22. package/lib/cli/commands/run.mjs +66 -21
  23. package/lib/cli/commands/task.mjs +6 -3
  24. package/lib/cli-args.mjs +4 -0
  25. package/lib/coordination-state.mjs +15 -1
  26. package/lib/core/coordination/event-retention.mjs +14 -2
  27. package/lib/core/coordination/gc-plan.mjs +3 -13
  28. package/lib/core/coordination/locks.mjs +45 -0
  29. package/lib/core/coordination/results.mjs +79 -0
  30. package/lib/core/peers/bindings.mjs +9 -3
  31. package/lib/diagnostics/codex.mjs +121 -0
  32. package/lib/integrations/codex-interactions.mjs +114 -0
  33. package/lib/integrations/dsh-collaboration.mjs +170 -0
  34. package/lib/integrations/dsh-cordis.d.ts +19 -0
  35. package/lib/integrations/dsh-cordis.mjs +177 -0
  36. package/lib/integrations/dsh.bundle.yml +7 -0
  37. package/lib/integrations/dsh.mjs +287 -0
  38. package/lib/integrations/native/claude.mjs +451 -0
  39. package/lib/integrations/native/codex.mjs +242 -0
  40. package/lib/integrations/native/dsh-acp.mjs +343 -0
  41. package/lib/integrations/native/index.mjs +19 -0
  42. package/lib/integrations/native/interactions.mjs +53 -0
  43. package/lib/integrations/native/jsonrpc.mjs +185 -0
  44. package/lib/integrations/peers/identity.mjs +36 -10
  45. package/lib/integrations/peers/processes.mjs +22 -8
  46. package/lib/integrations/providers.mjs +6 -2
  47. package/lib/mcp/scope.mjs +104 -0
  48. package/lib/mcp/stdio.mjs +80 -0
  49. package/lib/mcp/tools.mjs +197 -0
  50. package/lib/process/identity.mjs +3 -1
  51. package/lib/release/release-notes.mjs +9 -0
  52. package/lib/runtime/native/client.mjs +38 -0
  53. package/lib/runtime/native/service.mjs +511 -0
  54. package/lib/runtime/native/store.mjs +121 -0
  55. package/lib/ui/help.mjs +5 -2
  56. package/lib/web/adoption-state.mjs +55 -0
  57. package/lib/web/auto-attach.mjs +18 -2
  58. package/lib/web/codex-app-server.mjs +557 -0
  59. package/lib/web/codex-sessions.mjs +287 -0
  60. package/lib/web/control-lease.mjs +204 -0
  61. package/lib/web/http-routes.mjs +197 -15
  62. package/lib/web/http.mjs +5 -4
  63. package/lib/web/native-sessions.mjs +286 -0
  64. package/lib/web/project-contexts.mjs +2 -1
  65. package/lib/web/pty-sessions.mjs +60 -33
  66. package/lib/web/runtime-main.mjs +98 -6
  67. package/lib/web/session-serialize.mjs +0 -0
  68. package/lib/web/task-results.mjs +76 -0
  69. package/lib/web/tmux-sessions.mjs +92 -11
  70. package/lib/web/tmux-stream.mjs +2 -2
  71. package/lib/web/ui-codex.mjs +550 -0
  72. package/lib/web/ui-command-palette.mjs +119 -0
  73. package/lib/web/ui-handoff.mjs +102 -0
  74. package/lib/web/ui-history.mjs +118 -0
  75. package/lib/web/ui-i18n.mjs +841 -0
  76. package/lib/web/ui-interactions.mjs +78 -0
  77. package/lib/web/ui-login.mjs +101 -0
  78. package/lib/web/ui-native.mjs +165 -0
  79. package/lib/web/ui-preferences.mjs +278 -0
  80. package/lib/web/ui-review.mjs +132 -0
  81. package/lib/web/ui-template.mjs +1106 -576
  82. package/lib/web/ui-terminal-find.mjs +98 -0
  83. package/lib/web/ui-workbench.mjs +94 -0
  84. package/lib/web/ui-workspace.mjs +222 -0
  85. package/package.json +11 -3
  86. package/scripts/check-free-identifiers.mjs +67 -8
  87. package/scripts/codex-diagnostics-installed-acceptance.mjs +72 -0
  88. package/scripts/dsh-cordis-acceptance.mjs +200 -0
  89. package/scripts/dsh-installed-acceptance.mjs +247 -0
  90. package/scripts/dsh-official-acceptance.mjs +180 -0
  91. package/scripts/dsh-web-startup-acceptance.mjs +87 -0
  92. package/scripts/github-release.mjs +3 -1
  93. package/scripts/native-live-acceptance.mjs +369 -0
  94. package/scripts/regression.mjs +243 -89
  95. package/scripts/web-handoff-installed-acceptance.mjs +98 -0
  96. package/scripts/web-native-interaction-acceptance.mjs +204 -0
  97. package/scripts/web-native-model-acceptance.mjs +138 -0
@@ -14,6 +14,9 @@ import { normalizeStateResources } from '../ui/state-render.mjs';
14
14
  import { requestUrl, runtimeBaseUrl } from '../web/runtime.mjs';
15
15
  import { authOk, readJsonRequest, requestMatchesProxyOrigin, requestOriginMatches, sendFile, sendJson, tokenMatches } from '../web/http.mjs';
16
16
  import { API_VERSION, apiVersionUnsupportedBody, readHttpApiVersion } from '../web/api-version.mjs';
17
+ import { isDshHookBinding } from '../core/peers/bindings.mjs';
18
+ import { writeAdoptionState } from './adoption-state.mjs';
19
+ import { CodexAppServerError } from './codex-app-server.mjs';
17
20
 
18
21
  export function createHttpRoutes(deps) {
19
22
  const {
@@ -27,6 +30,9 @@ export function createHttpRoutes(deps) {
27
30
  prepareRuntimeBufferGc, applyPreparedRuntimeBufferGc,
28
31
  detachTmuxSession, safeTmuxKillPlan, executeTmuxKillPlan,
29
32
  killDbProvenTmuxSession, attachTmuxSession, writeSessionInput, startSession,
33
+ startCodexSession, codexAction, stopCodexSession, assertWebWrite,
34
+ listCodexThreads, readCodexThread, forkCodexThread, readSessionResults, writeSessionResult,
35
+ discoverNativeSessions, nativeAction,
30
36
  webStatusSnapshot, webPeerActionForProject,
31
37
  now, addEvent, auditPayload, sendMessage,
32
38
  getProcessIdentity, getActualPort,
@@ -35,6 +41,12 @@ export function createHttpRoutes(deps) {
35
41
  ACTIVE_PEER_TTL, DETECTED_PEER_MAX_AGE, PRODUCT_NAME, VERSION
36
42
  } = deps;
37
43
 
44
+ function ownsExecutor(session) {
45
+ return session.status === 'running' || (session.type === 'app-server' &&
46
+ !session.executorReleased && !session.adapter.snapshot().processExited) ||
47
+ (session.type === 'native' && !session.nativeRetired && session.status !== 'exited');
48
+ }
49
+
38
50
  const handleWebRequest = async (req, res) => {
39
51
  const url = requestUrl(req);
40
52
  try {
@@ -54,7 +66,7 @@ export function createHttpRoutes(deps) {
54
66
  }
55
67
  const sid = issueSession();
56
68
  const params = new URLSearchParams();
57
- for (const key of ['project', 'root']) {
69
+ for (const key of ['project', 'root', 'session']) {
58
70
  const value = url.searchParams.get(key);
59
71
  if (value) params.set(key, value);
60
72
  }
@@ -80,6 +92,14 @@ export function createHttpRoutes(deps) {
80
92
  sendFile(res, path.join(packageRoot(), 'node_modules', '@xterm', 'xterm', 'css', 'xterm.css'), 'text/css; charset=utf-8');
81
93
  return;
82
94
  }
95
+ if (req.method === 'GET' && url.pathname === '/assets/addon-fit.js') {
96
+ sendFile(res, path.join(packageRoot(), 'node_modules', '@xterm', 'addon-fit', 'lib', 'addon-fit.js'), 'application/javascript; charset=utf-8');
97
+ return;
98
+ }
99
+ if (req.method === 'GET' && url.pathname === '/assets/addon-search.js') {
100
+ sendFile(res, path.join(packageRoot(), 'node_modules', '@xterm', 'addon-search', 'lib', 'addon-search.js'), 'application/javascript; charset=utf-8');
101
+ return;
102
+ }
83
103
  if (req.method === 'POST' && url.pathname === '/login') {
84
104
  const input = await readJsonRequest(req);
85
105
  const loginToken = String(input.token || '');
@@ -105,6 +125,16 @@ export function createHttpRoutes(deps) {
105
125
  sendJson(res, 401, { ok: false, error: { code: 'UNAUTHORIZED', message: 'Missing or invalid token' } });
106
126
  return;
107
127
  }
128
+ if (req.method === 'GET' && url.pathname === '/pane') {
129
+ // Only an existing browser session may create a workspace pane. Never
130
+ // exchange a query token here or make the login page embeddable.
131
+ if (!cookieSessionOk(req)) {
132
+ sendJson(res, 401, { ok: false, error: { code: 'UNAUTHORIZED', message: 'A browser session is required for a workspace pane' } });
133
+ return;
134
+ }
135
+ sendWebHtml(res, renderWebIndex, { pane: true });
136
+ return;
137
+ }
108
138
  const safeMethod = ['GET', 'HEAD', 'OPTIONS'].includes(req.method || '');
109
139
  // Cookie-authenticated writes require affirmative same-origin evidence;
110
140
  // a missing Origin is not sufficient. Token-authenticated CLI requests
@@ -190,6 +220,10 @@ export function createHttpRoutes(deps) {
190
220
  resource: url.searchParams.getAll('resource')
191
221
  }
192
222
  : await readJsonRequest(req);
223
+ if (!readOnly && nativeAction) {
224
+ const nativeSession = sessionsForProject(reqCtx).find(s => s.type === 'native' && [s.id, s.peerId].includes(peer));
225
+ if (nativeSession) await nativeAction(nativeSession, 'read');
226
+ }
193
227
  const actionInput = readOnly
194
228
  ? input
195
229
  : { ...input, actorPeer: resolveWebActionSession(reqCtx, peer, input, req) };
@@ -198,17 +232,20 @@ export function createHttpRoutes(deps) {
198
232
  }
199
233
  // Detected sessions: peers registered via hooks/watcher but without PTY
200
234
  if (req.method === 'GET' && url.pathname === '/api/detected') {
235
+ await discoverNativeSessions?.(reqCtx);
201
236
  const db = connectWebProject(reqCtx);
202
237
  let detected = [];
203
238
  const managedIds = new Set();
204
239
  const t = now();
205
240
  try {
206
241
  detected = db.prepare(`
207
- SELECT id, kind, role, status, worktree, branch, pid, capabilities,
208
- created_at, last_seen_at, (? - last_seen_at) AS age_sec
209
- FROM peers
210
- WHERE status != 'exited' AND last_seen_at >= ?
211
- ORDER BY last_seen_at DESC, id ASC
242
+ SELECT p.id, p.kind, p.role, p.status, p.worktree, p.branch, p.pid, p.capabilities,
243
+ p.created_at, p.last_seen_at, (? - p.last_seen_at) AS age_sec,
244
+ b.provider, b.transport, b.provider_session_id, b.provider_session_name
245
+ FROM peers p
246
+ LEFT JOIN peer_bindings b ON b.peer = p.id
247
+ WHERE p.status != 'exited' AND p.last_seen_at >= ?
248
+ ORDER BY p.last_seen_at DESC, p.id ASC
212
249
  LIMIT 100
213
250
  `).all(t, t - DETECTED_PEER_MAX_AGE);
214
251
  for (const session of sessionsForProject(reqCtx)) {
@@ -312,11 +349,13 @@ export function createHttpRoutes(deps) {
312
349
  throw new CliError('BAD_REQUEST', 'buffer GC phase must be prepare or apply');
313
350
  }
314
351
  if (req.method === 'POST' && url.pathname === '/api/runtime/stop') {
352
+ if (cookieAuthenticated) throw new CliError('RUNTIME_ADMIN_REQUIRED', 'Stop the runtime with the local administrator CLI');
315
353
  sendJson(res, 200, { ok: true, pid: process.pid });
316
354
  setTimeout(shutdown, 10);
317
355
  return;
318
356
  }
319
357
  if (req.method === 'GET' && url.pathname === '/api/sessions') {
358
+ await discoverNativeSessions?.(reqCtx);
320
359
  const db = connectWebProject(reqCtx);
321
360
  try {
322
361
  sendJson(res, 200, {
@@ -329,23 +368,131 @@ export function createHttpRoutes(deps) {
329
368
  }
330
369
  if (req.method === 'POST' && url.pathname === '/api/sessions') {
331
370
  const input = await readJsonRequest(req);
332
- const session = startSession({ ...input, projectCtx: reqCtx, auditActorPeer: 'web', auditSource: 'web' });
371
+ const existing = input.id && sessionsForProject(reqCtx).find(s => s.id === input.id || s.peerId === input.id);
372
+ if (existing) assertWebWrite?.(existing, input, authMode, req);
373
+ const start = input.transport === 'app-server' || input.backend === 'app-server' ? startCodexSession : startSession;
374
+ const session = await start({ ...input, projectCtx: reqCtx, auditActorPeer: 'web', auditSource: 'web',
375
+ authorizeMutation: existing => assertWebWrite?.(existing, input, authMode, req) });
333
376
  sendJson(res, 200, { session: serializeSession(session) });
334
377
  return;
335
378
  }
336
379
  if (req.method === 'POST' && url.pathname === '/api/sessions/attach') {
337
380
  const input = await readJsonRequest(req);
338
- const session = attachTmuxSession({ ...input, projectCtx: reqCtx, auditActorPeer: 'web', auditSource: 'web' });
381
+ const bindingDb = connectWebProject(reqCtx);
382
+ let binding;
383
+ try {
384
+ binding = input.id ? bindingDb.prepare('SELECT provider, transport FROM peer_bindings WHERE peer = ?').get(input.id) : null;
385
+ } finally {
386
+ bindingDb.close();
387
+ }
388
+ if (binding?.transport === 'native') throw new CliError('NATIVE_MANAGED_SESSION', 'Open the existing native worker instead of attaching a terminal');
389
+ if (isDshHookBinding(binding) || input.kind === 'dsh' || input.binding?.provider === 'dsh') {
390
+ sendJson(res, 409, { ok: false, error: { code: 'DSH_SESSION_CONTROL_UNSUPPORTED', message: 'Control this agent in the dsh Web UI. A dsh service pane cannot represent an individual agent session.' } });
391
+ return;
392
+ }
393
+ for (const existing of sessionsForProject(reqCtx)) {
394
+ if ((input.id && [existing.id, existing.peerId].includes(input.id)) ||
395
+ (input.pane && existing.pane === input.pane)) assertWebWrite?.(existing, input, authMode, req);
396
+ }
397
+ const session = attachTmuxSession({ ...input, projectCtx: reqCtx, auditActorPeer: 'web', auditSource: 'web',
398
+ authorizeMutation: existing => assertWebWrite?.(existing, input, authMode, req) });
399
+ const adoptionDb = connectWebProject(reqCtx);
400
+ try { writeAdoptionState(adoptionDb, session, false, now()); } finally { adoptionDb.close(); }
339
401
  sendJson(res, 200, { session: serializeSession(session) });
340
402
  return;
341
403
  }
404
+ const nativeMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/native\/([^/]+)$/);
405
+ if (nativeMatch) {
406
+ await discoverNativeSessions?.(reqCtx);
407
+ const db = connectWebProject(reqCtx);
408
+ let session;
409
+ try { session = getSession(reqCtx, decodeURIComponent(nativeMatch[1]), db); } finally { db.close(); }
410
+ if (!session || session.type !== 'native') throw new CliError('NOT_FOUND', 'Native worker not found');
411
+ const action = decodeURIComponent(nativeMatch[2]);
412
+ if (req.method === 'GET' && action === 'state') {
413
+ sendJson(res, 200, { state: await nativeAction(session, 'read') }); return;
414
+ }
415
+ if (req.method !== 'POST' || !['send', 'interrupt', 'close', 'respond'].includes(action)) throw new CliError('BAD_REQUEST', 'Unsupported native action');
416
+ const input = await readJsonRequest(req);
417
+ const authorizeMutation = () => assertWebWrite(session, input, 'cookie', req);
418
+ authorizeMutation();
419
+ const receipt = await nativeAction(session, action, { ...input, authorizeMutation });
420
+ sendJson(res, 200, { receipt, state: session.nativeSnapshot() }); return;
421
+ }
422
+ if (req.method === 'GET' && url.pathname === '/api/codex/threads') {
423
+ const limit = url.searchParams.has('limit') ? Number(url.searchParams.get('limit')) : 30;
424
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new CliError('BAD_REQUEST', 'limit must be from 1 to 100');
425
+ sendJson(res, 200, await listCodexThreads(reqCtx, { cursor: url.searchParams.get('cursor') || null, limit }));
426
+ return;
427
+ }
428
+ const historyMatch = url.pathname.match(/^\/api\/codex\/threads\/([^/]+)(\/fork)?$/);
429
+ if (historyMatch) {
430
+ const threadId = decodeURIComponent(historyMatch[1]);
431
+ if (req.method === 'GET' && !historyMatch[2]) {
432
+ sendJson(res, 200, await readCodexThread(reqCtx, threadId)); return;
433
+ }
434
+ if (req.method !== 'POST' || !historyMatch[2]) throw new CliError('BAD_REQUEST', 'Unsupported history action');
435
+ const input = await readJsonRequest(req);
436
+ // Fork keeps the source task owner; it is still a mutation of history
437
+ // and must respect a source session's browser control when present.
438
+ for (const source of sessionsForProject(reqCtx)) {
439
+ if (source.binding?.provider === 'codex' && source.binding.provider_session_id === threadId && ownsExecutor(source)) {
440
+ assertWebWrite(source, input, 'cookie', req);
441
+ }
442
+ }
443
+ const authorizeMutation = () => {
444
+ for (const source of sessionsForProject(reqCtx)) {
445
+ if (source.binding?.provider === 'codex' && source.binding.provider_session_id === threadId && ownsExecutor(source)) {
446
+ assertWebWrite(source, input, 'cookie', req);
447
+ }
448
+ }
449
+ };
450
+ const forked = await forkCodexThread(reqCtx, threadId, { ...input, authorizeMutation });
451
+ sendJson(res, 200, { session: serializeSession(forked.session), thread: forked.thread, source_thread_id: threadId }); return;
452
+ }
453
+ const resultsMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/results$/);
454
+ if (resultsMatch) {
455
+ const db = connectWebProject(reqCtx);
456
+ let session;
457
+ try { session = getSession(reqCtx, decodeURIComponent(resultsMatch[1]), db); } finally { db.close(); }
458
+ if (!session) throw new CliError('NOT_FOUND', 'Session not found');
459
+ if (req.method === 'GET') { sendJson(res, 200, readSessionResults(session)); return; }
460
+ if (req.method !== 'POST') throw new CliError('BAD_REQUEST', 'Unsupported result action');
461
+ const input = await readJsonRequest(req);
462
+ assertWebWrite(session, input, 'cookie', req);
463
+ if (session.type === 'native') {
464
+ await nativeAction(session, 'read');
465
+ assertWebWrite(session, input, 'cookie', req);
466
+ }
467
+ sendJson(res, 200, writeSessionResult(session, input)); return;
468
+ }
469
+ const codexMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/codex\/([^/]+)$/);
470
+ if (codexMatch && ['GET', 'POST'].includes(req.method)) {
471
+ const id = decodeURIComponent(codexMatch[1]);
472
+ const action = decodeURIComponent(codexMatch[2]);
473
+ const db = connectWebProject(reqCtx);
474
+ let session;
475
+ try { session = getSession(reqCtx, id, db); } finally { db.close(); }
476
+ if (!session || session.type !== 'app-server') throw new CliError('NOT_FOUND', 'Codex App Server session not found');
477
+ if (req.method === 'GET' && action === 'state') {
478
+ sendJson(res, 200, { state: session.adapter.snapshot() }); return;
479
+ }
480
+ if (req.method === 'GET' && action === 'read') {
481
+ sendJson(res, 200, await codexAction(session, 'read', {})); return;
482
+ }
483
+ if (req.method !== 'POST' || !['turn', 'steer', 'interrupt', 'approve'].includes(action)) {
484
+ throw new CliError('BAD_REQUEST', 'Unsupported Codex action');
485
+ }
486
+ const input = await readJsonRequest(req);
487
+ // Structured turns and approvals always need a live browser controller,
488
+ // even when the caller also possesses the runtime administrator token.
489
+ assertWebWrite(session, input, 'cookie', req);
490
+ sendJson(res, 200, await codexAction(session, action, input)); return;
491
+ }
342
492
  const inputMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/input$/);
343
493
  if (req.method === 'POST' && inputMatch) {
344
- // This HTTP input route is the admin injection path used by the CLI
345
- // (hcc inject / ask / broadcast). It is gated by the runtime token
346
- // (authOk above) and intentionally does NOT require the per-session
347
- // action token — the browser types via the WebSocket input frame
348
- // (which DOES require it), and the CLI cannot obtain the action token.
494
+ // CLI injection remains available without a browser controller. While
495
+ // Web owns a lease, all injection paths require that controller's epoch.
349
496
  const id = decodeURIComponent(inputMatch[1]);
350
497
  const lookupDb = connectWebProject(reqCtx);
351
498
  let session;
@@ -363,6 +510,8 @@ export function createHttpRoutes(deps) {
363
510
  return;
364
511
  }
365
512
  const input = await readJsonRequest(req);
513
+ assertWebWrite?.(session, input, authMode, req);
514
+ if (['app-server', 'native'].includes(session.type)) throw new CliError('BAD_REQUEST', 'Use structured worker actions');
366
515
  const text = String(input.text ?? input.data ?? '');
367
516
  const data = input.data !== undefined ? String(input.data) : `${text}${input.enter === false ? '' : '\r'}`;
368
517
  writeSessionInput(session, data);
@@ -400,7 +549,13 @@ export function createHttpRoutes(deps) {
400
549
  sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Session not found' } });
401
550
  return;
402
551
  }
403
- if (session.status === 'running') {
552
+ assertWebWrite?.(session, stopInput, authMode, req);
553
+ if (session.type === 'native') {
554
+ await nativeAction(session, 'close', { ...stopInput,
555
+ authorizeMutation: () => assertWebWrite(session, stopInput, 'cookie', req) });
556
+ } else if (session.type === 'app-server') {
557
+ await stopCodexSession(session);
558
+ } else if (session.status === 'running') {
404
559
  if (session.type === 'external') {
405
560
  // Stop the hcc run wrapper first so it can kill the PTY and clean
406
561
  // buffer files; fall back to the child pid for older metadata.
@@ -414,6 +569,7 @@ export function createHttpRoutes(deps) {
414
569
  if (stopInput.kill_tmux) {
415
570
  killPlan = safeTmuxKillPlan(reqCtx, stopDb, peerId, session.pane || null);
416
571
  }
572
+ if (!stopInput.kill_tmux) writeAdoptionState(stopDb, session, true, now());
417
573
  } finally {
418
574
  stopDb.close();
419
575
  }
@@ -446,6 +602,10 @@ export function createHttpRoutes(deps) {
446
602
  if (req.method === 'POST' && detectedMsgMatch) {
447
603
  const peerId = decodeURIComponent(detectedMsgMatch[1]);
448
604
  const input = await readJsonRequest(req);
605
+ if (['all', '*'].includes(peerId)) throw new CliError('CONTROL_REQUIRED', 'Detected inbox messaging requires an individual peer; use a controlled coordination action for broadcast');
606
+ if (sessionsForProject(reqCtx).some(s => [s.id, s.peerId].includes(peerId))) {
607
+ throw new CliError('CONTROL_REQUIRED', 'Use the managed session message endpoint and its control lease');
608
+ }
449
609
  const body = String(input.body || '');
450
610
  const sender = 'web';
451
611
  const taskId = input.task ? Number(input.task) : null;
@@ -453,6 +613,8 @@ export function createHttpRoutes(deps) {
453
613
  const db = connectWebProject(reqCtx);
454
614
  let msgId;
455
615
  try {
616
+ const binding = db.prepare('SELECT transport FROM peer_bindings WHERE peer = ?').get(peerId);
617
+ if (['native', 'app-server'].includes(binding?.transport)) throw new CliError('STRUCTURED_SESSION_NO_TERMINAL', 'Use the structured worker control endpoint for messages');
456
618
  msgId = sendMessage(db, sender, peerId, taskId, 'note', body);
457
619
  } finally {
458
620
  db.close();
@@ -463,10 +625,19 @@ export function createHttpRoutes(deps) {
463
625
  const detectedStopMatch = url.pathname.match(/^\/api\/detected\/([^/]+)\/stop$/);
464
626
  if (req.method === 'POST' && detectedStopMatch) {
465
627
  const peerId = decodeURIComponent(detectedStopMatch[1]);
628
+ if (sessionsForProject(reqCtx).some(s => [s.id, s.peerId].includes(peerId))) {
629
+ throw new CliError('CONTROL_REQUIRED', 'Use the managed session stop endpoint and its control lease');
630
+ }
466
631
  let input = {};
467
632
  try { input = await readJsonRequest(req); } catch {}
468
633
  const db = connectWebProject(reqCtx);
469
634
  try {
635
+ const binding = db.prepare('SELECT provider, transport FROM peer_bindings WHERE peer = ?').get(peerId);
636
+ if (binding?.transport === 'native') throw new CliError('NATIVE_MANAGED_SESSION', 'Use the existing native worker control endpoint');
637
+ if (isDshHookBinding(binding)) {
638
+ sendJson(res, 409, { ok: false, error: { code: 'DSH_SESSION_CONTROL_UNSUPPORTED', message: 'Control this agent in the dsh Web UI. hello-cc hooks support coordination only.' } });
639
+ return;
640
+ }
470
641
  const now_ = now();
471
642
  let killPlan = null;
472
643
  if (input.kill_tmux) {
@@ -495,8 +666,17 @@ export function createHttpRoutes(deps) {
495
666
  const detectedRestartMatch = url.pathname.match(/^\/api\/detected\/([^/]+)\/restart$/);
496
667
  if (req.method === 'POST' && detectedRestartMatch) {
497
668
  const peerId = decodeURIComponent(detectedRestartMatch[1]);
669
+ if (sessionsForProject(reqCtx).some(s => [s.id, s.peerId].includes(peerId))) {
670
+ throw new CliError('CONTROL_REQUIRED', 'Open the existing managed session');
671
+ }
498
672
  const db = connectWebProject(reqCtx);
499
673
  try {
674
+ const binding = db.prepare('SELECT provider, transport FROM peer_bindings WHERE peer = ?').get(peerId);
675
+ if (binding?.transport === 'native') throw new CliError('NATIVE_MANAGED_SESSION', 'Use the existing native worker control endpoint');
676
+ if (isDshHookBinding(binding)) {
677
+ sendJson(res, 409, { ok: false, error: { code: 'DSH_SESSION_CONTROL_UNSUPPORTED', message: 'Resume this agent in the dsh Web UI. hello-cc hooks support coordination only.' } });
678
+ return;
679
+ }
500
680
  // v1-detected-restart: only flip a real detected peer that has a live
501
681
  // process or is not explicitly exited. Do not bump last_seen_at
502
682
  // (the peer did not actually heartbeat) so the reaper's age filter
@@ -522,12 +702,14 @@ export function createHttpRoutes(deps) {
522
702
  }
523
703
  sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Route not found' } });
524
704
  } catch (err) {
705
+ if (err instanceof CodexAppServerError) err = new CliError(err.code, err.message);
525
706
  const publicFailure = publicCliFailure(err);
526
707
  const publicError = publicFailure?.error || err;
527
708
  const detail = publicFailure || process.env.HCC_DEBUG
528
709
  ? publicError.message
529
710
  : 'internal server error';
530
- sendJson(res, webErrorStatus(publicError), {
711
+ const status = publicError.code === 'DSH_SESSION_CONTROL_UNSUPPORTED' ? 409 : webErrorStatus(publicError);
712
+ sendJson(res, status, {
531
713
  ok: false,
532
714
  error: {
533
715
  code: publicError.code || 'SERVER_ERROR',
package/lib/web/http.mjs CHANGED
@@ -30,7 +30,7 @@ export async function readJsonRequest(req) {
30
30
  }
31
31
  }
32
32
 
33
- export function contentSecurityPolicy(nonce) {
33
+ export function contentSecurityPolicy(nonce, { pane = false } = {}) {
34
34
  const value = String(nonce || '');
35
35
  if (!/^[A-Za-z0-9_-]{16,}$/.test(value)) {
36
36
  throw new CliError('BAD_ARGS', 'A valid CSP nonce is required');
@@ -40,7 +40,8 @@ export function contentSecurityPolicy(nonce) {
40
40
  "style-src 'self' 'unsafe-inline'; " +
41
41
  "img-src 'self' data:; " +
42
42
  "connect-src 'self' ws: wss:; " +
43
- "object-src 'none'; base-uri 'none'; frame-ancestors 'none'";
43
+ "object-src 'none'; base-uri 'none'; " +
44
+ (pane === true ? "frame-ancestors 'self'; frame-src 'none'" : "frame-ancestors 'none'");
44
45
  }
45
46
 
46
47
  export function sendHttp(res, status, contentType, body, options = {}) {
@@ -49,9 +50,9 @@ export function sendHttp(res, status, contentType, body, options = {}) {
49
50
  'Cache-Control': 'no-store',
50
51
  'Referrer-Policy': 'no-referrer',
51
52
  'X-Content-Type-Options': 'nosniff',
52
- 'X-Frame-Options': 'DENY',
53
+ 'X-Frame-Options': options.pane === true && options.nonce ? 'SAMEORIGIN' : 'DENY',
53
54
  'Content-Security-Policy': options.nonce
54
- ? contentSecurityPolicy(options.nonce)
55
+ ? contentSecurityPolicy(options.nonce, options)
55
56
  : "frame-ancestors 'none'"
56
57
  });
57
58
  res.end(body);