@jmoyers/harness 0.1.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 (214) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/native/ptyd/Cargo.lock +16 -0
  4. package/native/ptyd/Cargo.toml +7 -0
  5. package/native/ptyd/src/main.rs +257 -0
  6. package/package.json +90 -0
  7. package/scripts/build-ptyd.sh +73 -0
  8. package/scripts/control-plane-daemon.ts +277 -0
  9. package/scripts/cursor-hook-relay.ts +82 -0
  10. package/scripts/harness-animate.ts +469 -0
  11. package/scripts/harness-bin.js +77 -0
  12. package/scripts/harness-core.ts +1 -0
  13. package/scripts/harness-inspector.ts +439 -0
  14. package/scripts/harness.ts +2493 -0
  15. package/src/adapters/agent-session-state.ts +390 -0
  16. package/src/cli/gateway-record.ts +173 -0
  17. package/src/codex/live-session.ts +872 -0
  18. package/src/config/config-core.ts +1359 -0
  19. package/src/config/secrets-core.ts +170 -0
  20. package/src/control-plane/agent-realtime-api.ts +2441 -0
  21. package/src/control-plane/codex-session-stream.ts +392 -0
  22. package/src/control-plane/codex-telemetry.ts +1325 -0
  23. package/src/control-plane/lifecycle-hooks.ts +706 -0
  24. package/src/control-plane/session-summary.ts +380 -0
  25. package/src/control-plane/status/agent-status-reducer.ts +21 -0
  26. package/src/control-plane/status/reducer-base.ts +170 -0
  27. package/src/control-plane/status/reducers/claude-status-reducer.ts +37 -0
  28. package/src/control-plane/status/reducers/codex-status-reducer.ts +48 -0
  29. package/src/control-plane/status/reducers/critique-status-reducer.ts +15 -0
  30. package/src/control-plane/status/reducers/cursor-status-reducer.ts +37 -0
  31. package/src/control-plane/status/reducers/terminal-status-reducer.ts +15 -0
  32. package/src/control-plane/status/session-status-engine.ts +76 -0
  33. package/src/control-plane/stream-client.ts +396 -0
  34. package/src/control-plane/stream-command-parser.ts +1673 -0
  35. package/src/control-plane/stream-protocol.ts +1808 -0
  36. package/src/control-plane/stream-server-background.ts +486 -0
  37. package/src/control-plane/stream-server-command.ts +2557 -0
  38. package/src/control-plane/stream-server-connection.ts +234 -0
  39. package/src/control-plane/stream-server-observed-filter.ts +112 -0
  40. package/src/control-plane/stream-server-session-runtime.ts +566 -0
  41. package/src/control-plane/stream-server-state-store.ts +15 -0
  42. package/src/control-plane/stream-server.ts +3192 -0
  43. package/src/cursor/managed-hooks.ts +282 -0
  44. package/src/domain/conversations.ts +414 -0
  45. package/src/domain/directories.ts +78 -0
  46. package/src/domain/repositories.ts +123 -0
  47. package/src/domain/tasks.ts +148 -0
  48. package/src/domain/workspace.ts +156 -0
  49. package/src/events/normalized-events.ts +124 -0
  50. package/src/mux/ansi-integrity.ts +103 -0
  51. package/src/mux/control-plane-op-queue.ts +212 -0
  52. package/src/mux/conversation-rail.ts +339 -0
  53. package/src/mux/double-click.ts +78 -0
  54. package/src/mux/dual-pane-core.ts +435 -0
  55. package/src/mux/harness-core-ui.ts +817 -0
  56. package/src/mux/input-shortcuts.ts +667 -0
  57. package/src/mux/live-mux/actions-conversation.ts +344 -0
  58. package/src/mux/live-mux/actions-repository.ts +246 -0
  59. package/src/mux/live-mux/actions-task.ts +115 -0
  60. package/src/mux/live-mux/args.ts +142 -0
  61. package/src/mux/live-mux/command-menu.ts +298 -0
  62. package/src/mux/live-mux/control-plane-records.ts +546 -0
  63. package/src/mux/live-mux/conversation-state.ts +188 -0
  64. package/src/mux/live-mux/directory-resolution.ts +34 -0
  65. package/src/mux/live-mux/event-mapping.ts +96 -0
  66. package/src/mux/live-mux/gateway-profiler.ts +152 -0
  67. package/src/mux/live-mux/gateway-render-trace.ts +177 -0
  68. package/src/mux/live-mux/gateway-status-timeline.ts +166 -0
  69. package/src/mux/live-mux/git-parsing.ts +131 -0
  70. package/src/mux/live-mux/git-snapshot.ts +263 -0
  71. package/src/mux/live-mux/git-state.ts +136 -0
  72. package/src/mux/live-mux/global-shortcut-handlers.ts +143 -0
  73. package/src/mux/live-mux/home-pane-actions.ts +58 -0
  74. package/src/mux/live-mux/home-pane-drop.ts +44 -0
  75. package/src/mux/live-mux/home-pane-entity-click.ts +96 -0
  76. package/src/mux/live-mux/home-pane-pointer.ts +96 -0
  77. package/src/mux/live-mux/input-forwarding.ts +112 -0
  78. package/src/mux/live-mux/layout.ts +30 -0
  79. package/src/mux/live-mux/left-nav-activation.ts +103 -0
  80. package/src/mux/live-mux/left-nav.ts +85 -0
  81. package/src/mux/live-mux/left-rail-actions.ts +118 -0
  82. package/src/mux/live-mux/left-rail-conversation-click.ts +82 -0
  83. package/src/mux/live-mux/left-rail-pointer.ts +74 -0
  84. package/src/mux/live-mux/modal-command-menu-handler.ts +101 -0
  85. package/src/mux/live-mux/modal-conversation-handlers.ts +217 -0
  86. package/src/mux/live-mux/modal-input-reducers.ts +94 -0
  87. package/src/mux/live-mux/modal-overlays.ts +287 -0
  88. package/src/mux/live-mux/modal-pointer.ts +70 -0
  89. package/src/mux/live-mux/modal-prompt-handlers.ts +187 -0
  90. package/src/mux/live-mux/modal-task-editor-handler.ts +156 -0
  91. package/src/mux/live-mux/observed-stream.ts +87 -0
  92. package/src/mux/live-mux/palette-parsing.ts +128 -0
  93. package/src/mux/live-mux/pointer-routing.ts +108 -0
  94. package/src/mux/live-mux/process-usage.ts +53 -0
  95. package/src/mux/live-mux/project-pane-pointer.ts +44 -0
  96. package/src/mux/live-mux/rail-layout.ts +244 -0
  97. package/src/mux/live-mux/render-trace-analysis.ts +213 -0
  98. package/src/mux/live-mux/render-trace-state.ts +84 -0
  99. package/src/mux/live-mux/repository-folding.ts +207 -0
  100. package/src/mux/live-mux/runtime-shutdown.ts +51 -0
  101. package/src/mux/live-mux/selection.ts +411 -0
  102. package/src/mux/live-mux/startup-utils.ts +187 -0
  103. package/src/mux/live-mux/status-timeline-state.ts +82 -0
  104. package/src/mux/live-mux/task-pane-shortcuts.ts +206 -0
  105. package/src/mux/live-mux/terminal-palette.ts +79 -0
  106. package/src/mux/new-thread-prompt.ts +165 -0
  107. package/src/mux/project-tree.ts +295 -0
  108. package/src/mux/render-frame.ts +113 -0
  109. package/src/mux/runtime-wiring.ts +185 -0
  110. package/src/mux/selector-index.ts +160 -0
  111. package/src/mux/startup-sequencer.ts +238 -0
  112. package/src/mux/task-composer.ts +289 -0
  113. package/src/mux/task-focused-pane.ts +417 -0
  114. package/src/mux/task-screen-keybindings.ts +539 -0
  115. package/src/mux/terminal-input-modes.ts +35 -0
  116. package/src/mux/workspace-path.ts +55 -0
  117. package/src/mux/workspace-rail-model.ts +701 -0
  118. package/src/mux/workspace-rail.ts +247 -0
  119. package/src/perf/perf-core.ts +307 -0
  120. package/src/pty/pty_host.ts +217 -0
  121. package/src/pty/session-broker.ts +158 -0
  122. package/src/recording/terminal-recording.ts +383 -0
  123. package/src/services/control-plane.ts +567 -0
  124. package/src/services/conversation-lifecycle.ts +176 -0
  125. package/src/services/conversation-startup-hydration.ts +47 -0
  126. package/src/services/directory-hydration.ts +49 -0
  127. package/src/services/event-persistence.ts +104 -0
  128. package/src/services/mux-ui-state-persistence.ts +82 -0
  129. package/src/services/output-load-sampler.ts +231 -0
  130. package/src/services/process-usage-refresh.ts +88 -0
  131. package/src/services/recording.ts +75 -0
  132. package/src/services/render-trace-recorder.ts +177 -0
  133. package/src/services/runtime-control-actions.ts +123 -0
  134. package/src/services/runtime-control-plane-ops.ts +131 -0
  135. package/src/services/runtime-conversation-actions.ts +113 -0
  136. package/src/services/runtime-conversation-activation.ts +78 -0
  137. package/src/services/runtime-conversation-starter.ts +171 -0
  138. package/src/services/runtime-conversation-title-edit.ts +149 -0
  139. package/src/services/runtime-directory-actions.ts +164 -0
  140. package/src/services/runtime-envelope-handler.ts +198 -0
  141. package/src/services/runtime-git-state.ts +92 -0
  142. package/src/services/runtime-input-pipeline.ts +50 -0
  143. package/src/services/runtime-input-router.ts +202 -0
  144. package/src/services/runtime-layout-resize.ts +236 -0
  145. package/src/services/runtime-left-rail-render.ts +159 -0
  146. package/src/services/runtime-main-pane-input.ts +230 -0
  147. package/src/services/runtime-modal-input.ts +119 -0
  148. package/src/services/runtime-navigation-input.ts +207 -0
  149. package/src/services/runtime-process-wiring.ts +68 -0
  150. package/src/services/runtime-rail-input.ts +287 -0
  151. package/src/services/runtime-render-flush.ts +146 -0
  152. package/src/services/runtime-render-lifecycle.ts +104 -0
  153. package/src/services/runtime-render-orchestrator.ts +108 -0
  154. package/src/services/runtime-render-pipeline.ts +167 -0
  155. package/src/services/runtime-render-state.ts +72 -0
  156. package/src/services/runtime-repository-actions.ts +197 -0
  157. package/src/services/runtime-right-pane-render.ts +132 -0
  158. package/src/services/runtime-shutdown.ts +79 -0
  159. package/src/services/runtime-stream-subscriptions.ts +56 -0
  160. package/src/services/runtime-task-composer-persistence.ts +139 -0
  161. package/src/services/runtime-task-editor-actions.ts +83 -0
  162. package/src/services/runtime-task-pane-actions.ts +198 -0
  163. package/src/services/runtime-task-pane-shortcuts.ts +189 -0
  164. package/src/services/runtime-task-pane.ts +62 -0
  165. package/src/services/runtime-workspace-actions.ts +153 -0
  166. package/src/services/runtime-workspace-observed-events.ts +190 -0
  167. package/src/services/session-projection-instrumentation.ts +190 -0
  168. package/src/services/startup-background-probe.ts +91 -0
  169. package/src/services/startup-background-resume.ts +65 -0
  170. package/src/services/startup-orchestrator.ts +166 -0
  171. package/src/services/startup-output-tracker.ts +54 -0
  172. package/src/services/startup-paint-tracker.ts +115 -0
  173. package/src/services/startup-persisted-conversation-queue.ts +45 -0
  174. package/src/services/startup-settled-gate.ts +67 -0
  175. package/src/services/startup-shutdown.ts +53 -0
  176. package/src/services/startup-span-tracker.ts +77 -0
  177. package/src/services/startup-state-hydration.ts +94 -0
  178. package/src/services/startup-visibility.ts +35 -0
  179. package/src/services/status-timeline-recorder.ts +144 -0
  180. package/src/services/task-pane-selection-actions.ts +153 -0
  181. package/src/services/task-planning-hydration.ts +58 -0
  182. package/src/services/task-planning-observed-events.ts +89 -0
  183. package/src/services/workspace-observed-events.ts +113 -0
  184. package/src/store/control-plane-store-normalize.ts +760 -0
  185. package/src/store/control-plane-store-types.ts +224 -0
  186. package/src/store/control-plane-store.ts +2951 -0
  187. package/src/store/event-store.ts +253 -0
  188. package/src/store/sqlite.ts +81 -0
  189. package/src/terminal/compat-matrix.ts +345 -0
  190. package/src/terminal/differential-checkpoints.ts +132 -0
  191. package/src/terminal/parity-suite.ts +441 -0
  192. package/src/terminal/snapshot-oracle.ts +1840 -0
  193. package/src/ui/conversation-input-forwarder.ts +114 -0
  194. package/src/ui/conversation-selection-input.ts +103 -0
  195. package/src/ui/debug-footer-notice.ts +39 -0
  196. package/src/ui/global-shortcut-input.ts +126 -0
  197. package/src/ui/input-preflight.ts +68 -0
  198. package/src/ui/input-token-router.ts +312 -0
  199. package/src/ui/input.ts +238 -0
  200. package/src/ui/kit.ts +509 -0
  201. package/src/ui/left-nav-input.ts +80 -0
  202. package/src/ui/left-rail-pointer-input.ts +148 -0
  203. package/src/ui/main-pane-pointer-input.ts +150 -0
  204. package/src/ui/modals/manager.ts +192 -0
  205. package/src/ui/mux-theme.ts +529 -0
  206. package/src/ui/panes/conversation.ts +19 -0
  207. package/src/ui/panes/home-gridfire.ts +302 -0
  208. package/src/ui/panes/home.ts +109 -0
  209. package/src/ui/panes/left-rail.ts +12 -0
  210. package/src/ui/panes/project.ts +44 -0
  211. package/src/ui/pointer-routing-input.ts +158 -0
  212. package/src/ui/repository-fold-input.ts +91 -0
  213. package/src/ui/screen.ts +210 -0
  214. package/src/ui/surface.ts +224 -0
