@jmoyers/harness 0.1.10 → 0.1.20

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 (239) hide show
  1. package/README.md +31 -35
  2. package/package.json +31 -11
  3. package/packages/harness-ai/src/anthropic-protocol.ts +68 -68
  4. package/packages/harness-ai/src/stream-text.ts +13 -91
  5. package/packages/harness-ui/src/frame-primitives.ts +158 -0
  6. package/packages/harness-ui/src/index.ts +18 -0
  7. package/packages/harness-ui/src/interaction/conversation-input-forwarder.ts +221 -0
  8. package/packages/harness-ui/src/interaction/conversation-selection-input.ts +213 -0
  9. package/packages/harness-ui/src/interaction/global-shortcut-input.ts +172 -0
  10. package/{src/ui → packages/harness-ui/src/interaction}/input-preflight.ts +10 -12
  11. package/{src/ui → packages/harness-ui/src/interaction}/input-token-router.ts +120 -69
  12. package/packages/harness-ui/src/interaction/input.ts +420 -0
  13. package/packages/harness-ui/src/interaction/left-nav-input.ts +166 -0
  14. package/{src/ui → packages/harness-ui/src/interaction}/main-pane-pointer-input.ts +91 -23
  15. package/{src/ui → packages/harness-ui/src/interaction}/pointer-routing-input.ts +112 -48
  16. package/packages/harness-ui/src/interaction/rail-pointer-input.ts +62 -0
  17. package/packages/harness-ui/src/interaction/repository-fold-input.ts +118 -0
  18. package/packages/harness-ui/src/kit.ts +476 -0
  19. package/packages/harness-ui/src/layout.ts +238 -0
  20. package/{src/ui/modals/manager.ts → packages/harness-ui/src/modal-manager.ts} +94 -64
  21. package/{src/ui → packages/harness-ui/src}/screen.ts +53 -26
  22. package/packages/harness-ui/src/surface.ts +252 -0
  23. package/packages/harness-ui/src/text-layout.ts +210 -0
  24. package/packages/nim-core/src/contracts.ts +239 -0
  25. package/packages/nim-core/src/event-store.ts +299 -0
  26. package/packages/nim-core/src/events.ts +53 -0
  27. package/packages/nim-core/src/index.ts +9 -0
  28. package/packages/nim-core/src/provider-router.ts +129 -0
  29. package/packages/nim-core/src/providers/anthropic-driver.ts +291 -0
  30. package/packages/nim-core/src/runtime-factory.ts +49 -0
  31. package/packages/nim-core/src/runtime.ts +1797 -0
  32. package/packages/nim-core/src/session-store.ts +516 -0
  33. package/packages/nim-core/src/telemetry.ts +48 -0
  34. package/packages/nim-test-tui/src/index.ts +150 -0
  35. package/packages/nim-ui-core/src/index.ts +1 -0
  36. package/packages/nim-ui-core/src/projection.ts +87 -0
  37. package/scripts/codex-live-mux-runtime.ts +2 -3721
  38. package/scripts/control-plane-daemon.ts +24 -2
  39. package/scripts/harness-bin.js +5 -0
  40. package/scripts/harness-commands.ts +300 -0
  41. package/scripts/harness-runtime.ts +82 -0
  42. package/scripts/harness.ts +33 -3007
  43. package/scripts/nim-tui-smoke.ts +748 -0
  44. package/src/cli/auth/runtime.ts +948 -0
  45. package/src/cli/default-gateway-pointer.ts +193 -0
  46. package/src/cli/gateway/runtime.ts +1872 -0
  47. package/src/cli/parsing/flags.ts +23 -0
  48. package/src/cli/parsing/session.ts +42 -0
  49. package/src/cli/runtime/context.ts +193 -0
  50. package/src/cli/runtime-app/application.ts +392 -0
  51. package/src/cli/runtime-infra/gateway-control.ts +729 -0
  52. package/{scripts/harness-inspector.ts → src/cli/workflows/inspector.ts} +14 -11
  53. package/src/cli/workflows/runtime.ts +965 -0
  54. package/src/clients/tui/left-rail-interactions.ts +519 -0
  55. package/src/clients/tui/main-pane-interactions.ts +509 -0
  56. package/src/clients/tui/modal-input-routing.ts +71 -0
  57. package/src/clients/tui/render-snapshot-adapter.ts +88 -0
  58. package/src/clients/web/synced-selectors.ts +132 -0
  59. package/src/codex/live-session.ts +82 -29
  60. package/src/config/config-core.ts +361 -10
  61. package/src/config/harness-paths.ts +4 -7
  62. package/src/config/harness-runtime-migration.ts +142 -19
  63. package/src/config/harness.config.template.jsonc +33 -0
  64. package/src/config/secrets-core.ts +92 -4
  65. package/src/control-plane/agent-realtime-api.ts +82 -427
  66. package/src/control-plane/prompt/thread-title-namer.ts +49 -23
  67. package/src/control-plane/session-summary.ts +10 -81
  68. package/src/control-plane/status/reducer-base.ts +12 -12
  69. package/src/control-plane/status/reducers/claude-status-reducer.ts +3 -3
  70. package/src/control-plane/status/reducers/codex-status-reducer.ts +4 -4
  71. package/src/control-plane/status/reducers/cursor-status-reducer.ts +3 -3
  72. package/src/control-plane/stream-client.ts +12 -2
  73. package/src/control-plane/stream-command-parser.ts +83 -143
  74. package/src/control-plane/stream-protocol.ts +53 -37
  75. package/src/control-plane/stream-server-background.ts +18 -2
  76. package/src/control-plane/stream-server-command.ts +376 -69
  77. package/src/control-plane/stream-server-session-runtime.ts +3 -2
  78. package/src/control-plane/stream-server.ts +943 -80
  79. package/src/control-plane/stream-session-runtime-types.ts +41 -0
  80. package/src/{mux/live-mux/control-plane-records.ts → core/contracts/records.ts} +24 -97
  81. package/src/core/state/observed-stream-cursor.ts +43 -0
  82. package/src/core/state/synced-observed-state.ts +273 -0
  83. package/src/core/store/harness-synced-store.ts +81 -0
  84. package/src/diff/budget.ts +136 -0
  85. package/src/diff/build.ts +289 -0
  86. package/src/diff/chunker.ts +146 -0
  87. package/src/diff/git-invoke.ts +315 -0
  88. package/src/diff/git-parse.ts +472 -0
  89. package/src/diff/hash.ts +70 -0
  90. package/src/diff/index.ts +24 -0
  91. package/src/diff/normalize.ts +134 -0
  92. package/src/diff/types.ts +178 -0
  93. package/src/diff-ui/args.ts +346 -0
  94. package/src/diff-ui/commands.ts +123 -0
  95. package/src/diff-ui/finder.ts +94 -0
  96. package/src/diff-ui/highlight.ts +127 -0
  97. package/src/diff-ui/index.ts +2 -0
  98. package/src/diff-ui/model.ts +141 -0
  99. package/src/diff-ui/pager.ts +412 -0
  100. package/src/diff-ui/render.ts +337 -0
  101. package/src/diff-ui/runtime.ts +379 -0
  102. package/src/diff-ui/state.ts +224 -0
  103. package/src/diff-ui/types.ts +236 -0
  104. package/src/domain/conversations.ts +11 -7
  105. package/src/domain/workspace.ts +76 -4
  106. package/src/mux/control-plane-op-queue.ts +93 -7
  107. package/src/mux/conversation-rail.ts +28 -71
  108. package/src/mux/dual-pane-core.ts +13 -13
  109. package/src/mux/harness-core-ui.ts +313 -42
  110. package/src/mux/input-shortcuts.ts +22 -112
  111. package/src/mux/keybinding-catalog.ts +340 -0
  112. package/src/mux/keybinding-registry.ts +103 -0
  113. package/src/mux/live-mux/command-menu-open-in.ts +280 -0
  114. package/src/mux/live-mux/command-menu.ts +167 -4
  115. package/src/mux/live-mux/conversation-state.ts +13 -0
  116. package/src/mux/live-mux/directory-resolution.ts +1 -1
  117. package/src/mux/live-mux/git-parsing.ts +16 -0
  118. package/src/mux/live-mux/git-snapshot.ts +33 -2
  119. package/src/mux/live-mux/global-shortcut-handlers.ts +6 -0
  120. package/src/mux/live-mux/home-pane-drop.ts +1 -1
  121. package/src/mux/live-mux/home-pane-pointer.ts +10 -0
  122. package/src/mux/live-mux/input-forwarding.ts +59 -2
  123. package/src/mux/live-mux/left-nav-activation.ts +124 -7
  124. package/src/mux/live-mux/left-nav.ts +35 -0
  125. package/src/mux/live-mux/link-click.ts +292 -0
  126. package/src/mux/live-mux/modal-command-menu-handler.ts +46 -9
  127. package/src/mux/live-mux/modal-conversation-handlers.ts +5 -1
  128. package/src/mux/live-mux/modal-input-reducers.ts +106 -8
  129. package/src/mux/live-mux/modal-overlays.ts +210 -31
  130. package/src/mux/live-mux/modal-pointer.ts +3 -7
  131. package/src/mux/live-mux/modal-prompt-handlers.ts +107 -1
  132. package/src/mux/live-mux/modal-release-notes-handler.ts +111 -0
  133. package/src/mux/live-mux/modal-task-editor-handler.ts +16 -11
  134. package/src/mux/live-mux/pointer-routing.ts +5 -2
  135. package/src/mux/live-mux/project-pane-pointer.ts +8 -0
  136. package/src/mux/live-mux/rail-layout.ts +33 -30
  137. package/src/mux/live-mux/release-notes.ts +383 -0
  138. package/src/mux/live-mux/render-trace-analysis.ts +52 -7
  139. package/src/mux/live-mux/repository-folding.ts +3 -0
  140. package/src/mux/live-mux/selection.ts +0 -4
  141. package/src/mux/live-mux/session-diagnostics-paths.ts +21 -0
  142. package/src/mux/project-pane-github-review.ts +271 -0
  143. package/src/mux/render-frame.ts +4 -0
  144. package/src/mux/runtime-app/codex-live-mux-runtime.ts +5191 -0
  145. package/src/mux/task-composer.ts +21 -14
  146. package/src/mux/task-focused-pane.ts +118 -117
  147. package/src/mux/task-screen-keybindings.ts +19 -82
  148. package/src/mux/workspace-rail-model.ts +270 -104
  149. package/src/mux/workspace-rail.ts +45 -22
  150. package/src/pty/session-broker.ts +1 -1
  151. package/{scripts → src/recording}/terminal-recording-gif-lib.ts +2 -2
  152. package/src/services/control-plane.ts +50 -32
  153. package/src/services/conversation-lifecycle.ts +118 -87
  154. package/src/services/conversation-startup-hydration.ts +20 -12
  155. package/src/services/directory-hydration.ts +21 -16
  156. package/src/services/event-persistence.ts +7 -0
  157. package/src/services/left-rail-pointer-handler.ts +329 -0
  158. package/src/services/mux-ui-state-persistence.ts +5 -1
  159. package/src/services/recording.ts +34 -26
  160. package/src/services/runtime-command-menu-agent-tools.ts +1 -1
  161. package/src/services/runtime-control-actions.ts +79 -61
  162. package/src/services/runtime-control-plane-ops.ts +122 -83
  163. package/src/services/runtime-conversation-actions.ts +40 -26
  164. package/src/services/runtime-conversation-activation.ts +82 -30
  165. package/src/services/runtime-conversation-starter.ts +80 -48
  166. package/src/services/runtime-conversation-title-edit.ts +91 -80
  167. package/src/services/runtime-envelope-handler.ts +107 -105
  168. package/src/services/runtime-git-state.ts +42 -29
  169. package/src/services/runtime-layout-resize.ts +3 -1
  170. package/src/services/runtime-left-rail-render.ts +99 -63
  171. package/src/services/runtime-nim-cli-session.ts +438 -0
  172. package/src/services/runtime-nim-session.ts +705 -0
  173. package/src/services/runtime-nim-tool-bridge.ts +141 -0
  174. package/src/services/runtime-observed-event-projection-pipeline.ts +45 -0
  175. package/src/services/runtime-process-wiring.ts +29 -36
  176. package/src/services/runtime-project-pane-github-review-cache.ts +164 -0
  177. package/src/services/runtime-render-flush.ts +63 -70
  178. package/src/services/runtime-render-lifecycle.ts +65 -64
  179. package/src/services/runtime-render-orchestrator.ts +55 -45
  180. package/src/services/runtime-render-pipeline.ts +106 -103
  181. package/src/services/runtime-render-state.ts +62 -49
  182. package/src/services/runtime-repository-actions.ts +97 -70
  183. package/src/services/runtime-right-pane-render.ts +80 -53
  184. package/src/services/runtime-shutdown.ts +38 -35
  185. package/src/services/runtime-stream-subscriptions.ts +35 -27
  186. package/src/services/runtime-task-composer-persistence.ts +71 -59
  187. package/src/services/runtime-task-composer-snapshot.ts +14 -0
  188. package/src/services/runtime-task-editor-actions.ts +46 -29
  189. package/src/services/runtime-task-pane-actions.ts +220 -134
  190. package/src/services/runtime-task-pane-shortcuts.ts +323 -123
  191. package/src/services/runtime-workspace-observed-effect-queue.ts +25 -0
  192. package/src/services/runtime-workspace-observed-events.ts +33 -184
  193. package/src/services/runtime-workspace-observed-transition-policy.ts +228 -0
  194. package/src/services/session-diagnostics-store.ts +217 -0
  195. package/src/services/startup-background-resume.ts +26 -21
  196. package/src/services/startup-orchestrator.ts +16 -13
  197. package/src/services/startup-paint-tracker.ts +29 -21
  198. package/src/services/startup-persisted-conversation-queue.ts +19 -13
  199. package/src/services/startup-settled-gate.ts +25 -15
  200. package/src/services/startup-shutdown.ts +18 -22
  201. package/src/services/startup-state-hydration.ts +44 -34
  202. package/src/services/startup-visibility.ts +12 -4
  203. package/src/services/task-pane-selection-actions.ts +89 -72
  204. package/src/services/task-planning-hydration.ts +24 -18
  205. package/src/services/task-planning-observed-events.ts +50 -52
  206. package/src/services/workspace-observed-events.ts +66 -63
  207. package/src/storage/storage-lifecycle-core.ts +438 -0
  208. package/src/store/control-plane-store-normalize.ts +33 -242
  209. package/src/store/control-plane-store-types.ts +1 -35
  210. package/src/store/control-plane-store.ts +396 -56
  211. package/src/store/event-store.ts +397 -3
  212. package/src/terminal/snapshot-oracle.ts +207 -94
  213. package/src/ui/mux-theme.ts +112 -8
  214. package/src/ui/panes/home-gridfire.ts +40 -31
  215. package/src/ui/panes/home.ts +10 -2
  216. package/src/ui/panes/nim.ts +315 -0
  217. package/src/mux/live-mux/actions-task.ts +0 -115
  218. package/src/mux/live-mux/left-rail-actions.ts +0 -118
  219. package/src/mux/live-mux/left-rail-conversation-click.ts +0 -82
  220. package/src/mux/live-mux/left-rail-pointer.ts +0 -74
  221. package/src/mux/live-mux/task-pane-shortcuts.ts +0 -206
  222. package/src/services/runtime-directory-actions.ts +0 -164
  223. package/src/services/runtime-input-pipeline.ts +0 -50
  224. package/src/services/runtime-input-router.ts +0 -189
  225. package/src/services/runtime-main-pane-input.ts +0 -230
  226. package/src/services/runtime-modal-input.ts +0 -119
  227. package/src/services/runtime-navigation-input.ts +0 -197
  228. package/src/services/runtime-rail-input.ts +0 -278
  229. package/src/services/runtime-task-pane.ts +0 -62
  230. package/src/services/runtime-workspace-actions.ts +0 -158
  231. package/src/ui/conversation-input-forwarder.ts +0 -114
  232. package/src/ui/conversation-selection-input.ts +0 -103
  233. package/src/ui/global-shortcut-input.ts +0 -89
  234. package/src/ui/input.ts +0 -238
  235. package/src/ui/kit.ts +0 -509
  236. package/src/ui/left-nav-input.ts +0 -80
  237. package/src/ui/left-rail-pointer-input.ts +0 -148
  238. package/src/ui/repository-fold-input.ts +0 -91
  239. package/src/ui/surface.ts +0 -224
