ompchamber 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +24 -8
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/static/css/index.5c0447cefe.css +2 -0
  4. package/dist/client/static/js/async/{3y.4c2596f64c.js → 3y.71f2ccf3b6.js} +2 -2
  5. package/dist/client/static/js/async/{xw.ae67ba06fd.js → 5b.0d6ae50fe8.js} +10 -10
  6. package/dist/client/static/js/async/5bh.8b2363424f.js +1 -0
  7. package/dist/client/static/js/async/{vy.bbe114c4e0.js → bi.09688ba75c.js} +1 -2
  8. package/dist/client/static/js/async/c.797d933882.js +1 -0
  9. package/dist/client/static/js/async/{dc.6dc4b0a04b.js → dc.856fda6560.js} +2 -2
  10. package/dist/client/static/js/async/{g.8515cbc6f7.js → g.d1c2fe09aa.js} +2 -2
  11. package/dist/client/static/js/async/g.d1c2fe09aa.js.LICENSE.txt +11 -0
  12. package/dist/client/static/js/async/{m.4b29d34f93.js → m.0896dab823.js} +2 -2
  13. package/dist/client/static/js/async/ma.2c261b04dd.js +2 -0
  14. package/dist/client/static/js/async/nam.b5663e919f.js +10 -0
  15. package/dist/client/static/js/async/nam.b5663e919f.js.LICENSE.txt +11 -0
  16. package/dist/client/static/js/async/{q.542c184fec.js → q.23af2a358f.js} +3 -3
  17. package/dist/client/static/js/async/{s.413c2a4683.js → s.222ee974f6.js} +2 -2
  18. package/dist/client/static/js/async/t.4dca0e2ba8.js +1 -0
  19. package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
  20. package/dist/client/static/js/async/x4.69ec69c61b.js +6 -0
  21. package/dist/client/static/js/async/xn.6360379f71.js +2 -0
  22. package/dist/client/static/js/async/xnl.feb0021e50.js +2 -0
  23. package/dist/client/static/js/index.dc5f5fabe8.js +33 -0
  24. package/dist/client/static/js/{uy.2e8929ed0c.js → jp.50817896f3.js} +6 -6
  25. package/package.json +3 -1
  26. package/src/cli/lib/args.js +1 -1
  27. package/src/cli/lib/commands/restart.js +11 -0
  28. package/src/cli/lib/commands/update.js +21 -7
  29. package/src/cli/lib/runtime.js +6 -3
  30. package/src/client/components/common/MarkdownRenderer.tsx +69 -27
  31. package/src/client/components/common/code-editor/history.test.ts +118 -0
  32. package/src/client/components/common/code-editor/history.ts +101 -0
  33. package/src/client/components/common/code-editor/index.tsx +56 -66
  34. package/src/client/components/common/code-surface/index.tsx +84 -11
  35. package/src/client/components/common/code-surface/measure.ts +66 -17
  36. package/src/client/components/common/diagram-viewer/index.tsx +258 -0
  37. package/src/client/components/common/diagram-viewer/read-block.ts +53 -0
  38. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -0
  39. package/src/client/components/layout/session-sidebar/SessionItem.tsx +10 -3
  40. package/src/client/components/settings/LazyModal.tsx +1 -1
  41. package/src/client/components/settings/Modal.tsx +2 -1
  42. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +8 -1
  43. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +11 -52
  44. package/src/client/components/workspace/chat-timeline/chat-input/selection.ts +57 -0
  45. package/src/client/components/workspace/chat-timeline/index.tsx +121 -107
  46. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/FrameControl.tsx +172 -0
  47. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +155 -0
  48. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/drafts.ts +113 -0
  49. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +92 -0
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Footer.tsx +3 -3
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Header.tsx +3 -3
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Lazy.tsx +8 -8
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/SelectBody.tsx +3 -3
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/index.tsx +16 -10
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/index.tsx +2 -2
  56. package/src/client/components/workspace/file-explorer/index.tsx +18 -5
  57. package/src/client/hooks/chat/omp/index.ts +4 -109
  58. package/src/client/hooks/chat/omp/prompt-send.ts +161 -0
  59. package/src/client/hooks/chat/omp/session-list.tsx +4 -0
  60. package/src/client/hooks/chat/timeline/actions.ts +0 -9
  61. package/src/client/hooks/chat/timeline/ask-frames.ts +102 -0
  62. package/src/client/hooks/chat/timeline/extension-dialog.ts +12 -9
  63. package/src/client/hooks/chat/timeline/index.ts +6 -8
  64. package/src/client/hooks/chat/timeline/prompt-text.ts +35 -0
  65. package/src/client/hooks/chat/timeline/send.ts +16 -29
  66. package/src/client/hooks/chat/timeline/session-load.ts +48 -106
  67. package/src/client/hooks/chat/timeline/session-pagination.ts +144 -0
  68. package/src/client/hooks/editor/use-code-window.ts +118 -0
  69. package/src/client/hooks/ui/input-required-alert.ts +43 -0
  70. package/src/client/hooks/ui/notification-sound.ts +65 -0
  71. package/src/client/hooks/ui/theme.ts +22 -6
  72. package/src/client/tailwind.css +38 -12
  73. package/src/server/index.ts +4 -3
  74. package/src/server/lib/lifecycle/bun.ts +18 -0
  75. package/src/server/lib/lifecycle/launch-mode.test.ts +32 -0
  76. package/src/server/lib/lifecycle/launch-mode.ts +40 -0
  77. package/src/server/lib/lifecycle/restart.test.ts +21 -0
  78. package/src/server/lib/lifecycle/restart.ts +111 -0
  79. package/src/server/lib/omp/rpc/manager.ts +14 -3
  80. package/src/server/lib/omp/rpc/session-commands.ts +15 -1
  81. package/src/server/lib/omp/rpc/session-registry.ts +18 -0
  82. package/src/server/lib/omp/session/sidebar-data.server.ts +7 -1
  83. package/src/server/lib/updates/apply.ts +8 -5
  84. package/src/server/lib/updates/install.ts +7 -4
  85. package/src/server/routes/health/probe.ts +2 -1
  86. package/src/shared/lib/chat/ask-questions.test.ts +169 -0
  87. package/src/shared/lib/chat/ask-questions.ts +264 -0
  88. package/src/shared/lib/code/lazy-window.test.ts +161 -0
  89. package/src/shared/lib/code/lazy-window.ts +156 -0
  90. package/src/shared/lib/code/syntax-highlight.test.ts +50 -1
  91. package/src/shared/lib/code/syntax-highlight.ts +14 -1
  92. package/src/shared/lib/code/windowed-highlight.ts +63 -0
  93. package/src/shared/lib/markdown/mermaid.ts +8 -6
  94. package/src/shared/lib/omp/session/stream-state.server.ts +16 -1
  95. package/src/shared/types/omp/agent.ts +2 -2
  96. package/src/shared/types/workspace.ts +4 -0
  97. package/dist/client/static/css/index.51f68210a5.css +0 -2
  98. package/dist/client/static/js/async/5b.d88304bc13.js +0 -1
  99. package/dist/client/static/js/async/c.cd45b0ac45.js +0 -1
  100. package/dist/client/static/js/async/fw.6a51998a8e.js +0 -6
  101. package/dist/client/static/js/async/kc.bbdb35b771.js +0 -1
  102. package/dist/client/static/js/async/mw.ca1512563c.js +0 -10
  103. package/dist/client/static/js/async/rf.d626afc367.js +0 -2
  104. package/dist/client/static/js/async/t.cda4891526.js +0 -1
  105. package/dist/client/static/js/async/uf.24ca3d49c3.js +0 -2
  106. package/dist/client/static/js/async/v0.394ed37c6b.js +0 -2
  107. package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +0 -6
  108. package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +0 -6
  109. package/dist/client/static/js/async/xn.afecebbbd9.js +0 -2
  110. package/dist/client/static/js/index.47d3b04d5f.js +0 -33
  111. package/dist/client/static/js/index.47d3b04d5f.js.LICENSE.txt +0 -4
  112. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Ask.tsx +0 -205
  113. /package/dist/client/static/js/async/{3y.4c2596f64c.js.LICENSE.txt → 3y.71f2ccf3b6.js.LICENSE.txt} +0 -0
  114. /package/dist/client/static/js/async/{g.8515cbc6f7.js.LICENSE.txt → 5b.0d6ae50fe8.js.LICENSE.txt} +0 -0
  115. /package/dist/client/static/js/async/{dc.6dc4b0a04b.js.LICENSE.txt → dc.856fda6560.js.LICENSE.txt} +0 -0
  116. /package/dist/client/static/js/async/{fw.6a51998a8e.js.LICENSE.txt → m.0896dab823.js.LICENSE.txt} +0 -0
  117. /package/dist/client/static/js/async/{m.4b29d34f93.js.LICENSE.txt → ma.2c261b04dd.js.LICENSE.txt} +0 -0
  118. /package/dist/client/static/js/async/{mw.ca1512563c.js.LICENSE.txt → q.23af2a358f.js.LICENSE.txt} +0 -0
  119. /package/dist/client/static/js/async/{q.542c184fec.js.LICENSE.txt → s.222ee974f6.js.LICENSE.txt} +0 -0
  120. /package/dist/client/static/js/async/{rf.d626afc367.js.LICENSE.txt → uf.46ad6209fa.js.LICENSE.txt} +0 -0
  121. /package/dist/client/static/js/async/{s.413c2a4683.js.LICENSE.txt → x4.69ec69c61b.js.LICENSE.txt} +0 -0
  122. /package/dist/client/static/js/async/{uf.24ca3d49c3.js.LICENSE.txt → xn.6360379f71.js.LICENSE.txt} +0 -0
  123. /package/dist/client/static/js/async/{xn.afecebbbd9.js.LICENSE.txt → xnl.feb0021e50.js.LICENSE.txt} +0 -0
  124. /package/dist/client/static/js/{async/xw.ae67ba06fd.js.LICENSE.txt → index.dc5f5fabe8.js.LICENSE.txt} +0 -0
  125. /package/dist/client/static/js/{uy.2e8929ed0c.js.LICENSE.txt → jp.50817896f3.js.LICENSE.txt} +0 -0