@@ -0,0 +1,190 @@
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;
42
+ 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>;
55
+ readonly markDirty: () => void;
56
+ }
57
+
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 activateFallbackConversation = (
103
+ preferredDirectoryId: string | null,
104
+ label: string,
105
+ ): boolean => {
106
+ const ordered = this.options.orderedConversationIds();
107
+ const fallbackConversationId =
108
+ (preferredDirectoryId === null
109
+ ? null
110
+ : (ordered.find(
111
+ (sessionId) =>
112
+ this.options.conversationDirectoryId(sessionId) === preferredDirectoryId,
113
+ ) ?? null)) ??
114
+ ordered[0] ??
115
+ null;
116
+ if (fallbackConversationId === null) {
117
+ return false;
118
+ }
119
+ this.options.queueControlPlaneOp(async () => {
120
+ await this.options.activateConversation(fallbackConversationId);
121
+ }, label);
122
+ return true;
123
+ };
124
+ const fallbackToDirectoryOrHome = (): void => {
125
+ const fallbackDirectoryId = this.options.resolveActiveDirectoryId();
126
+ if (fallbackDirectoryId !== null) {
127
+ this.options.enterProjectPane(fallbackDirectoryId);
128
+ this.options.markDirty();
129
+ return;
130
+ }
131
+ this.options.enterHomePane();
132
+ };
133
+
134
+ if (
135
+ activeConversationIdBefore !== null &&
136
+ removedConversationIdSet.has(activeConversationIdBefore)
137
+ ) {
138
+ this.options.setActiveConversationId(null);
139
+ const preferredDirectoryId =
140
+ previousConversationDirectoryById.get(activeConversationIdBefore) ?? null;
141
+ if (
142
+ !activateFallbackConversation(preferredDirectoryId, 'observed-active-conversation-removed')
143
+ ) {
144
+ fallbackToDirectoryOrHome();
145
+ }
146
+ this.options.markDirty();
147
+ return;
148
+ }
149
+
150
+ if (
151
+ leftNavConversationIdBefore !== null &&
152
+ removedConversationIdSet.has(leftNavConversationIdBefore)
153
+ ) {
154
+ const currentActiveId = this.options.getActiveConversationId();
155
+ if (currentActiveId !== null && this.options.hasConversation(currentActiveId)) {
156
+ this.options.workspace.selectLeftNavConversation(currentActiveId);
157
+ this.options.markDirty();
158
+ return;
159
+ }
160
+ const preferredDirectoryId =
161
+ previousConversationDirectoryById.get(leftNavConversationIdBefore) ?? null;
162
+ if (
163
+ !activateFallbackConversation(
164
+ preferredDirectoryId,
165
+ 'observed-selected-conversation-removed',
166
+ )
167
+ ) {
168
+ fallbackToDirectoryOrHome();
169
+ }
170
+ this.options.markDirty();
171
+ return;
172
+ }
173
+
174
+ if (
175
+ this.options.workspace.leftNavSelection.kind === 'project' &&
176
+ !this.options.hasDirectory(this.options.workspace.leftNavSelection.directoryId)
177
+ ) {
178
+ const fallbackDirectoryId = this.options.resolveActiveDirectoryId();
179
+ if (fallbackDirectoryId !== null) {
180
+ this.options.enterProjectPane(fallbackDirectoryId);
181
+ } else {
182
+ this.options.enterHomePane();
183
+ }
184
+ this.options.markDirty();
185
+ return;
186
+ }
187
+
188
+ this.options.markDirty();
189
+ }
190
+ }
@@ -0,0 +1,190 @@
1
+ import type { ControlPlaneKeyEvent } from '../control-plane/codex-session-stream.ts';
2
+ import { buildSelectorIndexEntries } from '../mux/selector-index.ts';
3
+ import {
4
+ compactDebugText,
5
+ conversationSummary,
6
+ type ConversationState,
7
+ } from '../mux/live-mux/conversation-state.ts';
8
+ import { projectWorkspaceRailConversation } from '../mux/workspace-rail-model.ts';
9
+
10
+ type PerfAttrs = Record<string, boolean | number | string>;
11
+
12
+ interface ProcessUsageSample {
13
+ readonly cpuPercent: number | null;
14
+ readonly memoryMb: number | null;
15
+ }
16
+
17
+ interface SelectorIndexDirectory {
18
+ readonly directoryId: string;
19
+ }
20
+
21
+ interface ConversationProjectionSnapshot {
22
+ readonly status: string;
23
+ readonly glyph: string;
24
+ readonly detailText: string;
25
+ }
26
+
27
+ interface SessionProjectionTransition {
28
+ readonly sessionId: string;
29
+ readonly eventType: ControlPlaneKeyEvent['type'];
30
+ readonly cursor: number;
31
+ readonly statusFrom: string;
32
+ readonly statusTo: string;
33
+ readonly glyphFrom: string;
34
+ readonly glyphTo: string;
35
+ readonly detailFrom: string;
36
+ readonly detailTo: string;
37
+ readonly source: string;
38
+ readonly eventName: string;
39
+ readonly summary: string;
40
+ }
41
+
42
+ interface SessionProjectionInstrumentationOptions {
43
+ readonly getProcessUsageSample: (sessionId: string) => ProcessUsageSample | undefined;
44
+ readonly recordPerfEvent: (name: string, attrs: PerfAttrs) => void;
45
+ readonly onTransition?: (transition: SessionProjectionTransition) => void;
46
+ readonly nowMs?: () => number;
47
+ }
48
+
49
+ export class SessionProjectionInstrumentation {
50
+ private readonly selectorIndexBySessionId = new Map<
51
+ string,
52
+ {
53
+ selectorIndex: number;
54
+ directoryIndex: number;
55
+ directoryId: string;
56
+ }
57
+ >();
58
+ private lastSelectorSnapshotHash: string | null = null;
59
+ private selectorSnapshotVersion = 0;
60
+ private readonly nowMs: () => number;
61
+
62
+ constructor(private readonly options: SessionProjectionInstrumentationOptions) {
63
+ this.nowMs = options.nowMs ?? Date.now;
64
+ }
65
+
66
+ snapshotForConversation(conversation: ConversationState): ConversationProjectionSnapshot {
67
+ const projected = projectWorkspaceRailConversation(
68
+ {
69
+ ...conversationSummary(conversation),
70
+ directoryKey: conversation.directoryId ?? 'directory-missing',
71
+ title: conversation.title,
72
+ agentLabel: conversation.agentType,
73
+ cpuPercent: this.options.getProcessUsageSample(conversation.sessionId)?.cpuPercent ?? null,
74
+ memoryMb: this.options.getProcessUsageSample(conversation.sessionId)?.memoryMb ?? null,
75
+ statusModel: conversation.statusModel,
76
+ controller: conversation.controller,
77
+ },
78
+ {
79
+ nowMs: this.nowMs(),
80
+ },
81
+ );
82
+ return {
83
+ status: projected.status,
84
+ glyph: projected.glyph,
85
+ detailText: compactDebugText(projected.detailText),
86
+ };
87
+ }
88
+
89
+ refreshSelectorSnapshot(
90
+ reason: string,
91
+ directories: ReadonlyMap<string, SelectorIndexDirectory>,
92
+ conversations: ReadonlyMap<string, ConversationState>,
93
+ orderedIds: readonly string[],
94
+ ): void {
95
+ const entries = buildSelectorIndexEntries(directories, conversations, orderedIds);
96
+ const hash = entries
97
+ .map(
98
+ (entry) =>
99
+ `${entry.selectorIndex}:${entry.directoryId}:${entry.sessionId}:${entry.directoryIndex}:${entry.title}:${entry.agentType}`,
100
+ )
101
+ .join('|');
102
+ if (hash === this.lastSelectorSnapshotHash) {
103
+ return;
104
+ }
105
+ this.lastSelectorSnapshotHash = hash;
106
+ this.selectorSnapshotVersion += 1;
107
+ this.selectorIndexBySessionId.clear();
108
+ for (const entry of entries) {
109
+ this.selectorIndexBySessionId.set(entry.sessionId, {
110
+ selectorIndex: entry.selectorIndex,
111
+ directoryIndex: entry.directoryIndex,
112
+ directoryId: entry.directoryId,
113
+ });
114
+ }
115
+ this.options.recordPerfEvent('mux.selector.snapshot', {
116
+ reason: compactDebugText(reason),
117
+ version: this.selectorSnapshotVersion,
118
+ count: entries.length,
119
+ });
120
+ for (const entry of entries) {
121
+ this.options.recordPerfEvent('mux.selector.entry', {
122
+ version: this.selectorSnapshotVersion,
123
+ index: entry.selectorIndex,
124
+ directoryIndex: entry.directoryIndex,
125
+ sessionId: entry.sessionId,
126
+ directoryId: entry.directoryId,
127
+ title: compactDebugText(entry.title),
128
+ agentType: entry.agentType,
129
+ });
130
+ }
131
+ }
132
+
133
+ recordTransition(
134
+ event: ControlPlaneKeyEvent,
135
+ before: ConversationProjectionSnapshot | null,
136
+ conversation: ConversationState,
137
+ ): void {
138
+ const after = this.snapshotForConversation(conversation);
139
+ if (this.projectionSnapshotEqual(before, after)) {
140
+ return;
141
+ }
142
+ const selectorEntry = this.selectorIndexBySessionId.get(conversation.sessionId);
143
+ let source = '';
144
+ let eventName = '';
145
+ let summary: string | null = null;
146
+ if (event.type === 'session-telemetry') {
147
+ source = event.keyEvent.source;
148
+ eventName = event.keyEvent.eventName ?? '';
149
+ summary = event.keyEvent.summary;
150
+ } else if (event.type === 'session-status') {
151
+ source = event.telemetry?.source ?? '';
152
+ eventName = event.telemetry?.eventName ?? '';
153
+ summary = event.telemetry?.summary ?? null;
154
+ }
155
+ const transition: SessionProjectionTransition = {
156
+ sessionId: conversation.sessionId,
157
+ eventType: event.type,
158
+ cursor: event.cursor,
159
+ statusFrom: before?.status ?? '',
160
+ statusTo: after.status,
161
+ glyphFrom: before?.glyph ?? '',
162
+ glyphTo: after.glyph,
163
+ detailFrom: before?.detailText ?? '',
164
+ detailTo: after.detailText,
165
+ source,
166
+ eventName,
167
+ summary: compactDebugText(summary),
168
+ };
169
+ this.options.recordPerfEvent('mux.session-projection.transition', {
170
+ ...transition,
171
+ selectorIndex: selectorEntry?.selectorIndex ?? 0,
172
+ directoryIndex: selectorEntry?.directoryIndex ?? 0,
173
+ });
174
+ this.options.onTransition?.(transition);
175
+ }
176
+
177
+ private projectionSnapshotEqual(
178
+ left: ConversationProjectionSnapshot | null,
179
+ right: ConversationProjectionSnapshot,
180
+ ): boolean {
181
+ if (left === null) {
182
+ return false;
183
+ }
184
+ return (
185
+ left.status === right.status &&
186
+ left.glyph === right.glyph &&
187
+ left.detailText === right.detailText
188
+ );
189
+ }
190
+ }
@@ -0,0 +1,91 @@
1
+ type PerfAttrs = Record<string, boolean | number | string>;
2
+
3
+ type UsageRefreshReason = 'startup' | 'interval';
4
+
5
+ type IntervalHandle = ReturnType<typeof setInterval>;
6
+ type TimeoutHandle = ReturnType<typeof setTimeout>;
7
+
8
+ interface StartupBackgroundProbeOptions {
9
+ readonly enabled: boolean;
10
+ readonly maxWaitMs: number;
11
+ readonly isShuttingDown: () => boolean;
12
+ readonly waitForSettled: () => Promise<void>;
13
+ readonly settledObserved: () => boolean;
14
+ readonly refreshProcessUsage: (reason: UsageRefreshReason) => void | Promise<void>;
15
+ readonly recordPerfEvent: (name: string, attrs: PerfAttrs) => void;
16
+ readonly setIntervalFn?: (handler: () => void, ms: number) => IntervalHandle;
17
+ readonly clearIntervalFn?: (handle: IntervalHandle) => void;
18
+ readonly setTimeoutFn?: (handler: () => void, ms: number) => TimeoutHandle;
19
+ readonly clearTimeoutFn?: (handle: TimeoutHandle) => void;
20
+ }
21
+
22
+ export class StartupBackgroundProbeService {
23
+ private readonly setIntervalFn: (handler: () => void, ms: number) => IntervalHandle;
24
+ private readonly clearIntervalFn: (handle: IntervalHandle) => void;
25
+ private readonly setTimeoutFn: (handler: () => void, ms: number) => TimeoutHandle;
26
+ private readonly clearTimeoutFn: (handle: TimeoutHandle) => void;
27
+ private started = false;
28
+ private intervalHandle: IntervalHandle | null = null;
29
+
30
+ constructor(private readonly options: StartupBackgroundProbeOptions) {
31
+ this.setIntervalFn = options.setIntervalFn ?? setInterval;
32
+ this.clearIntervalFn = options.clearIntervalFn ?? clearInterval;
33
+ this.setTimeoutFn = options.setTimeoutFn ?? setTimeout;
34
+ this.clearTimeoutFn = options.clearTimeoutFn ?? clearTimeout;
35
+ }
36
+
37
+ recordWaitPhase(): void {
38
+ this.options.recordPerfEvent('mux.startup.background-probes.wait', {
39
+ maxWaitMs: this.options.maxWaitMs,
40
+ enabled: this.options.enabled ? 1 : 0,
41
+ });
42
+ if (!this.options.enabled) {
43
+ this.options.recordPerfEvent('mux.startup.background-probes.skipped', {
44
+ reason: 'disabled',
45
+ });
46
+ }
47
+ }
48
+
49
+ async startWhenSettled(): Promise<void> {
50
+ if (!this.options.enabled) {
51
+ return;
52
+ }
53
+ let timedOut = false;
54
+ let timeoutHandle: TimeoutHandle | null = null;
55
+ await Promise.race([
56
+ this.options.waitForSettled(),
57
+ new Promise<void>((resolve) => {
58
+ timeoutHandle = this.setTimeoutFn(() => {
59
+ timedOut = true;
60
+ resolve();
61
+ }, this.options.maxWaitMs);
62
+ }),
63
+ ]);
64
+ if (timeoutHandle !== null) {
65
+ this.clearTimeoutFn(timeoutHandle);
66
+ }
67
+ this.maybeStart(timedOut);
68
+ }
69
+
70
+ stop(): void {
71
+ if (this.intervalHandle !== null) {
72
+ this.clearIntervalFn(this.intervalHandle);
73
+ this.intervalHandle = null;
74
+ }
75
+ }
76
+
77
+ private maybeStart(timedOut: boolean): void {
78
+ if (this.options.isShuttingDown() || this.started || !this.options.enabled) {
79
+ return;
80
+ }
81
+ this.started = true;
82
+ this.options.recordPerfEvent('mux.startup.background-probes.begin', {
83
+ timedOut,
84
+ settledObserved: this.options.settledObserved(),
85
+ });
86
+ void this.options.refreshProcessUsage('startup');
87
+ this.intervalHandle = this.setIntervalFn(() => {
88
+ void this.options.refreshProcessUsage('interval');
89
+ }, 1000);
90
+ }
91
+ }
@@ -0,0 +1,65 @@
1
+ type PerfAttrs = Record<string, boolean | number | string>;
2
+ type TimeoutHandle = ReturnType<typeof setTimeout>;
3
+
4
+ interface StartupBackgroundResumeOptions {
5
+ readonly enabled: boolean;
6
+ readonly maxWaitMs: number;
7
+ readonly waitForSettled: () => Promise<void>;
8
+ readonly settledObserved: () => boolean;
9
+ readonly queuePersistedConversationsInBackground: (initialActiveId: string | null) => number;
10
+ readonly recordPerfEvent: (name: string, attrs: PerfAttrs) => void;
11
+ readonly setTimeoutFn?: (handler: () => void, ms: number) => TimeoutHandle;
12
+ readonly clearTimeoutFn?: (handle: TimeoutHandle) => void;
13
+ }
14
+
15
+ export class StartupBackgroundResumeService {
16
+ private readonly setTimeoutFn: (handler: () => void, ms: number) => TimeoutHandle;
17
+ private readonly clearTimeoutFn: (handle: TimeoutHandle) => void;
18
+
19
+ constructor(private readonly options: StartupBackgroundResumeOptions) {
20
+ this.setTimeoutFn = options.setTimeoutFn ?? setTimeout;
21
+ this.clearTimeoutFn = options.clearTimeoutFn ?? clearTimeout;
22
+ }
23
+
24
+ async run(initialActiveId: string | null): Promise<void> {
25
+ const sessionId = initialActiveId ?? 'none';
26
+ this.options.recordPerfEvent('mux.startup.background-start.wait', {
27
+ sessionId,
28
+ maxWaitMs: this.options.maxWaitMs,
29
+ enabled: this.options.enabled ? 1 : 0,
30
+ });
31
+ if (!this.options.enabled) {
32
+ this.options.recordPerfEvent('mux.startup.background-start.skipped', {
33
+ sessionId,
34
+ reason: 'disabled',
35
+ });
36
+ return;
37
+ }
38
+
39
+ let timedOut = false;
40
+ let timeoutHandle: TimeoutHandle | null = null;
41
+ await Promise.race([
42
+ this.options.waitForSettled(),
43
+ new Promise<void>((resolve) => {
44
+ timeoutHandle = this.setTimeoutFn(() => {
45
+ timedOut = true;
46
+ resolve();
47
+ }, this.options.maxWaitMs);
48
+ }),
49
+ ]);
50
+ if (timeoutHandle !== null) {
51
+ this.clearTimeoutFn(timeoutHandle);
52
+ }
53
+
54
+ this.options.recordPerfEvent('mux.startup.background-start.begin', {
55
+ sessionId,
56
+ timedOut,
57
+ settledObserved: this.options.settledObserved(),
58
+ });
59
+ const queued = this.options.queuePersistedConversationsInBackground(initialActiveId);
60
+ this.options.recordPerfEvent('mux.startup.background-start.queued', {
61
+ sessionId,
62
+ queued,
63
+ });
64
+ }
65
+ }
@@ -0,0 +1,166 @@
1
+ import type { ConversationState } from '../mux/live-mux/conversation-state.ts';
2
+ import { StartupSequencer } from '../mux/startup-sequencer.ts';
3
+ import { StartupBackgroundProbeService } from './startup-background-probe.ts';
4
+ import { StartupBackgroundResumeService } from './startup-background-resume.ts';
5
+ import { StartupOutputTracker } from './startup-output-tracker.ts';
6
+ import { StartupPaintTracker } from './startup-paint-tracker.ts';
7
+ import { StartupSettledGate } from './startup-settled-gate.ts';
8
+ import { StartupShutdownService } from './startup-shutdown.ts';
9
+ import { StartupSpanTracker } from './startup-span-tracker.ts';
10
+ import { StartupVisibility } from './startup-visibility.ts';
11
+
12
+ type PerfAttrs = Record<string, boolean | number | string>;
13
+
14
+ interface PerfSpanLike {
15
+ end(attrs?: PerfAttrs): void;
16
+ }
17
+
18
+ interface StartupOrchestratorOptions {
19
+ readonly startupSettleQuietMs: number;
20
+ readonly startupSettleNonemptyFallbackMs: number;
21
+ readonly backgroundWaitMaxMs: number;
22
+ readonly backgroundProbeEnabled: boolean;
23
+ readonly backgroundResumeEnabled: boolean;
24
+ readonly startPerfSpan: (name: string, attrs?: PerfAttrs) => PerfSpanLike;
25
+ readonly startupSpan: PerfSpanLike;
26
+ readonly recordPerfEvent: (name: string, attrs: PerfAttrs) => void;
27
+ readonly getConversation: (sessionId: string) => ConversationState | undefined;
28
+ readonly isShuttingDown: () => boolean;
29
+ readonly refreshProcessUsage: (reason: 'startup' | 'interval') => void | Promise<void>;
30
+ readonly queuePersistedConversationsInBackground: (initialActiveId: string | null) => number;
31
+ readonly hydrateStartupState: (afterCursor: number | null) => Promise<void>;
32
+ readonly activateConversation: (sessionId: string) => Promise<void>;
33
+ readonly conversationCount: () => number;
34
+ }
35
+
36
+ interface StartupRenderFlushInput {
37
+ readonly activeConversation: ConversationState | null;
38
+ readonly activeConversationId: string | null;
39
+ readonly rightFrameVisible: boolean;
40
+ readonly changedRowCount: number;
41
+ }
42
+
43
+ export class StartupOrchestrator {
44
+ private readonly startupSequencer: StartupSequencer;
45
+ private readonly startupSpanTracker: StartupSpanTracker;
46
+ private readonly startupOutputTracker: StartupOutputTracker;
47
+ private readonly startupPaintTracker: StartupPaintTracker;
48
+ private readonly startupBackgroundProbeService: StartupBackgroundProbeService;
49
+ private readonly startupBackgroundResumeService: StartupBackgroundResumeService;
50
+ private readonly startupShutdownService: StartupShutdownService;
51
+
52
+ constructor(private readonly options: StartupOrchestratorOptions) {
53
+ this.startupSequencer = new StartupSequencer({
54
+ quietMs: options.startupSettleQuietMs,
55
+ nonemptyFallbackMs: options.startupSettleNonemptyFallbackMs,
56
+ });
57
+ this.startupSpanTracker = new StartupSpanTracker(
58
+ options.startPerfSpan,
59
+ options.startupSettleQuietMs,
60
+ );
61
+ const startupVisibility = new StartupVisibility();
62
+ const startupSettledGate = new StartupSettledGate({
63
+ startupSequencer: this.startupSequencer,
64
+ startupSpanTracker: this.startupSpanTracker,
65
+ getConversation: options.getConversation,
66
+ visibleGlyphCellCount: (conversation) =>
67
+ startupVisibility.visibleGlyphCellCount(conversation),
68
+ recordPerfEvent: options.recordPerfEvent,
69
+ });
70
+ this.startupOutputTracker = new StartupOutputTracker({
71
+ startupSequencer: this.startupSequencer,
72
+ startupSpanTracker: this.startupSpanTracker,
73
+ recordPerfEvent: options.recordPerfEvent,
74
+ });
75
+ this.startupPaintTracker = new StartupPaintTracker({
76
+ startupSequencer: this.startupSequencer,
77
+ startupSpanTracker: this.startupSpanTracker,
78
+ startupVisibility,
79
+ startupSettledGate,
80
+ recordPerfEvent: options.recordPerfEvent,
81
+ });
82
+ this.startupBackgroundProbeService = new StartupBackgroundProbeService({
83
+ enabled: options.backgroundProbeEnabled,
84
+ maxWaitMs: options.backgroundWaitMaxMs,
85
+ isShuttingDown: options.isShuttingDown,
86
+ waitForSettled: () => this.startupSequencer.waitForSettled(),
87
+ settledObserved: () => this.startupSequencer.snapshot().settledObserved,
88
+ refreshProcessUsage: options.refreshProcessUsage,
89
+ recordPerfEvent: options.recordPerfEvent,
90
+ });
91
+ this.startupBackgroundResumeService = new StartupBackgroundResumeService({
92
+ enabled: options.backgroundResumeEnabled,
93
+ maxWaitMs: options.backgroundWaitMaxMs,
94
+ waitForSettled: () => this.startupSequencer.waitForSettled(),
95
+ settledObserved: () => this.startupSequencer.snapshot().settledObserved,
96
+ queuePersistedConversationsInBackground: options.queuePersistedConversationsInBackground,
97
+ recordPerfEvent: options.recordPerfEvent,
98
+ });
99
+ this.startupShutdownService = new StartupShutdownService({
100
+ startupSequencer: this.startupSequencer,
101
+ startupSpanTracker: this.startupSpanTracker,
102
+ startupSettledGate,
103
+ });
104
+ }
105
+
106
+ get firstPaintTargetSessionId(): string | null {
107
+ return this.startupSpanTracker.firstPaintTargetSessionId;
108
+ }
109
+
110
+ endStartCommandSpan(attrs: PerfAttrs): void {
111
+ this.startupSpanTracker.endStartCommandSpan(attrs);
112
+ }
113
+
114
+ onOutputChunk(sessionId: string, bytes: number): void {
115
+ this.startupOutputTracker.onOutputChunk(sessionId, bytes);
116
+ }
117
+
118
+ onPaintOutputChunk(sessionId: string): void {
119
+ this.startupPaintTracker.onOutputChunk(sessionId);
120
+ }
121
+
122
+ onRenderFlush(input: StartupRenderFlushInput): void {
123
+ this.startupPaintTracker.onRenderFlush(input);
124
+ }
125
+
126
+ startBackgroundProbe(): void {
127
+ this.startupBackgroundProbeService.recordWaitPhase();
128
+ void this.startupBackgroundProbeService.startWhenSettled();
129
+ }
130
+
131
+ stop(): void {
132
+ this.startupBackgroundProbeService.stop();
133
+ }
134
+
135
+ async hydrateStartupState(afterCursor: number | null): Promise<void> {
136
+ await this.options.hydrateStartupState(afterCursor);
137
+ }
138
+
139
+ async activateInitialConversation(initialActiveId: string | null): Promise<void> {
140
+ this.startupSequencer.setTargetSession(initialActiveId);
141
+ if (initialActiveId === null) {
142
+ return;
143
+ }
144
+ this.startupSpanTracker.beginForSession(initialActiveId);
145
+ const initialActivateSpan = this.options.startPerfSpan('mux.startup.activate-initial', {
146
+ initialActiveId,
147
+ });
148
+ await this.options.activateConversation(initialActiveId);
149
+ initialActivateSpan.end();
150
+ }
151
+
152
+ finalizeStartup(initialActiveId: string | null): void {
153
+ const conversationCount = this.options.conversationCount();
154
+ this.options.startupSpan.end({
155
+ conversations: conversationCount,
156
+ });
157
+ this.options.recordPerfEvent('mux.startup.ready', {
158
+ conversations: conversationCount,
159
+ });
160
+ void this.startupBackgroundResumeService.run(initialActiveId);
161
+ }
162
+
163
+ finalize(): void {
164
+ this.startupShutdownService.finalize();
165
+ }
166
+ }