@@ -10,7 +10,7 @@ interface RuntimeControlPlaneOpEvent {
10
10
  readonly waitMs: number;
11
11
  }
12
12
 
13
- interface RuntimeControlPlaneOpsOptions {
13
+ export interface RuntimeControlPlaneOpsOptions {
14
14
  readonly onFatal: (error: unknown) => void;
15
15
  readonly startPerfSpan: (
16
16
  name: string,
@@ -23,94 +23,43 @@ interface RuntimeControlPlaneOpsOptions {
23
23
  readonly schedule?: (callback: () => void) => void;
24
24
  }
25
25
 
26
- export class RuntimeControlPlaneOps {
27
- private readonly opSpans = new Map<
26
+ export interface RuntimeControlPlaneOps {
27
+ enqueueInteractive(task: () => Promise<void>, label?: string): void;
28
+ enqueueInteractiveLatest(
29
+ key: string,
30
+ task: (options: { readonly signal: AbortSignal }) => Promise<void>,
31
+ label?: string,
32
+ ): void;
33
+ enqueueBackgroundLatest(
34
+ key: string,
35
+ task: (options: { readonly signal: AbortSignal }) => Promise<void>,
36
+ label?: string,
37
+ ): void;
38
+ enqueueBackground(task: () => Promise<void>, label?: string): void;
39
+ waitForDrain(): Promise<void>;
40
+ metrics(): {
41
+ readonly interactiveQueued: number;
42
+ readonly backgroundQueued: number;
43
+ readonly running: boolean;
44
+ };
45
+ }
46
+
47
+ export function createRuntimeControlPlaneOps(
48
+ options: RuntimeControlPlaneOpsOptions,
49
+ ): RuntimeControlPlaneOps {
50
+ const opSpans = new Map<
28
51
  number,
29
52
  {
30
53
  end: (attrs?: PerfAttrs) => void;
31
54
  }
32
55
  >();
33
56
 
34
- private readonly queue: ControlPlaneOpQueue;
35
-
36
- constructor(private readonly options: RuntimeControlPlaneOpsOptions) {
37
- this.queue = new ControlPlaneOpQueue({
38
- ...(options.nowMs === undefined
39
- ? {}
40
- : {
41
- nowMs: options.nowMs,
42
- }),
43
- ...(options.schedule === undefined
44
- ? {}
45
- : {
46
- schedule: options.schedule,
47
- }),
48
- onFatal: (error: unknown) => {
49
- this.options.onFatal(error);
50
- },
51
- onEnqueued: (event, metrics) => {
52
- this.options.recordPerfEvent('mux.control-plane.op.enqueued', {
53
- id: event.id,
54
- label: event.label,
55
- priority: event.priority,
56
- interactiveQueued: metrics.interactiveQueued,
57
- backgroundQueued: metrics.backgroundQueued,
58
- });
59
- },
60
- onStart: (event, metrics) => {
61
- const opSpan = this.options.startPerfSpan('mux.control-plane.op', {
62
- id: event.id,
63
- label: event.label,
64
- priority: event.priority,
65
- waitMs: event.waitMs,
66
- });
67
- this.opSpans.set(event.id, opSpan);
68
- this.options.recordPerfEvent('mux.control-plane.op.start', {
69
- id: event.id,
70
- label: event.label,
71
- priority: event.priority,
72
- waitMs: event.waitMs,
73
- interactiveQueued: metrics.interactiveQueued,
74
- backgroundQueued: metrics.backgroundQueued,
75
- });
76
- },
77
- onSuccess: (event) => {
78
- this.endSpan(event, 'ok');
79
- },
80
- onError: (event, _metrics, error) => {
81
- const message = error instanceof Error ? error.message : String(error);
82
- this.endSpan(event, 'error', message);
83
- this.options.writeStderr(`[mux] control-plane error ${message}\n`);
84
- },
85
- });
86
- }
87
-
88
- enqueueInteractive(task: () => Promise<void>, label = 'interactive-op'): void {
89
- this.queue.enqueueInteractive(task, label);
90
- }
91
-
92
- enqueueBackground(task: () => Promise<void>, label = 'background-op'): void {
93
- this.queue.enqueueBackground(task, label);
94
- }
95
-
96
- async waitForDrain(): Promise<void> {
97
- await this.queue.waitForDrain();
98
- }
99
-
100
- metrics(): {
101
- readonly interactiveQueued: number;
102
- readonly backgroundQueued: number;
103
- readonly running: boolean;
104
- } {
105
- return this.queue.metrics();
106
- }
107
-
108
- private endSpan(
57
+ const endSpan = (
109
58
  event: RuntimeControlPlaneOpEvent,
110
- status: 'ok' | 'error',
59
+ status: 'ok' | 'error' | 'canceled',
111
60
  message?: string,
112
- ): void {
113
- const opSpan = this.opSpans.get(event.id);
61
+ ): void => {
62
+ const opSpan = opSpans.get(event.id);
114
63
  if (opSpan === undefined) {
115
64
  return;
116
65
  }
@@ -126,6 +75,96 @@ export class RuntimeControlPlaneOps {
126
75
  message,
127
76
  }),
128
77
  });
129
- this.opSpans.delete(event.id);
130
- }
78
+ opSpans.delete(event.id);
79
+ };
80
+
81
+ const queue = new ControlPlaneOpQueue({
82
+ ...(options.nowMs === undefined
83
+ ? {}
84
+ : {
85
+ nowMs: options.nowMs,
86
+ }),
87
+ ...(options.schedule === undefined
88
+ ? {}
89
+ : {
90
+ schedule: options.schedule,
91
+ }),
92
+ onFatal: (error: unknown) => {
93
+ options.onFatal(error);
94
+ },
95
+ onEnqueued: (event, metrics) => {
96
+ options.recordPerfEvent('mux.control-plane.op.enqueued', {
97
+ id: event.id,
98
+ label: event.label,
99
+ priority: event.priority,
100
+ interactiveQueued: metrics.interactiveQueued,
101
+ backgroundQueued: metrics.backgroundQueued,
102
+ });
103
+ },
104
+ onStart: (event, metrics) => {
105
+ const opSpan = options.startPerfSpan('mux.control-plane.op', {
106
+ id: event.id,
107
+ label: event.label,
108
+ priority: event.priority,
109
+ waitMs: event.waitMs,
110
+ });
111
+ opSpans.set(event.id, opSpan);
112
+ options.recordPerfEvent('mux.control-plane.op.start', {
113
+ id: event.id,
114
+ label: event.label,
115
+ priority: event.priority,
116
+ waitMs: event.waitMs,
117
+ interactiveQueued: metrics.interactiveQueued,
118
+ backgroundQueued: metrics.backgroundQueued,
119
+ });
120
+ },
121
+ onSuccess: (event) => {
122
+ endSpan(event, 'ok');
123
+ },
124
+ onError: (event, _metrics, error) => {
125
+ const message = error instanceof Error ? error.message : String(error);
126
+ endSpan(event, 'error', message);
127
+ options.writeStderr(`[mux] control-plane error ${message}\n`);
128
+ },
129
+ onCanceled: (event) => {
130
+ endSpan(event, 'canceled');
131
+ },
132
+ });
133
+
134
+ return {
135
+ enqueueInteractive: (task: () => Promise<void>, label = 'interactive-op'): void => {
136
+ queue.enqueueInteractive(async () => {
137
+ await task();
138
+ }, label);
139
+ },
140
+ enqueueInteractiveLatest: (
141
+ key: string,
142
+ task: (options: { readonly signal: AbortSignal }) => Promise<void>,
143
+ label = 'interactive-op',
144
+ ): void => {
145
+ queue.enqueueInteractive(task, label, {
146
+ key,
147
+ supersede: 'pending-and-running',
148
+ });
149
+ },
150
+ enqueueBackgroundLatest: (
151
+ key: string,
152
+ task: (options: { readonly signal: AbortSignal }) => Promise<void>,
153
+ label = 'background-op',
154
+ ): void => {
155
+ queue.enqueueBackground(task, label, {
156
+ key,
157
+ supersede: 'pending-and-running',
158
+ });
159
+ },
160
+ enqueueBackground: (task: () => Promise<void>, label = 'background-op'): void => {
161
+ queue.enqueueBackground(async () => {
162
+ await task();
163
+ }, label);
164
+ },
165
+ waitForDrain: async (): Promise<void> => {
166
+ await queue.waitForDrain();
167
+ },
168
+ metrics: () => queue.metrics(),
169
+ };
131
170
  }
@@ -52,19 +52,25 @@ export interface RuntimeConversationActionsOptions<TControllerRecord> {
52
52
  readonly markDirty: () => void;
53
53
  }
54
54
 
55
- export class RuntimeConversationActions<TControllerRecord> {
56
- constructor(private readonly options: RuntimeConversationActionsOptions<TControllerRecord>) {}
55
+ export interface RuntimeConversationActions {
56
+ createAndActivateConversationInDirectory(directoryId: string, agentType: string): Promise<void>;
57
+ openOrCreateCritiqueConversationInDirectory(directoryId: string): Promise<void>;
58
+ takeoverConversation(sessionId: string): Promise<void>;
59
+ }
57
60
 
58
- async createAndActivateConversationInDirectory(
61
+ export function createRuntimeConversationActions<TControllerRecord>(
62
+ options: RuntimeConversationActionsOptions<TControllerRecord>,
63
+ ): RuntimeConversationActions {
64
+ const createAndActivateConversationInDirectory = async (
59
65
  directoryId: string,
60
66
  agentType: string,
61
- ): Promise<void> {
67
+ ): Promise<void> => {
62
68
  await createAndActivateConversationInDirectoryFn({
63
69
  directoryId,
64
70
  agentType,
65
- createConversationId: this.options.createConversationId,
71
+ createConversationId: options.createConversationId,
66
72
  createConversationRecord: async (sessionId, targetDirectoryId, targetAgentType) => {
67
- await this.options.controlPlaneService.createConversation({
73
+ await options.controlPlaneService.createConversation({
68
74
  conversationId: sessionId,
69
75
  directoryId: targetDirectoryId,
70
76
  title: '',
@@ -72,42 +78,50 @@ export class RuntimeConversationActions<TControllerRecord> {
72
78
  adapterState: {},
73
79
  });
74
80
  },
75
- ensureConversation: this.options.ensureConversation,
76
- noteGitActivity: this.options.noteGitActivity,
77
- startConversation: this.options.startConversation,
78
- activateConversation: this.options.activateConversation,
81
+ ensureConversation: options.ensureConversation,
82
+ noteGitActivity: options.noteGitActivity,
83
+ startConversation: options.startConversation,
84
+ activateConversation: options.activateConversation,
79
85
  });
80
- }
86
+ };
81
87
 
82
- async openOrCreateCritiqueConversationInDirectory(directoryId: string): Promise<void> {
88
+ const openOrCreateCritiqueConversationInDirectory = async (
89
+ directoryId: string,
90
+ ): Promise<void> => {
83
91
  await openOrCreateCritiqueConversationInDirectoryFn({
84
92
  directoryId,
85
- orderedConversationIds: this.options.orderedConversationIds,
86
- conversationById: this.options.conversationById,
87
- activateConversation: this.options.activateConversation,
93
+ orderedConversationIds: options.orderedConversationIds,
94
+ conversationById: options.conversationById,
95
+ activateConversation: options.activateConversation,
88
96
  createAndActivateCritiqueConversationInDirectory: async (targetDirectoryId) => {
89
- await this.createAndActivateConversationInDirectory(targetDirectoryId, 'critique');
97
+ await createAndActivateConversationInDirectory(targetDirectoryId, 'critique');
90
98
  },
91
99
  });
92
- }
100
+ };
93
101
 
94
- async takeoverConversation(sessionId: string): Promise<void> {
102
+ const takeoverConversation = async (sessionId: string): Promise<void> => {
95
103
  await takeoverConversationFn({
96
104
  sessionId,
97
- conversationsHas: this.options.conversationsHas,
105
+ conversationsHas: options.conversationsHas,
98
106
  claimSession: async (targetSessionId) => {
99
- return await this.options.controlPlaneService.claimSession({
107
+ return await options.controlPlaneService.claimSession({
100
108
  sessionId: targetSessionId,
101
- controllerId: this.options.muxControllerId,
109
+ controllerId: options.muxControllerId,
102
110
  controllerType: 'human',
103
- controllerLabel: this.options.muxControllerLabel,
111
+ controllerLabel: options.muxControllerLabel,
104
112
  reason: 'human takeover',
105
113
  takeover: true,
106
114
  });
107
115
  },
108
- applyController: this.options.applyController,
109
- setLastEventNow: this.options.setLastEventNow,
110
- markDirty: this.options.markDirty,
116
+ applyController: options.applyController,
117
+ setLastEventNow: options.setLastEventNow,
118
+ markDirty: options.markDirty,
111
119
  });
112
- }
120
+ };
121
+
122
+ return {
123
+ createAndActivateConversationInDirectory,
124
+ openOrCreateCritiqueConversationInDirectory,
125
+ takeoverConversation,
126
+ };
113
127
  }
@@ -24,55 +24,107 @@ export interface RuntimeConversationActivationOptions {
24
24
  readonly markDirty: () => void;
25
25
  }
26
26
 
27
- export class RuntimeConversationActivation {
28
- constructor(private readonly options: RuntimeConversationActivationOptions) {}
27
+ export interface RuntimeConversationActivation {
28
+ activateConversation(sessionId: string, input?: { readonly signal?: AbortSignal }): Promise<void>;
29
+ }
29
30
 
30
- async activateConversation(sessionId: string): Promise<void> {
31
- if (this.options.getActiveConversationId() === sessionId) {
32
- if (!this.options.isConversationPaneMode()) {
33
- this.options.enterConversationPaneForActiveSession(sessionId);
34
- this.options.markDirty();
31
+ export function createRuntimeConversationActivation(
32
+ options: RuntimeConversationActivationOptions,
33
+ ): RuntimeConversationActivation {
34
+ async function attachConversationWithRecovery(
35
+ sessionId: string,
36
+ signal: AbortSignal | undefined,
37
+ ): Promise<boolean> {
38
+ try {
39
+ await options.attachConversation(sessionId);
40
+ return !(signal?.aborted ?? false);
41
+ } catch (error: unknown) {
42
+ if (!options.isSessionNotFoundError(error) && !options.isSessionNotLiveError(error)) {
43
+ throw error;
44
+ }
45
+ options.markSessionUnavailable(sessionId);
46
+ await options.startConversation(sessionId);
47
+ if (signal?.aborted) {
48
+ return false;
49
+ }
50
+ await options.attachConversation(sessionId);
51
+ return !(signal?.aborted ?? false);
52
+ }
53
+ }
54
+
55
+ async function activateConversation(
56
+ sessionId: string,
57
+ input: { readonly signal?: AbortSignal } = {},
58
+ ): Promise<void> {
59
+ const signal = input.signal;
60
+ if (signal?.aborted) {
61
+ return;
62
+ }
63
+ if (options.getActiveConversationId() === sessionId) {
64
+ if (!options.isConversationPaneMode()) {
65
+ const targetConversation = options.conversationById(sessionId);
66
+ if (
67
+ targetConversation !== undefined &&
68
+ !targetConversation.live &&
69
+ targetConversation.status !== 'exited'
70
+ ) {
71
+ await options.startConversation(sessionId);
72
+ if (signal?.aborted) {
73
+ return;
74
+ }
75
+ }
76
+ if (targetConversation?.status !== 'exited') {
77
+ const attached = await attachConversationWithRecovery(sessionId, signal);
78
+ if (!attached) {
79
+ return;
80
+ }
81
+ }
82
+ options.enterConversationPaneForActiveSession(sessionId);
83
+ options.noteGitActivity(targetConversation?.directoryId ?? null);
84
+ options.schedulePtyResizeImmediate();
85
+ options.markDirty();
35
86
  }
36
87
  return;
37
88
  }
38
89
 
39
- this.options.stopConversationTitleEditForOtherSession(sessionId);
40
- const previousActiveId = this.options.getActiveConversationId();
41
- this.options.clearSelectionState();
90
+ options.stopConversationTitleEditForOtherSession(sessionId);
91
+ const previousActiveId = options.getActiveConversationId();
92
+ options.clearSelectionState();
42
93
  if (previousActiveId !== null) {
43
- await this.options.detachConversation(previousActiveId);
94
+ await options.detachConversation(previousActiveId);
95
+ if (signal?.aborted) {
96
+ return;
97
+ }
44
98
  }
45
- this.options.setActiveConversationId(sessionId);
46
- this.options.enterConversationPaneForSessionSwitch(sessionId);
47
99
 
48
- const targetConversation = this.options.conversationById(sessionId);
49
- this.options.noteGitActivity(targetConversation?.directoryId ?? null);
100
+ const targetConversation = options.conversationById(sessionId);
50
101
 
51
102
  if (
52
103
  targetConversation !== undefined &&
53
104
  !targetConversation.live &&
54
105
  targetConversation.status !== 'exited'
55
106
  ) {
56
- await this.options.startConversation(sessionId);
107
+ await options.startConversation(sessionId);
108
+ if (signal?.aborted) {
109
+ return;
110
+ }
57
111
  }
58
112
 
59
113
  if (targetConversation?.status !== 'exited') {
60
- try {
61
- await this.options.attachConversation(sessionId);
62
- } catch (error: unknown) {
63
- if (
64
- !this.options.isSessionNotFoundError(error) &&
65
- !this.options.isSessionNotLiveError(error)
66
- ) {
67
- throw error;
68
- }
69
- this.options.markSessionUnavailable(sessionId);
70
- await this.options.startConversation(sessionId);
71
- await this.options.attachConversation(sessionId);
114
+ const attached = await attachConversationWithRecovery(sessionId, signal);
115
+ if (!attached) {
116
+ return;
72
117
  }
73
118
  }
74
119
 
75
- this.options.schedulePtyResizeImmediate();
76
- this.options.markDirty();
120
+ options.setActiveConversationId(sessionId);
121
+ options.enterConversationPaneForSessionSwitch(sessionId);
122
+ options.noteGitActivity(targetConversation?.directoryId ?? null);
123
+ options.schedulePtyResizeImmediate();
124
+ options.markDirty();
77
125
  }
126
+
127
+ return {
128
+ activateConversation,
129
+ };
78
130
  }
@@ -33,6 +33,13 @@ interface RuntimeConversationStarterLaunchArgsInput {
33
33
 
34
34
  type RuntimeConversationStarterSpanAttributes = Record<string, string | number | boolean>;
35
35
 
36
+ function isSessionAlreadyExistsError(error: unknown): boolean {
37
+ if (!(error instanceof Error)) {
38
+ return false;
39
+ }
40
+ return error.message.includes('session already exists');
41
+ }
42
+
36
43
  export interface RuntimeConversationStarterOptions<
37
44
  TConversation extends RuntimeConversationStarterConversationRecord,
38
45
  TSessionSummary,
@@ -76,82 +83,113 @@ export interface RuntimeConversationStarterOptions<
76
83
  readonly subscribeConversationEvents: (sessionId: string) => Promise<void>;
77
84
  }
78
85
 
79
- export class RuntimeConversationStarter<
86
+ export interface RuntimeConversationStarter<
80
87
  TConversation extends RuntimeConversationStarterConversationRecord,
81
- TSessionSummary,
82
88
  > {
83
- constructor(
84
- private readonly options: RuntimeConversationStarterOptions<TConversation, TSessionSummary>,
85
- ) {}
89
+ startConversation(sessionId: string): Promise<TConversation>;
90
+ }
91
+
92
+ export function createRuntimeConversationStarter<
93
+ TConversation extends RuntimeConversationStarterConversationRecord,
94
+ TSessionSummary,
95
+ >(
96
+ options: RuntimeConversationStarterOptions<TConversation, TSessionSummary>,
97
+ ): RuntimeConversationStarter<TConversation> {
98
+ function endStartCommandSpanIfTarget(
99
+ sessionId: string,
100
+ payload: RuntimeConversationStarterSpanAttributes,
101
+ ): void {
102
+ if (options.firstPaintTargetSessionId() !== sessionId) {
103
+ return;
104
+ }
105
+ options.endStartCommandSpan(payload);
106
+ }
86
107
 
87
- async startConversation(sessionId: string): Promise<TConversation> {
88
- return await this.options.runWithStartInFlight(sessionId, async () => {
89
- const existing = this.options.conversationById(sessionId);
90
- const targetConversation = existing ?? this.options.ensureConversation(sessionId);
91
- const agentType = this.options.normalizeThreadAgentType(targetConversation.agentType);
108
+ async function startConversation(sessionId: string): Promise<TConversation> {
109
+ return await options.runWithStartInFlight(sessionId, async () => {
110
+ const existing = options.conversationById(sessionId);
111
+ const targetConversation = existing ?? options.ensureConversation(sessionId);
112
+ const agentType = options.normalizeThreadAgentType(targetConversation.agentType);
92
113
  const baseArgsForAgent =
93
114
  agentType === 'codex'
94
- ? this.options.codexArgs
115
+ ? options.codexArgs
95
116
  : agentType === 'critique'
96
- ? this.options.critiqueDefaultArgs
117
+ ? options.critiqueDefaultArgs
97
118
  : [];
98
- const sessionCwd = this.options.sessionCwdForConversation(targetConversation);
99
- const launchArgs = this.options.buildLaunchArgs({
119
+ const sessionCwd = options.sessionCwdForConversation(targetConversation);
120
+ const launchArgs = options.buildLaunchArgs({
100
121
  agentType,
101
122
  baseArgsForAgent,
102
123
  adapterState: targetConversation.adapterState,
103
124
  sessionCwd,
104
125
  });
105
- targetConversation.launchCommand = this.options.formatCommandForDebugBar(
106
- this.options.launchCommandForAgent(agentType),
126
+ targetConversation.launchCommand = options.formatCommandForDebugBar(
127
+ options.launchCommandForAgent(agentType),
107
128
  launchArgs,
108
129
  );
109
130
 
110
131
  if (existing?.live === true) {
111
- this.endStartCommandSpanIfTarget(sessionId, {
132
+ endStartCommandSpanIfTarget(sessionId, {
112
133
  alreadyLive: true,
113
134
  });
114
135
  return existing;
115
136
  }
116
137
 
117
- const startSpan = this.options.startConversationSpan(sessionId);
138
+ const startSpan = options.startConversationSpan(sessionId);
118
139
  targetConversation.lastOutputCursor = 0;
119
- const layout = this.options.layout();
140
+ const layout = options.layout();
120
141
  const ptyStartInput: RuntimeConversationStarterPtyStartInput = {
121
142
  sessionId,
122
143
  args: launchArgs,
123
- env: this.options.sessionEnv,
144
+ env: options.sessionEnv,
124
145
  cwd: sessionCwd,
125
146
  initialCols: layout.rightCols,
126
147
  initialRows: layout.paneRows,
127
148
  };
128
- if (this.options.worktreeId !== undefined) {
129
- ptyStartInput.worktreeId = this.options.worktreeId;
149
+ if (options.worktreeId !== undefined) {
150
+ ptyStartInput.worktreeId = options.worktreeId;
130
151
  }
131
- if (this.options.terminalForegroundHex !== undefined) {
132
- ptyStartInput.terminalForegroundHex = this.options.terminalForegroundHex;
152
+ if (options.terminalForegroundHex !== undefined) {
153
+ ptyStartInput.terminalForegroundHex = options.terminalForegroundHex;
133
154
  }
134
- if (this.options.terminalBackgroundHex !== undefined) {
135
- ptyStartInput.terminalBackgroundHex = this.options.terminalBackgroundHex;
155
+ if (options.terminalBackgroundHex !== undefined) {
156
+ ptyStartInput.terminalBackgroundHex = options.terminalBackgroundHex;
136
157
  }
137
- await this.options.startPtySession(ptyStartInput);
138
- this.options.setPtySize(sessionId, {
158
+ let startedSession = false;
159
+ try {
160
+ await options.startPtySession(ptyStartInput);
161
+ startedSession = true;
162
+ } catch (error: unknown) {
163
+ if (!isSessionAlreadyExistsError(error)) {
164
+ throw error;
165
+ }
166
+ }
167
+ options.setPtySize(sessionId, {
139
168
  cols: layout.rightCols,
140
169
  rows: layout.paneRows,
141
170
  });
142
- this.options.sendResize(sessionId, layout.rightCols, layout.paneRows);
143
- this.endStartCommandSpanIfTarget(sessionId, {
144
- alreadyLive: false,
145
- argCount: launchArgs.length,
146
- resumed: launchArgs[0] === 'resume',
147
- });
148
- const state = this.options.ensureConversation(sessionId);
149
- this.options.recordStartCommand(sessionId, launchArgs);
150
- const statusSummary = await this.options.getSessionStatus(sessionId);
171
+ options.sendResize(sessionId, layout.rightCols, layout.paneRows);
172
+ if (startedSession) {
173
+ endStartCommandSpanIfTarget(sessionId, {
174
+ alreadyLive: false,
175
+ argCount: launchArgs.length,
176
+ resumed: launchArgs[0] === 'resume',
177
+ });
178
+ } else {
179
+ endStartCommandSpanIfTarget(sessionId, {
180
+ alreadyLive: true,
181
+ recoveredDuplicateStart: true,
182
+ });
183
+ }
184
+ const state = options.ensureConversation(sessionId);
185
+ if (startedSession) {
186
+ options.recordStartCommand(sessionId, launchArgs);
187
+ }
188
+ const statusSummary = await options.getSessionStatus(sessionId);
151
189
  if (statusSummary !== null) {
152
- this.options.upsertFromSessionSummary(statusSummary);
190
+ options.upsertFromSessionSummary(statusSummary);
153
191
  }
154
- await this.options.subscribeConversationEvents(sessionId);
192
+ await options.subscribeConversationEvents(sessionId);
155
193
  startSpan.end({
156
194
  live: state.live,
157
195
  });
@@ -159,13 +197,7 @@ export class RuntimeConversationStarter<
159
197
  });
160
198
  }
161
199
 
162
- private endStartCommandSpanIfTarget(
163
- sessionId: string,
164
- payload: RuntimeConversationStarterSpanAttributes,
165
- ): void {
166
- if (this.options.firstPaintTargetSessionId() !== sessionId) {
167
- return;
168
- }
169
- this.options.endStartCommandSpan(payload);
170
- }
200
+ return {
201
+ startConversation,
202
+ };
171
203
  }