@@ -318,6 +318,11 @@
318
318
  border-radius: 0.5rem;
319
319
  background: var(--theme-paper);
320
320
  padding: 0.75rem;
321
+ /* `applySvg` swaps the `.code-block` shell (the positioned ancestor the copy
322
+ button relied on) for the SVG, so the block owns the anchor itself. */
323
+ position: relative;
324
+ /* The block is the door into the zoom/pan viewer. */
325
+ cursor: zoom-in;
321
326
  }
322
327
  .prose-content .mermaid-block > svg {
323
328
  display: block;
@@ -332,6 +337,27 @@
332
337
  color: var(--theme-error);
333
338
  }
334
339
 
340
+ /* Full-screen mermaid viewer (`common/diagram-viewer/`): a chart opened from
341
+ the timeline. The stage is the fixed-size pan surface, the canvas child
342
+ carries the zoom/pan transform, so the SVG keeps its natural pixel size and
343
+ `100%` on it always means "the size mermaid rendered". */
344
+ .diagram-viewer-stage {
345
+ touch-action: none;
346
+ overscroll-behavior: contain;
347
+ }
348
+ .diagram-viewer-canvas {
349
+ transform-origin: center center;
350
+ will-change: transform;
351
+ background: var(--theme-paper);
352
+ box-shadow: 0 25px 50px -12px color-mix(in srgb, var(--theme-ink) 45%, transparent);
353
+ }
354
+ .diagram-viewer-canvas > svg {
355
+ display: block;
356
+ width: 100% !important;
357
+ height: 100% !important;
358
+ max-width: none !important;
359
+ }
360
+
335
361
  /* PWA Window Controls Overlay and Native Titlebar Styling */
