@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
@@ -1,191 +1,40 @@
1
- interface WorkspaceObservedReduction {
2
- readonly changed: boolean;
3
- readonly removedConversationIds: readonly string[];
4
- readonly removedDirectoryIds: readonly string[];
5
- }
6
-
7
- interface WorkspaceObservedReducer<TObservedEvent> {
8
- apply(observed: TObservedEvent): WorkspaceObservedReduction;
9
- }
10
-
11
- interface RuntimeWorkspaceStateLike {
12
- leftNavSelection:
13
- | {
14
- kind: 'home';
15
- }
16
- | {
17
- kind: 'project';
18
- directoryId: string;
19
- }
20
- | {
21
- kind: 'repository';
22
- repositoryId: string;
23
- }
24
- | {
25
- kind: 'conversation';
26
- sessionId: string;
27
- };
28
- conversationTitleEdit: {
29
- conversationId: string;
30
- } | null;
31
- projectPaneSnapshot: {
32
- directoryId: string;
33
- } | null;
34
- projectPaneScrollTop: number;
35
- activeDirectoryId: string | null;
36
- selectLeftNavConversation(sessionId: string): void;
37
- }
38
-
39
- interface RuntimeWorkspaceObservedEventsOptions<TObservedEvent> {
40
- readonly reducer: WorkspaceObservedReducer<TObservedEvent>;
41
- readonly workspace: RuntimeWorkspaceStateLike;
1
+ import type { HarnessSyncedStore } from '../core/store/harness-synced-store.ts';
2
+ import {
3
+ enqueueRuntimeWorkspaceObservedReactions,
4
+ type RuntimeWorkspaceObservedEffectQueueOptions,
5
+ } from './runtime-workspace-observed-effect-queue.ts';
6
+ import {
7
+ planRuntimeWorkspaceObservedTransition,
8
+ type RuntimeWorkspaceObservedTransitionPolicyOptions,
9
+ } from './runtime-workspace-observed-transition-policy.ts';
10
+
11
+ export interface RuntimeWorkspaceObservedEventsOptions {
12
+ readonly store: HarnessSyncedStore;
42
13
  readonly orderedConversationIds: () => readonly string[];
43
- readonly conversationDirectoryId: (sessionId: string) => string | null;
44
- readonly hasConversation: (sessionId: string) => boolean;
45
- readonly getActiveConversationId: () => string | null;
46
- readonly setActiveConversationId: (sessionId: string | null) => void;
47
- readonly hasDirectory: (directoryId: string) => boolean;
48
- readonly resolveActiveDirectoryId: () => string | null;
49
- readonly unsubscribeConversationEvents: (sessionId: string) => Promise<void>;
50
- readonly stopConversationTitleEdit: (persistPending: boolean) => void;
51
- readonly enterProjectPane: (directoryId: string) => void;
52
- readonly enterHomePane: () => void;
53
- readonly queueControlPlaneOp: (task: () => Promise<void>, label: string) => void;
54
- readonly activateConversation: (sessionId: string) => Promise<void>;
14
+ readonly transitionPolicy: RuntimeWorkspaceObservedTransitionPolicyOptions;
15
+ readonly effectQueue: RuntimeWorkspaceObservedEffectQueueOptions;
55
16
  readonly markDirty: () => void;
56
17
  }
57
18
 
58
- export class RuntimeWorkspaceObservedEvents<TObservedEvent> {
59
- constructor(private readonly options: RuntimeWorkspaceObservedEventsOptions<TObservedEvent>) {}
60
-
61
- apply(observed: TObservedEvent): void {
62
- const activeConversationIdBefore = this.options.getActiveConversationId();
63
- const leftNavConversationIdBefore =
64
- this.options.workspace.leftNavSelection.kind === 'conversation'
65
- ? this.options.workspace.leftNavSelection.sessionId
66
- : null;
67
- const previousConversationDirectoryById = new Map<string, string | null>();
68
- for (const sessionId of this.options.orderedConversationIds()) {
69
- previousConversationDirectoryById.set(
70
- sessionId,
71
- this.options.conversationDirectoryId(sessionId),
72
- );
73
- }
74
-
75
- const reduced = this.options.reducer.apply(observed);
76
- if (!reduced.changed) {
77
- return;
78
- }
79
-
80
- for (const sessionId of reduced.removedConversationIds) {
81
- void this.options.unsubscribeConversationEvents(sessionId);
82
- if (this.options.workspace.conversationTitleEdit?.conversationId === sessionId) {
83
- this.options.stopConversationTitleEdit(false);
84
- }
85
- }
86
-
87
- for (const directoryId of reduced.removedDirectoryIds) {
88
- if (this.options.workspace.projectPaneSnapshot?.directoryId === directoryId) {
89
- this.options.workspace.projectPaneSnapshot = null;
90
- this.options.workspace.projectPaneScrollTop = 0;
91
- }
92
- }
93
-
94
- if (
95
- this.options.workspace.activeDirectoryId !== null &&
96
- !this.options.hasDirectory(this.options.workspace.activeDirectoryId)
97
- ) {
98
- this.options.workspace.activeDirectoryId = this.options.resolveActiveDirectoryId();
99
- }
100
-
101
- const removedConversationIdSet = new Set(reduced.removedConversationIds);
102
- const activateFallbackConversationInDirectory = (
103
- preferredDirectoryId: string | null,
104
- label: string,
105
- ): boolean => {
106
- if (preferredDirectoryId === null) {
107
- return false;
108
- }
109
- const fallbackConversationId =
110
- this.options
111
- .orderedConversationIds()
112
- .find(
113
- (sessionId) => this.options.conversationDirectoryId(sessionId) === preferredDirectoryId,
114
- ) ?? null;
115
- if (fallbackConversationId === null) {
116
- return false;
117
- }
118
- this.options.queueControlPlaneOp(async () => {
119
- await this.options.activateConversation(fallbackConversationId);
120
- }, label);
121
- return true;
122
- };
123
- const fallbackToDirectoryOrHome = (): void => {
124
- const fallbackDirectoryId = this.options.resolveActiveDirectoryId();
125
- if (fallbackDirectoryId !== null) {
126
- this.options.enterProjectPane(fallbackDirectoryId);
127
- return;
128
- }
129
- this.options.enterHomePane();
130
- };
131
-
132
- if (
133
- activeConversationIdBefore !== null &&
134
- removedConversationIdSet.has(activeConversationIdBefore)
135
- ) {
136
- this.options.setActiveConversationId(null);
137
- const preferredDirectoryId =
138
- previousConversationDirectoryById.get(activeConversationIdBefore) ?? null;
139
- if (
140
- !activateFallbackConversationInDirectory(
141
- preferredDirectoryId,
142
- 'observed-active-conversation-removed',
143
- )
144
- ) {
145
- fallbackToDirectoryOrHome();
146
- }
147
- this.options.markDirty();
19
+ export function subscribeRuntimeWorkspaceObservedEvents(
20
+ options: RuntimeWorkspaceObservedEventsOptions,
21
+ ): () => void {
22
+ return options.store.subscribe((state, previousState) => {
23
+ if (state.synced === previousState.synced) {
148
24
  return;
149
25
  }
150
-
151
- if (
152
- leftNavConversationIdBefore !== null &&
153
- removedConversationIdSet.has(leftNavConversationIdBefore)
154
- ) {
155
- const currentActiveId = this.options.getActiveConversationId();
156
- if (currentActiveId !== null && this.options.hasConversation(currentActiveId)) {
157
- this.options.workspace.selectLeftNavConversation(currentActiveId);
158
- this.options.markDirty();
159
- return;
160
- }
161
- const preferredDirectoryId =
162
- previousConversationDirectoryById.get(leftNavConversationIdBefore) ?? null;
163
- if (
164
- !activateFallbackConversationInDirectory(
165
- preferredDirectoryId,
166
- 'observed-selected-conversation-removed',
167
- )
168
- ) {
169
- fallbackToDirectoryOrHome();
170
- }
171
- this.options.markDirty();
172
- return;
173
- }
174
-
175
- if (
176
- this.options.workspace.leftNavSelection.kind === 'project' &&
177
- !this.options.hasDirectory(this.options.workspace.leftNavSelection.directoryId)
178
- ) {
179
- const fallbackDirectoryId = this.options.resolveActiveDirectoryId();
180
- if (fallbackDirectoryId !== null) {
181
- this.options.enterProjectPane(fallbackDirectoryId);
182
- } else {
183
- this.options.enterHomePane();
184
- }
185
- this.options.markDirty();
186
- return;
187
- }
188
-
189
- this.options.markDirty();
190
- }
26
+ const planned = planRuntimeWorkspaceObservedTransition({
27
+ transition: {
28
+ previous: previousState.synced,
29
+ current: state.synced,
30
+ orderedConversationIds: options.orderedConversationIds(),
31
+ },
32
+ options: options.transitionPolicy,
33
+ });
34
+ enqueueRuntimeWorkspaceObservedReactions({
35
+ reactions: planned.reactions,
36
+ options: options.effectQueue,
37
+ });
38
+ options.markDirty();
39
+ });
191
40
  }
@@ -0,0 +1,228 @@
1
+ import type { HarnessSyncedState } from '../core/state/synced-observed-state.ts';
2
+
3
+ interface RuntimeWorkspaceStateLike {
4
+ leftNavSelection:
5
+ | {
6
+ kind: 'home';
7
+ }
8
+ | {
9
+ kind: 'nim';
10
+ }
11
+ | {
12
+ kind: 'tasks';
13
+ }
14
+ | {
15
+ kind: 'project';
16
+ directoryId: string;
17
+ }
18
+ | {
19
+ kind: 'github';
20
+ directoryId: string;
21
+ }
22
+ | {
23
+ kind: 'repository';
24
+ repositoryId: string;
25
+ }
26
+ | {
27
+ kind: 'conversation';
28
+ sessionId: string;
29
+ };
30
+ conversationTitleEdit: {
31
+ conversationId: string;
32
+ } | null;
33
+ projectPaneSnapshot: {
34
+ directoryId: string;
35
+ } | null;
36
+ projectPaneScrollTop: number;
37
+ activeDirectoryId: string | null;
38
+ selectLeftNavConversation(sessionId: string): void;
39
+ }
40
+
41
+ export interface RuntimeWorkspaceObservedTransition {
42
+ readonly previous: HarnessSyncedState;
43
+ readonly current: HarnessSyncedState;
44
+ readonly orderedConversationIds: readonly string[];
45
+ }
46
+
47
+ export interface RuntimeWorkspaceObservedQueuedReaction {
48
+ readonly kind: 'unsubscribe-conversation' | 'activate-conversation';
49
+ readonly sessionId: string;
50
+ readonly label: string;
51
+ }
52
+
53
+ export interface RuntimeWorkspaceObservedTransitionPolicyResult {
54
+ readonly reactions: readonly RuntimeWorkspaceObservedQueuedReaction[];
55
+ }
56
+
57
+ export interface RuntimeWorkspaceObservedTransitionPolicyOptions {
58
+ readonly workspace: RuntimeWorkspaceStateLike;
59
+ readonly getActiveConversationId: () => string | null;
60
+ readonly setActiveConversationId: (sessionId: string | null) => void;
61
+ readonly resolveActiveDirectoryId: () => string | null;
62
+ readonly stopConversationTitleEdit: (persistPending: boolean) => void;
63
+ readonly enterProjectPane: (directoryId: string) => void;
64
+ readonly enterHomePane: () => void;
65
+ }
66
+
67
+ function conversationDirectoryIdOf(state: HarnessSyncedState, sessionId: string): string | null {
68
+ return state.conversationsById[sessionId]?.directoryId ?? null;
69
+ }
70
+
71
+ function hasConversation(state: HarnessSyncedState, sessionId: string): boolean {
72
+ return state.conversationsById[sessionId] !== undefined;
73
+ }
74
+
75
+ function hasDirectory(state: HarnessSyncedState, directoryId: string): boolean {
76
+ return state.directoriesById[directoryId] !== undefined;
77
+ }
78
+
79
+ function removedIds(
80
+ previous: Readonly<Record<string, unknown>>,
81
+ current: Readonly<Record<string, unknown>>,
82
+ ): readonly string[] {
83
+ const removed: string[] = [];
84
+ for (const id of Object.keys(previous)) {
85
+ if (current[id] !== undefined) {
86
+ continue;
87
+ }
88
+ removed.push(id);
89
+ }
90
+ return removed;
91
+ }
92
+
93
+ export function planRuntimeWorkspaceObservedTransition(input: {
94
+ readonly transition: RuntimeWorkspaceObservedTransition;
95
+ readonly options: RuntimeWorkspaceObservedTransitionPolicyOptions;
96
+ }): RuntimeWorkspaceObservedTransitionPolicyResult {
97
+ const reactions: RuntimeWorkspaceObservedQueuedReaction[] = [];
98
+ const removedConversationIds = removedIds(
99
+ input.transition.previous.conversationsById,
100
+ input.transition.current.conversationsById,
101
+ );
102
+ const removedDirectoryIds = removedIds(
103
+ input.transition.previous.directoriesById,
104
+ input.transition.current.directoriesById,
105
+ );
106
+ const activeConversationIdBefore = input.options.getActiveConversationId();
107
+ const leftNavConversationIdBefore =
108
+ input.options.workspace.leftNavSelection.kind === 'conversation'
109
+ ? input.options.workspace.leftNavSelection.sessionId
110
+ : null;
111
+
112
+ for (const sessionId of removedConversationIds) {
113
+ reactions.push({
114
+ kind: 'unsubscribe-conversation',
115
+ sessionId,
116
+ label: `observed-unsubscribe-conversation:${sessionId}`,
117
+ });
118
+ if (input.options.workspace.conversationTitleEdit?.conversationId === sessionId) {
119
+ input.options.stopConversationTitleEdit(false);
120
+ }
121
+ }
122
+
123
+ for (const directoryId of removedDirectoryIds) {
124
+ if (input.options.workspace.projectPaneSnapshot?.directoryId === directoryId) {
125
+ input.options.workspace.projectPaneSnapshot = null;
126
+ input.options.workspace.projectPaneScrollTop = 0;
127
+ }
128
+ }
129
+
130
+ if (
131
+ input.options.workspace.activeDirectoryId !== null &&
132
+ !hasDirectory(input.transition.current, input.options.workspace.activeDirectoryId)
133
+ ) {
134
+ input.options.workspace.activeDirectoryId = input.options.resolveActiveDirectoryId();
135
+ }
136
+
137
+ const removedConversationIdSet = new Set(removedConversationIds);
138
+ const activateFallbackConversationInDirectory = (
139
+ preferredDirectoryId: string | null,
140
+ label: string,
141
+ ): boolean => {
142
+ if (preferredDirectoryId === null) {
143
+ return false;
144
+ }
145
+ const fallbackConversationId =
146
+ input.transition.orderedConversationIds.find(
147
+ (sessionId) =>
148
+ conversationDirectoryIdOf(input.transition.current, sessionId) === preferredDirectoryId,
149
+ ) ?? null;
150
+ if (fallbackConversationId === null) {
151
+ return false;
152
+ }
153
+ reactions.push({
154
+ kind: 'activate-conversation',
155
+ sessionId: fallbackConversationId,
156
+ label,
157
+ });
158
+ return true;
159
+ };
160
+
161
+ const fallbackToDirectoryOrHome = (): void => {
162
+ const fallbackDirectoryId = input.options.resolveActiveDirectoryId();
163
+ if (fallbackDirectoryId !== null) {
164
+ input.options.enterProjectPane(fallbackDirectoryId);
165
+ return;
166
+ }
167
+ input.options.enterHomePane();
168
+ };
169
+
170
+ if (
171
+ activeConversationIdBefore !== null &&
172
+ removedConversationIdSet.has(activeConversationIdBefore)
173
+ ) {
174
+ input.options.setActiveConversationId(null);
175
+ const preferredDirectoryId = conversationDirectoryIdOf(
176
+ input.transition.previous,
177
+ activeConversationIdBefore,
178
+ );
179
+ if (
180
+ !activateFallbackConversationInDirectory(
181
+ preferredDirectoryId,
182
+ 'observed-active-conversation-removed',
183
+ )
184
+ ) {
185
+ fallbackToDirectoryOrHome();
186
+ }
187
+ return { reactions };
188
+ }
189
+
190
+ if (
191
+ leftNavConversationIdBefore !== null &&
192
+ removedConversationIdSet.has(leftNavConversationIdBefore)
193
+ ) {
194
+ const currentActiveId = input.options.getActiveConversationId();
195
+ if (currentActiveId !== null && hasConversation(input.transition.current, currentActiveId)) {
196
+ input.options.workspace.selectLeftNavConversation(currentActiveId);
197
+ return { reactions };
198
+ }
199
+ const preferredDirectoryId = conversationDirectoryIdOf(
200
+ input.transition.previous,
201
+ leftNavConversationIdBefore,
202
+ );
203
+ if (
204
+ !activateFallbackConversationInDirectory(
205
+ preferredDirectoryId,
206
+ 'observed-selected-conversation-removed',
207
+ )
208
+ ) {
209
+ fallbackToDirectoryOrHome();
210
+ }
211
+ return { reactions };
212
+ }
213
+
214
+ if (
215
+ input.options.workspace.leftNavSelection.kind === 'project' &&
216
+ !hasDirectory(input.transition.current, input.options.workspace.leftNavSelection.directoryId)
217
+ ) {
218
+ const fallbackDirectoryId = input.options.resolveActiveDirectoryId();
219
+ if (fallbackDirectoryId !== null) {
220
+ input.options.enterProjectPane(fallbackDirectoryId);
221
+ } else {
222
+ input.options.enterHomePane();
223
+ }
224
+ return { reactions };
225
+ }
226
+
227
+ return { reactions };
228
+ }
@@ -0,0 +1,217 @@
1
+ import { closeSync, mkdirSync, openSync, rmSync, writeSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import {
4
+ renderTraceChunkPreview,
5
+ type RenderTraceControlIssue,
6
+ } from '../mux/live-mux/render-trace-analysis.ts';
7
+
8
+ const DEFAULT_MAX_ENTRIES_PER_CONVERSATION = 512;
9
+ export const SESSION_DIAGNOSTICS_FILE_EXTENSION = '.jsonl';
10
+
11
+ export interface SessionStatusSnapshot {
12
+ readonly status: string;
13
+ readonly attentionReason: string | null;
14
+ readonly live: boolean;
15
+ readonly phase: string | null;
16
+ readonly detailText: string | null;
17
+ readonly lastKnownWork: string | null;
18
+ readonly lastKnownWorkAt: string | null;
19
+ readonly telemetrySource: string | null;
20
+ }
21
+
22
+ export interface UnsupportedControlSequencesEntry {
23
+ readonly kind: 'unsupported-control-sequences';
24
+ readonly observedAt: string;
25
+ readonly source: string;
26
+ readonly cursor: number;
27
+ readonly chunkPreview: string;
28
+ readonly issues: ReadonlyArray<{
29
+ readonly kind: RenderTraceControlIssue['kind'];
30
+ readonly offset: number;
31
+ readonly sequence: string;
32
+ readonly sequencePreview: string;
33
+ readonly finalByte?: string;
34
+ readonly rawParams?: string;
35
+ }>;
36
+ }
37
+
38
+ export interface SessionStatusTransitionEntry {
39
+ readonly kind: 'status-transition';
40
+ readonly observedAt: string;
41
+ readonly source: string;
42
+ readonly from: SessionStatusSnapshot;
43
+ readonly to: SessionStatusSnapshot;
44
+ readonly metadata?: Record<string, unknown>;
45
+ }
46
+
47
+ export type SessionDiagnosticsEntry =
48
+ | UnsupportedControlSequencesEntry
49
+ | SessionStatusTransitionEntry;
50
+
51
+ interface SessionDiagnosticsStoreOptions {
52
+ readonly maxEntriesPerConversation?: number;
53
+ readonly diagnosticsDirectory?: string | null;
54
+ }
55
+
56
+ export class SessionDiagnosticsStore {
57
+ private readonly maxEntriesPerConversation: number;
58
+ private readonly diagnosticsDirectory: string | null;
59
+ private readonly entriesByConversationId = new Map<string, SessionDiagnosticsEntry[]>();
60
+ private readonly fileDescriptorByConversationId = new Map<string, number>();
61
+
62
+ constructor(options: SessionDiagnosticsStoreOptions = {}) {
63
+ const configuredMax = options.maxEntriesPerConversation;
64
+ this.maxEntriesPerConversation =
65
+ typeof configuredMax === 'number' && Number.isFinite(configuredMax)
66
+ ? Math.max(1, Math.floor(configuredMax))
67
+ : DEFAULT_MAX_ENTRIES_PER_CONVERSATION;
68
+ this.diagnosticsDirectory =
69
+ typeof options.diagnosticsDirectory === 'string' && options.diagnosticsDirectory.length > 0
70
+ ? resolve(options.diagnosticsDirectory)
71
+ : null;
72
+ }
73
+
74
+ close(): void {
75
+ for (const conversationId of this.fileDescriptorByConversationId.keys()) {
76
+ this.closeConversationFile(conversationId);
77
+ }
78
+ }
79
+
80
+ recordUnsupportedControlSequences(input: {
81
+ readonly conversationId: string;
82
+ readonly observedAt: string;
83
+ readonly source: string;
84
+ readonly cursor: number;
85
+ readonly chunkPreview: string;
86
+ readonly issues: readonly RenderTraceControlIssue[];
87
+ }): void {
88
+ if (input.issues.length === 0) {
89
+ return;
90
+ }
91
+ const normalizedIssues = input.issues.map((issue) => ({
92
+ kind: issue.kind,
93
+ offset: issue.offset,
94
+ sequence: issue.sequence,
95
+ sequencePreview: renderTraceChunkPreview(issue.sequence, 160),
96
+ ...(issue.finalByte === undefined ? {} : { finalByte: issue.finalByte }),
97
+ ...(issue.rawParams === undefined ? {} : { rawParams: issue.rawParams }),
98
+ }));
99
+ this.recordConversationEntry(input.conversationId, {
100
+ kind: 'unsupported-control-sequences',
101
+ observedAt: input.observedAt,
102
+ source: input.source,
103
+ cursor: input.cursor,
104
+ chunkPreview: input.chunkPreview,
105
+ issues: normalizedIssues,
106
+ });
107
+ }
108
+
109
+ recordStatusTransition(input: {
110
+ readonly conversationId: string;
111
+ readonly observedAt: string;
112
+ readonly source: string;
113
+ readonly from: SessionStatusSnapshot;
114
+ readonly to: SessionStatusSnapshot;
115
+ readonly metadata?: Record<string, unknown>;
116
+ }): void {
117
+ this.recordConversationEntry(input.conversationId, {
118
+ kind: 'status-transition',
119
+ observedAt: input.observedAt,
120
+ source: input.source,
121
+ from: input.from,
122
+ to: input.to,
123
+ ...(input.metadata === undefined ? {} : { metadata: input.metadata }),
124
+ });
125
+ }
126
+
127
+ listConversationEntries(conversationId: string): readonly SessionDiagnosticsEntry[] {
128
+ const entries = this.entriesByConversationId.get(conversationId);
129
+ return entries === undefined ? [] : [...entries];
130
+ }
131
+
132
+ clearConversation(conversationId: string): void {
133
+ this.entriesByConversationId.delete(conversationId);
134
+ this.closeConversationFile(conversationId);
135
+ const filePath = this.resolveConversationDiagnosticsPath(conversationId);
136
+ if (filePath === null) {
137
+ return;
138
+ }
139
+ try {
140
+ rmSync(filePath, { force: true });
141
+ } catch {
142
+ // Best-effort cleanup only.
143
+ }
144
+ }
145
+
146
+ private recordConversationEntry(conversationId: string, entry: SessionDiagnosticsEntry): void {
147
+ const entries = this.entriesByConversationId.get(conversationId) ?? [];
148
+ entries.push(entry);
149
+ if (entries.length > this.maxEntriesPerConversation) {
150
+ entries.splice(0, entries.length - this.maxEntriesPerConversation);
151
+ }
152
+ this.entriesByConversationId.set(conversationId, entries);
153
+ this.writeConversationEntry(conversationId, entry);
154
+ }
155
+
156
+ private writeConversationEntry(conversationId: string, entry: SessionDiagnosticsEntry): void {
157
+ const filePath = this.resolveConversationDiagnosticsPath(conversationId);
158
+ if (filePath === null) {
159
+ return;
160
+ }
161
+ const fd = this.ensureConversationFile(conversationId, filePath);
162
+ if (fd === null) {
163
+ return;
164
+ }
165
+ const record = {
166
+ conversationId,
167
+ ...entry,
168
+ };
169
+ try {
170
+ writeSync(fd, `${JSON.stringify(record)}\n`);
171
+ } catch {
172
+ this.closeConversationFile(conversationId);
173
+ }
174
+ }
175
+
176
+ private ensureConversationFile(conversationId: string, filePath: string): number | null {
177
+ const existing = this.fileDescriptorByConversationId.get(conversationId);
178
+ if (existing !== undefined) {
179
+ return existing;
180
+ }
181
+ try {
182
+ mkdirSync(dirname(filePath), { recursive: true });
183
+ const fd = openSync(filePath, 'a');
184
+ this.fileDescriptorByConversationId.set(conversationId, fd);
185
+ return fd;
186
+ } catch {
187
+ this.closeConversationFile(conversationId);
188
+ return null;
189
+ }
190
+ }
191
+
192
+ private closeConversationFile(conversationId: string): void {
193
+ const fd = this.fileDescriptorByConversationId.get(conversationId);
194
+ if (fd === undefined) {
195
+ return;
196
+ }
197
+ try {
198
+ closeSync(fd);
199
+ } catch {
200
+ // Best-effort close only.
201
+ }
202
+ this.fileDescriptorByConversationId.delete(conversationId);
203
+ }
204
+
205
+ private resolveConversationDiagnosticsPath(conversationId: string): string | null {
206
+ if (this.diagnosticsDirectory === null) {
207
+ return null;
208
+ }
209
+ const fileToken = sanitizeFileToken(conversationId);
210
+ return resolve(this.diagnosticsDirectory, `${fileToken}${SESSION_DIAGNOSTICS_FILE_EXTENSION}`);
211
+ }
212
+ }
213
+
214
+ function sanitizeFileToken(value: string): string {
215
+ const normalized = value.trim().replace(/[^A-Za-z0-9._-]+/gu, '-');
216
+ return normalized.length === 0 ? 'conversation' : normalized;
217
+ }