336
362
  .titlebar-drag-region {
337
363
  -webkit-app-region: drag;
@@ -344,31 +370,31 @@
344
370
  }
345
371
 
346
372
 
347
- /* Ask-dialog enter animation — replaces the retired `motion` dependency.
348
- Exit is intentionally instant: a 160ms fade-out on a blocking modal is not
349
- load-bearing, and keeping the element mounted to animate it out would add
350
- state the dialog does not otherwise need. */
351
- @keyframes ask-dialog-backdrop-in {
373
+ /* Blocking extension-dialog enter animation — replaces the retired `motion`
374
+ dependency. Exit is intentionally instant: a 160ms fade-out on a blocking
375
+ modal is not load-bearing, and keeping the element mounted to animate it out
376
+ would add state the dialog does not otherwise need. */
377
+ @keyframes extension-dialog-backdrop-in {
352
378
  from { opacity: 0; }
353
379
  to { opacity: 1; }
354
380
  }
355
381
 
356
- @keyframes ask-dialog-panel-in {
382
+ @keyframes extension-dialog-panel-in {
357
383
  from { opacity: 0; transform: translateY(10px) scale(0.95); }
358
384
  to { opacity: 1; transform: none; }
359
385
  }
360
386
 
361
- .ask-dialog-backdrop {
362
- animation: ask-dialog-backdrop-in 150ms ease-out;
387
+ .extension-dialog-backdrop {
388
+ animation: extension-dialog-backdrop-in 150ms ease-out;
363
389
  }
364
390
 
365
- .ask-dialog-panel {
366
- animation: ask-dialog-panel-in 180ms ease-out;
391
+ .extension-dialog-panel {
392
+ animation: extension-dialog-panel-in 180ms ease-out;
367
393
  }
368
394
 
369
395
  @media (prefers-reduced-motion: reduce) {
370
- .ask-dialog-backdrop,
371
- .ask-dialog-panel {
396
+ .extension-dialog-backdrop,
397
+ .extension-dialog-panel {
372
398
  animation: none;
373
399
  }
374
400
  }
@@ -6,6 +6,7 @@ import { getDatabasePath } from '@/server/db.server';
6
6
  import { fetchOmpRegistrySnapshot } from '@/server/lib/models/provider-registry.server';
7
7
  import { ompStartupError, ompStartupLogLines } from '@/server/lib/omp/core/startup';
8
8
  import { readInstanceRecord, removeInstanceRecord, writeInstanceRecord } from '@/server/lib/lifecycle/instance';
9
+ import { resolveLaunchMode } from '@/server/lib/lifecycle/launch-mode';
9
10
  import { acquirePortLock, claimPort, PortInUseError } from '@/server/lib/lifecycle/port-guard';
10
11
  import { isMockMode } from '@/server/mock.server';
11
12
 
@@ -28,9 +29,9 @@ console.log(`[ompchamber] db: ${await getDatabasePath()}`);
28
29
  const port = Number(Bun.env.PORT) || 3000;
29
30
  const host = Bun.env.HOST || 'localhost';
30
31
  const mode = Bun.env.NODE_ENV === 'production' ? 'prod' : 'dev';
31
- const launchMode = Bun.env.OMPCHAMBER_LAUNCH_MODE === 'daemon' || Bun.env.OMPCHAMBER_LAUNCH_MODE === 'foreground'
32
- ? Bun.env.OMPCHAMBER_LAUNCH_MODE
33
- : 'direct';
32
+ // argv, not env: see launch-mode.ts — an inherited `OMPCHAMBER_LAUNCH_MODE`
33
+ // would label a `bun run dev` started inside an OMPChamber shell as `daemon`.
34
+ const launchMode = resolveLaunchMode();
34
35
 
35
36
  const lock = await acquirePortLock(port);
36
37
  if (!lock) {
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The Bun binary to spawn child processes with.
8
+ *
9
+ * The server is usually itself Bun, so `process.execPath` is the most
10
+ * trustworthy answer — it is the exact runtime this install was started with.
11
+ * A host that is not Bun falls back to a PATH lookup, and finally to the
12
+ * literal `bun`, which lets the child resolve it from its own environment.
13
+ */
14
+ export function resolveBunBin(): string {
15
+ const execPath = process.execPath;
16
+ if (/(^|[\\/])bun$/.test(execPath)) return execPath;
17
+ return Bun.which('bun') ?? 'bun';
18
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { launchModeArg, resolveLaunchMode } from '@/server/lib/lifecycle/launch-mode';
4
+
5
+ const ENTRY = '/repo/src/server/index.ts';
6
+
7
+ describe('resolveLaunchMode', () => {
8
+ test('reads the flag the CLI appends to the servers it spawns', () => {
9
+ expect(resolveLaunchMode(['bun', ENTRY, '--ompchamber-server', launchModeArg('daemon')])).toBe('daemon');
10
+ expect(resolveLaunchMode(['bun', ENTRY, '--ompchamber-server', launchModeArg('foreground')])).toBe('foreground');
11
+ });
12
+
13
+ test('treats a server started without the flag as direct', () => {
14
+ // `bun run dev`, `bun run start`, a manual run, an external supervisor.
15
+ expect(resolveLaunchMode(['bun', 'run', '--hot', ENTRY, '--ompchamber-server'])).toBe('direct');
16
+ expect(resolveLaunchMode(['bun', ENTRY, '--ompchamber-server', '--launch-mode=sideways'])).toBe('direct');
17
+ });
18
+
19
+ test('ignores an inherited environment marker', () => {
20
+ // The chamber's terminal panel runs commands with the server's own env, so
21
+ // a `bun run dev` started there inherits `daemon` — argv is the only
22
+ // trustworthy signal, and this test fails if env is ever read again.
23
+ const previous = Bun.env.OMPCHAMBER_LAUNCH_MODE;
24
+ Bun.env.OMPCHAMBER_LAUNCH_MODE = 'daemon';
25
+ try {
26
+ expect(resolveLaunchMode(['bun', ENTRY, '--ompchamber-server'])).toBe('direct');
27
+ } finally {
28
+ if (previous === undefined) delete Bun.env.OMPCHAMBER_LAUNCH_MODE;
29
+ else Bun.env.OMPCHAMBER_LAUNCH_MODE = previous;
30
+ }
31
+ });
32
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * How this server was launched — the fact that decides whether an update may
8
+ * replace it.
9
+ *
10
+ * The marker travels as an argv flag, never as an environment variable. Env is
11
+ * inherited by every descendant, so a server started from a shell *inside* an
12
+ * OMPChamber tree — the chamber's own terminal panel runs commands with the
13
+ * server's environment — would inherit `daemon` and then be swapped out by the
14
+ * next update, killing a `bun run dev` loop OMPChamber does not own. argv
15
+ * belongs to the process alone.
16
+ */
17
+
18
+ import type { InstanceLaunchMode } from '@/server/lib/lifecycle/instance';
19
+
20
+ /** The argv flag the CLI appends to every server it spawns. */
21
+ const LAUNCH_MODE_FLAG = '--launch-mode=';
22
+
23
+ /**
24
+ * The argv entry for `mode`. Paired with `resolveLaunchMode`, so the flag
25
+ * spelling exists in exactly one place for the writer and the reader.
26
+ */
27
+ export function launchModeArg(mode: InstanceLaunchMode): string {
28
+ return `${LAUNCH_MODE_FLAG}${mode}`;
29
+ }
30
+
31
+ /**
32
+ * Launch mode of a server invocation. Only the CLI passes the flag, so a
33
+ * `bun run dev`, a `bun run start`, a manual run and an externally supervised
34
+ * process all resolve to `direct` — the mode that is left alone.
35
+ */
36
+ export function resolveLaunchMode(argv: readonly string[] = Bun.argv): InstanceLaunchMode {
37
+ const flag = argv.find((arg) => arg.startsWith(LAUNCH_MODE_FLAG));
38
+ const value = flag?.slice(LAUNCH_MODE_FLAG.length);
39
+ return value === 'daemon' || value === 'foreground' ? value : 'direct';
40
+ }
@@ -0,0 +1,21 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { isAutoRestartable } from '@/server/lib/lifecycle/restart';
4
+
5
+ // The modal update and `ompchamber update` both decide with this predicate:
6
+ // guessing wrong here kills a `bun run dev` the user owns, or leaves a daemon
7
+ // serving the build that was just replaced.
8
+ describe('isAutoRestartable', () => {
9
+ test('restarts the instances the CLI started', () => {
10
+ expect(isAutoRestartable('daemon')).toBe(true);
11
+ expect(isAutoRestartable('foreground')).toBe(true);
12
+ });
13
+
14
+ test('leaves every instance OMPChamber did not start alone', () => {
15
+ // `bun run dev`, `bun run start`, a manual run, a systemd/pm2 unit.
16
+ expect(isAutoRestartable('direct')).toBe(false);
17
+ // A probed or unreadable record never authorises a kill either.
18
+ expect(isAutoRestartable('unknown')).toBe(false);
19
+ expect(isAutoRestartable(undefined)).toBe(false);
20
+ });
21
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Replacing the instance that is serving an update request.
8
+ *
9
+ * A server cannot restart itself: the process holding the port has to exit
10
+ * before a replacement can bind it, and the update response has to leave first.
11
+ * So once the files on disk are replaced, the work goes to a detached
12
+ * `ompchamber restart --port <port>` — the same stop-then-serve the CLI runs
13
+ * after `ompchamber update`.
14
+ *
15
+ * Instances the CLI did not start are left alone. `bun run dev` and
16
+ * `bun run start` execute this package's source in the user's own shell, and an
17
+ * external supervisor (systemd, pm2, a container) owns the process it started;
18
+ * all of them record `direct`, and replacing any of them would break a loop
19
+ * OMPChamber does not own — or fight a second daemon for the port. Their own
20
+ * launcher is what brings the new files up.
21
+ */
22
+
23
+ import { existsSync } from 'node:fs';
24
+ import { join as joinPath, resolve as resolvePath } from 'node:path';
25
+
26
+ import { resolveBunBin } from '@/server/lib/lifecycle/bun';
27
+ import { listInstanceRecords } from '@/server/lib/lifecycle/instance';
28
+ import { resolveLaunchMode } from '@/server/lib/lifecycle/launch-mode';
29
+
30
+ /** Package root of the running copy: src/server/lib/lifecycle -> four levels up. */
31
+ const PKG_ROOT = resolvePath(import.meta.dir, '..', '..', '..', '..');
32
+
33
+ /**
34
+ * Time between answering the update request and stopping the server. The
35
+ * response is a small JSON body on a local listener; this is the margin that
36
+ * keeps the console's toast from dying with the process that sent it.
37
+ */
38
+ export const RESTART_GRACE_MS = 1_200;
39
+
40
+ export interface SelfRestartPlan {
41
+ /** A detached restart is armed; `message` says so. */
42
+ restarting: boolean;
43
+ /** What to tell the user, either way. */
44
+ message: string;
45
+ }
46
+
47
+ /**
48
+ * True when the CLI owns this instance's lifecycle and may replace it. `direct`
49
+ * is every server OMPChamber did not start: `bun run dev`, `bun run start`, a
50
+ * manual `bun src/server/index.ts`, or an external supervisor.
51
+ */
52
+ export function isAutoRestartable(launchMode: string | null | undefined): boolean {
53
+ return launchMode === 'daemon' || launchMode === 'foreground';
54
+ }
55
+
56
+ /** One-line reason an instance is left running, shared by the CLI and console. */
57
+ export function skipRestartNote(launchMode: string | null | undefined): string {
58
+ if (launchMode === 'direct') {
59
+ return 'this instance runs from source (`bun run dev` / `bun run start`) — start it again to apply the update';
60
+ }
61
+ return 'this instance is not managed by the ompchamber CLI — restart it yourself to apply the update';
62
+ }
63
+
64
+ /** The record this process wrote for itself; falls back to the env it was launched with. */
65
+ function ownInstance(): { port: number; launchMode: string } | null {
66
+ const record = listInstanceRecords().find((entry) => entry.pid === process.pid);
67
+ if (record) return { port: record.port, launchMode: record.launchMode };
68
+
69
+ const port = Number(Bun.env.PORT);
70
+ if (!Number.isFinite(port) || port <= 0) return null;
71
+ return { port, launchMode: resolveLaunchMode() };
72
+ }
73
+
74
+ /** `ompchamber restart --port <port>` for the install this server runs from. */
75
+ function restartCommand(port: number): string[] | null {
76
+ const cli = joinPath(PKG_ROOT, 'src', 'cli', 'ompchamber.js');
77
+ if (!existsSync(cli)) return null;
78
+ return [resolveBunBin(), cli, 'restart', '--port', String(port)];
79
+ }
80
+
81
+ /**
82
+ * Stop and start this port again, once the response has been delivered. The
83
+ * helper is detached so this process exiting does not take it down, and it
84
+ * removes this port's record itself (`stopInstance`) before serving.
85
+ */
86
+ export function scheduleSelfRestart(): SelfRestartPlan {
87
+ const self = ownInstance();
88
+ if (!self) {
89
+ return { restarting: false, message: 'This instance could not be identified — restart it yourself to apply the update.' };
90
+ }
91
+
92
+ if (!isAutoRestartable(self.launchMode)) {
93
+ return { restarting: false, message: skipRestartNote(self.launchMode) };
94
+ }
95
+
96
+ const cmd = restartCommand(self.port);
97
+ if (!cmd) {
98
+ return { restarting: false, message: `The ompchamber CLI is missing from ${PKG_ROOT} — restart it yourself to apply the update.` };
99
+ }
100
+
101
+ setTimeout(() => {
102
+ console.log(`[ompchamber] restarting on port ${self.port}: ${cmd.join(' ')}`);
103
+ try {
104
+ Bun.spawn({ cmd, cwd: PKG_ROOT, stdin: 'ignore', stdout: 'inherit', stderr: 'inherit', detached: true }).unref();
105
+ } catch (err) {
106
+ console.error(`[ompchamber] restart helper could not be started: ${err instanceof Error ? err.message : String(err)}`);
107
+ }
108
+ }, RESTART_GRACE_MS);
109
+
110
+ return { restarting: true, message: `Restarting OMPChamber on port ${self.port} to apply it — the console reconnects on its own.` };
111
+ }
@@ -102,10 +102,21 @@ export class AgentSessionWrapper {
102
102
  }
103
103
 
104
104
  /** Anything in flight that a process reset would destroy: the current turn,
105
- * a compaction, a shell command, or live subagents — which outlive the turn
106
- * that spawned them, so no other flag here can see them. */
105
+ * a compaction, a shell command, live subagents — which outlive the turn that
106
+ * spawned them, so no other flag here can see them — or an ask/approval dialog
107
+ * omp is BLOCKED on.
108
+ *
109
+ * The dialogs are the load-bearing entry: an ask parks the tool call for as
110
+ * long as the user takes to answer, and omp answers RPC handlers one at a
111
+ * time, so every probe (`get_state`) queues behind it and times out. Without
112
+ * this the timeout path read the session as "idle and unresponsive", killed
113
+ * the child, and with it the question the user was still reading. */
107
114
  isBusy(): boolean {
108
- return this.isRunning() || this.subagents.liveCount(Date.now(), SUBAGENT_STALE_MS) > 0;
115
+ return (
116
+ this.isRunning() ||
117
+ this.pendingUiDialogs.list().length > 0 ||
118
+ this.subagents.liveCount(Date.now(), SUBAGENT_STALE_MS) > 0
119
+ );
109
120
  }
110
121
 
111
122
  start(): void {
@@ -13,7 +13,7 @@ import { RpcCommandTimeoutError, type RpcProcess } from '@/server/lib/omp/rpc/pr
13
13
  import { AWAITING_AGENT_START_TIMEOUT_MS, GET_STATE_TIMEOUT_MS, IMAGE_BEARING_COMMANDS, PASSTHROUGH_COMMANDS, PROMPT_ACK_TIMEOUT_MS, RESTARTING_MESSAGE, SESSION_BUSY_MESSAGE, WebRpcError, toImageContents, type AgentEvent, type RpcSessionState, validateAgentImages } from '@/server/lib/omp/rpc/constants';
14
14
  import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
15
15
  import { notifyRunningChange } from '@/server/lib/omp/rpc/session-registry';
16
- import { markStreamStatus } from '@/shared/lib/omp/session/stream-state.server';
16
+ import { clearStreamStatus, markStreamStatus } from '@/shared/lib/omp/session/stream-state.server';
17
17
  import { buildWebState, type WebStateHost } from '@/server/lib/omp/rpc/web-state';
18
18
 
19
19
  /** Runtime surface AgentSessionWrapper exposes to the command dispatcher. */
@@ -66,12 +66,24 @@ export async function dispatchSessionCommand(host: SessionCommandHost, command:
66
66
  throw new Error('Cannot send a prompt while a shell command is running');
67
67
  }
68
68
  const streamingBehavior = command.streamingBehavior as 'steer' | 'followUp' | undefined;
69
+ // The live `stream` row starts with the DISPATCH, not with agent_start:
70
+ // the spawn/ack round trip that precedes agent_start must not read as
71
+ // "nothing happened" in the sidebar after the user hit send. Skipped
72
+ // while a turn already streams — that row belongs to the running turn,
73
+ // and this dispatch's failure must never roll it back.
74
+ const ownsStreamRow = !streamingBehavior && !host.streaming && Boolean(host.sessionId);
75
+ // A prompt that never started a turn leaves no run behind, so the row we
76
+ // wrote must go — unless a turn began meanwhile, which owns it now.
77
+ const releaseStreamRow = (): void => {
78
+ if (ownsStreamRow && !host.streaming) void clearStreamStatus(host.sessionId);
79
+ };
69
80
  if (!streamingBehavior) {
70
81
  host.promptRunning = true;
71
82
  host.promptDispatchPendingCount += 1;
72
83
  host.awaitingAgentStart = false;
73
84
  host.awaitingAgentStartDeadline = 0;
74
85
  host.continuationGraceUntil = 0;
86
+ if (ownsStreamRow) void markStreamStatus(host.sessionId, 'stream');
75
87
  notifyRunningChange();
76
88
  }
77
89
  try {
@@ -85,6 +97,7 @@ export async function dispatchSessionCommand(host: SessionCommandHost, command:
85
97
  host.promptRunning = false;
86
98
  host.awaitingAgentStart = false;
87
99
  host.awaitingAgentStartDeadline = 0;
100
+ releaseStreamRow();
88
101
  host.emit({ type: 'prompt_result', agentInvoked: false });
89
102
  notifyRunningChange();
90
103
  } else if (!streamingBehavior && ack?.agentInvoked !== false) {
@@ -95,6 +108,7 @@ export async function dispatchSessionCommand(host: SessionCommandHost, command:
95
108
  host.promptRunning = false;
96
109
  host.awaitingAgentStart = false;
97
110
  host.awaitingAgentStartDeadline = 0;
111
+ releaseStreamRow();
98
112
  notifyRunningChange();
99
113
  // The ack may simply be queued behind a running turn: omp accepts the
100
114
  // prompt before the turn it starts. No reset, and the client must not
@@ -72,6 +72,24 @@ export function getRunningRpcSessionIds(): string[] {
72
72
  return getRunningRpcSessions().map((s) => s.id);
73
73
  }
74
74
 
75
+ /**
76
+ * Sessions whose omp process is parked on a dialog only the user can release —
77
+ * an `ask` question or an approval gate. Deliberately independent of
78
+ * `isRunning()`: a blocked dialog leaves every turn flag exactly as it was, so
79
+ * the pending-dialog registry is the only thing that can tell a session waiting
80
+ * for an answer from an idle one. Feeds the sidebar's "needs input" badge, which
81
+ * has to reach clients that never opened the session.
82
+ */
83
+ export function getAwaitingInputSessionIds(): string[] {
84
+ const ids = new Set<string>();
85
+ for (const [sessionId, session] of getRegistry()) {
86
+ if (session.isAlive() && session.getPendingUiDialogs().length > 0) {
87
+ ids.add(session.sessionId || sessionId);
88
+ }
89
+ }
90
+ return [...ids];
91
+ }
92
+
75
93
  function getRunningListeners(): Set<(update: RunningSessionUpdate) => void> {
76
94
  if (!globalThis.__ompRunningListeners) globalThis.__ompRunningListeners = new Set();
77
95
  return globalThis.__ompRunningListeners;
@@ -22,7 +22,7 @@ import { isValidSessionSortOption, sortFolders } from '@/shared/lib/workspace/si
22
22
  import { loadOmpSidebarData } from '@/server/lib/omp/session/reader';
23
23
  import { sessionHasSubagents } from '@/server/lib/omp/session/subagent-presence';
24
24
  import { healStaleStreamStatuses, loadStreamStatuses } from '@/shared/lib/omp/session/stream-state.server';
25
- import { getRunningRpcSessionIds } from '@/server/lib/omp/rpc/session-registry';
25
+ import { getAwaitingInputSessionIds, getRunningRpcSessionIds } from '@/server/lib/omp/rpc/session-registry';
26
26
  import type { SessionItemData, SessionSortOption, WorkspaceFolderData } from '@/shared/types';
27
27
  import type { OmpSession } from '@/shared/types/omp/session';
28
28
 
@@ -115,15 +115,21 @@ export async function loadSidebarData(): Promise<SessionListPayload> {
115
115
  // `finish` right here — the authoritative status travels with the same
116
116
  // fetch that refreshes the sidebar list.
117
117
  const streamStatuses: Record<string, 'stream' | 'finish' | 'abort' | 'error'> = {};
118
+ // Sessions blocked on a dialog nobody has answered yet. Read live from the
119
+ // process registry (never persisted): the child that owns the question is the
120
+ // same thing that owns the flag, so a restart cannot leave a stale badge.
121
+ const awaitingInput = new Set<string>();
118
122
  if (!mock) {
119
123
  await healStaleStreamStatuses(new Set(getRunningRpcSessionIds()));
120
124
  Object.assign(streamStatuses, await loadStreamStatuses());
125
+ for (const id of getAwaitingInputSessionIds()) awaitingInput.add(id);
121
126
  }
122
127
  const foldersWithStatus = groupedFolders.map((folder) => ({
123
128
  ...folder,
124
129
  sessions: (folder.sessions ?? []).map((s) => ({
125
130
  ...s,
126
131
  streamStatus: streamStatuses[String(s.id)],
132
+ ...(awaitingInput.has(String(s.id)) ? { awaitingInput: true } : {}),
127
133
  })),
128
134
  }));
129
135
 
@@ -1,16 +1,16 @@
1
1
  /**
2
2
  * Applies a requested update. oh-my-pi is updated through the omp binary;
3
3
  * OMPChamber through the shared self-update engine, which replaces the running
4
- * install in place (bun global install, or git fast-forward + rebuild).
4
+ * install in place (bun global install, or git fast-forward + rebuild) and then
5
+ * restarts this instance through a detached `ompchamber restart` — unless the
6
+ * instance was started from source, which its own launcher has to restart.
5
7
  */
6
8
 
9
+ import { scheduleSelfRestart } from '@/server/lib/lifecycle/restart';
7
10
  import { updateOmpChamber } from '@/server/lib/updates/install';
8
11
  import { applyOmpUpdate } from '@/server/lib/updates/omp';
9
12
  import type { UpdateApplyResult, UpdateTarget } from '@/shared/types/updates';
10
13
 
11
- /** A server cannot replace its own process, so the user restarts it. */
12
- const RESTART_NOTE = ' Restart the server (`ompchamber restart`) to apply it.';
13
-
14
14
  export async function applyUpdate(target: UpdateTarget): Promise<UpdateApplyResult> {
15
15
  if (target === 'omp') {
16
16
  try {
@@ -28,11 +28,14 @@ export async function applyUpdate(target: UpdateTarget): Promise<UpdateApplyResu
28
28
 
29
29
  if (target === 'ompchamber') {
30
30
  const result = await updateOmpChamber();
31
+ // Only a call that actually replaced files has something to restart for:
32
+ // an "already up to date" answer must not bounce a healthy server.
33
+ const restart = result.success && result.updated ? scheduleSelfRestart() : null;
31
34
  return {
32
35
  success: result.success,
33
36
  target: 'ompchamber',
34
37
  manual: result.manual,
35
- message: `${result.message}${result.updated ? RESTART_NOTE : ''}`,
38
+ message: restart ? `${result.message} ${restart.message}` : result.message,
36
39
  output: result.output || undefined,
37
40
  };
38
41
  }
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { join as joinPath, resolve as resolvePath } from 'node:path';
16
16
 
17
+ import { resolveBunBin } from '@/server/lib/lifecycle/bun';
17
18
  import { fetchLatestRelease, type GitHubRelease } from '@/server/lib/updates/github';
18
19
  import {
19
20
  detectInstallMethod,
@@ -137,10 +138,12 @@ export function resolveInstallContext(pkgRoot = DEFAULT_PKG_ROOT): InstallContex
137
138
  const remote = gitWorkTree ? runSync(['git', '-C', root, 'remote', 'get-url', 'origin']) : null;
138
139
  const gitRemote = remote?.ok && remote.out.length > 0 ? remote.out : null;
139
140
 
140
- const execPath = process.execPath;
141
- const bunBin = /(^|[\\/])bun$/.test(execPath) ? execPath : (Bun.which('bun') ?? 'bun');
142
-
143
- return { ...detectInstallMethod({ pkgRoot: root, gitWorkTree, gitRemote }), pkgRoot: root, gitRemote, bunBin };
141
+ return {
142
+ ...detectInstallMethod({ pkgRoot: root, gitWorkTree, gitRemote }),
143
+ pkgRoot: root,
144
+ gitRemote,
145
+ bunBin: resolveBunBin(),
146
+ };
144
147
  }
145
148
 
146
149
  /** The commands that install `version` for a given install method. */
@@ -1,5 +1,6 @@
1
1
  import { json, NO_STORE_HEADERS } from '@/server/lib/remix-compat';
2
2
  import pkg from '@/../package.json';
3
+ import { resolveLaunchMode } from '@/server/lib/lifecycle/launch-mode';
3
4
  import { isMockMode } from '@/server/mock.server';
4
5
 
5
6
  const STARTED_AT = Date.now();
@@ -26,7 +27,7 @@ export async function loader() {
26
27
  // `launchMode` are what `status` prints, and the pair with `pid` is what
27
28
  // separates a dev server from a stale published build.
28
29
  mode: Bun.env.NODE_ENV === 'production' ? 'prod' : 'dev',
29
- launchMode: Bun.env.OMPCHAMBER_LAUNCH_MODE ?? 'direct',
30
+ launchMode: resolveLaunchMode(),
30
31
  port: Number(Bun.env.PORT) || 3000,
31
32
  host: Bun.env.HOST || 'localhost',
32
33
  },