@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,176 @@
1
+ import {
2
+ ConversationStartupHydrationService,
3
+ type ConversationStartupHydrationServiceOptions,
4
+ type SessionSummaryLike,
5
+ } from './conversation-startup-hydration.ts';
6
+ import {
7
+ RuntimeConversationStarter,
8
+ type RuntimeConversationStarterConversationRecord,
9
+ type RuntimeConversationStarterOptions,
10
+ } from './runtime-conversation-starter.ts';
11
+ import {
12
+ RuntimeConversationActivation,
13
+ type RuntimeConversationActivationOptions,
14
+ } from './runtime-conversation-activation.ts';
15
+ import {
16
+ RuntimeConversationActions,
17
+ type RuntimeConversationActionsOptions,
18
+ } from './runtime-conversation-actions.ts';
19
+ import {
20
+ RuntimeConversationTitleEditService,
21
+ type RuntimeConversationTitleEditServiceOptions,
22
+ } from './runtime-conversation-title-edit.ts';
23
+ import {
24
+ RuntimeStreamSubscriptions,
25
+ type RuntimeStreamSubscriptionsOptions,
26
+ } from './runtime-stream-subscriptions.ts';
27
+ import {
28
+ StartupPersistedConversationQueueService,
29
+ type StartupPersistedConversationQueueServiceOptions,
30
+ type StartupQueueConversationRecord,
31
+ } from './startup-persisted-conversation-queue.ts';
32
+
33
+ interface ConversationLifecycleOptions<
34
+ TConversation extends RuntimeConversationStarterConversationRecord &
35
+ StartupQueueConversationRecord & { title: string },
36
+ TSessionSummary extends SessionSummaryLike,
37
+ TControllerRecord,
38
+ > {
39
+ readonly streamSubscriptions: RuntimeStreamSubscriptionsOptions;
40
+ readonly starter: Omit<
41
+ RuntimeConversationStarterOptions<TConversation, TSessionSummary>,
42
+ 'subscribeConversationEvents'
43
+ >;
44
+ readonly startupHydration: Omit<
45
+ ConversationStartupHydrationServiceOptions<TSessionSummary>,
46
+ 'subscribeConversationEvents'
47
+ >;
48
+ readonly startupQueue: Omit<
49
+ StartupPersistedConversationQueueServiceOptions<TConversation>,
50
+ 'startConversation'
51
+ >;
52
+ readonly activation: Omit<RuntimeConversationActivationOptions, 'startConversation'>;
53
+ readonly actions: Omit<
54
+ RuntimeConversationActionsOptions<TControllerRecord>,
55
+ 'startConversation' | 'activateConversation'
56
+ >;
57
+ readonly titleEdit: RuntimeConversationTitleEditServiceOptions<TConversation>;
58
+ }
59
+
60
+ export class ConversationLifecycle<
61
+ TConversation extends RuntimeConversationStarterConversationRecord &
62
+ StartupQueueConversationRecord & { title: string },
63
+ TSessionSummary extends SessionSummaryLike,
64
+ TControllerRecord,
65
+ > {
66
+ private readonly streamSubscriptions: RuntimeStreamSubscriptions;
67
+ private readonly starter: RuntimeConversationStarter<TConversation, TSessionSummary>;
68
+ private readonly startupHydration: ConversationStartupHydrationService<TSessionSummary>;
69
+ private readonly startupQueue: StartupPersistedConversationQueueService<TConversation>;
70
+ private readonly activation: RuntimeConversationActivation;
71
+ private readonly actions: RuntimeConversationActions<TControllerRecord>;
72
+ private readonly titleEdit: RuntimeConversationTitleEditService<TConversation>;
73
+
74
+ constructor(
75
+ options: ConversationLifecycleOptions<TConversation, TSessionSummary, TControllerRecord>,
76
+ ) {
77
+ this.streamSubscriptions = new RuntimeStreamSubscriptions(options.streamSubscriptions);
78
+ this.starter = new RuntimeConversationStarter({
79
+ ...options.starter,
80
+ subscribeConversationEvents: async (sessionId) => {
81
+ await this.subscribeConversationEvents(sessionId);
82
+ },
83
+ });
84
+ this.startupHydration = new ConversationStartupHydrationService({
85
+ ...options.startupHydration,
86
+ subscribeConversationEvents: async (sessionId) => {
87
+ await this.subscribeConversationEvents(sessionId);
88
+ },
89
+ });
90
+ this.startupQueue = new StartupPersistedConversationQueueService({
91
+ ...options.startupQueue,
92
+ startConversation: async (sessionId) => {
93
+ await this.startConversation(sessionId);
94
+ },
95
+ });
96
+ this.activation = new RuntimeConversationActivation({
97
+ ...options.activation,
98
+ startConversation: async (sessionId) => {
99
+ await this.startConversation(sessionId);
100
+ },
101
+ });
102
+ this.actions = new RuntimeConversationActions({
103
+ ...options.actions,
104
+ startConversation: async (sessionId) => {
105
+ await this.startConversation(sessionId);
106
+ },
107
+ activateConversation: async (sessionId) => {
108
+ await this.activateConversation(sessionId);
109
+ },
110
+ });
111
+ this.titleEdit = new RuntimeConversationTitleEditService(options.titleEdit);
112
+ }
113
+
114
+ async subscribeConversationEvents(sessionId: string): Promise<void> {
115
+ await this.streamSubscriptions.subscribeConversationEvents(sessionId);
116
+ }
117
+
118
+ async unsubscribeConversationEvents(sessionId: string): Promise<void> {
119
+ await this.streamSubscriptions.unsubscribeConversationEvents(sessionId);
120
+ }
121
+
122
+ async subscribeTaskPlanningEvents(afterCursor: number | null): Promise<void> {
123
+ await this.streamSubscriptions.subscribeTaskPlanningEvents(afterCursor);
124
+ }
125
+
126
+ async unsubscribeTaskPlanningEvents(): Promise<void> {
127
+ await this.streamSubscriptions.unsubscribeTaskPlanningEvents();
128
+ }
129
+
130
+ async startConversation(sessionId: string): Promise<TConversation> {
131
+ return await this.starter.startConversation(sessionId);
132
+ }
133
+
134
+ async activateConversation(sessionId: string): Promise<void> {
135
+ await this.activation.activateConversation(sessionId);
136
+ }
137
+
138
+ async createAndActivateConversationInDirectory(
139
+ directoryId: string,
140
+ agentType: string,
141
+ ): Promise<void> {
142
+ await this.actions.createAndActivateConversationInDirectory(directoryId, agentType);
143
+ }
144
+
145
+ async openOrCreateCritiqueConversationInDirectory(directoryId: string): Promise<void> {
146
+ await this.actions.openOrCreateCritiqueConversationInDirectory(directoryId);
147
+ }
148
+
149
+ async takeoverConversation(sessionId: string): Promise<void> {
150
+ await this.actions.takeoverConversation(sessionId);
151
+ }
152
+
153
+ scheduleConversationTitlePersist(): void {
154
+ this.titleEdit.schedulePersist();
155
+ }
156
+
157
+ stopConversationTitleEdit(persistPending: boolean): void {
158
+ this.titleEdit.stop(persistPending);
159
+ }
160
+
161
+ beginConversationTitleEdit(conversationId: string): void {
162
+ this.titleEdit.begin(conversationId);
163
+ }
164
+
165
+ clearConversationTitleEditTimer(): void {
166
+ this.titleEdit.clearCurrentTimer();
167
+ }
168
+
169
+ async hydrateConversationList(): Promise<void> {
170
+ await this.startupHydration.hydrateConversationList();
171
+ }
172
+
173
+ queuePersistedConversationsInBackground(activeSessionId: string | null): number {
174
+ return this.startupQueue.queuePersistedConversationsInBackground(activeSessionId);
175
+ }
176
+ }
@@ -0,0 +1,47 @@
1
+ export interface SessionSummaryLike {
2
+ readonly sessionId: string;
3
+ readonly live: boolean;
4
+ }
5
+
6
+ interface PerfSpanLike {
7
+ end(input?: Record<string, unknown>): void;
8
+ }
9
+
10
+ export interface ConversationStartupHydrationServiceOptions<
11
+ TSessionSummary extends SessionSummaryLike,
12
+ > {
13
+ readonly startHydrationSpan: () => PerfSpanLike;
14
+ readonly hydrateDirectoryList: () => Promise<void>;
15
+ readonly directoryIds: () => readonly string[];
16
+ readonly hydratePersistedConversationsForDirectory: (directoryId: string) => Promise<number>;
17
+ readonly listSessions: () => Promise<readonly TSessionSummary[]>;
18
+ readonly upsertFromSessionSummary: (summary: TSessionSummary) => void;
19
+ readonly subscribeConversationEvents: (sessionId: string) => Promise<void>;
20
+ }
21
+
22
+ export class ConversationStartupHydrationService<TSessionSummary extends SessionSummaryLike> {
23
+ constructor(
24
+ private readonly options: ConversationStartupHydrationServiceOptions<TSessionSummary>,
25
+ ) {}
26
+
27
+ async hydrateConversationList(): Promise<void> {
28
+ const hydrateSpan = this.options.startHydrationSpan();
29
+ await this.options.hydrateDirectoryList();
30
+ let persistedCount = 0;
31
+ for (const directoryId of this.options.directoryIds()) {
32
+ persistedCount += await this.options.hydratePersistedConversationsForDirectory(directoryId);
33
+ }
34
+
35
+ const summaries = await this.options.listSessions();
36
+ for (const summary of summaries) {
37
+ this.options.upsertFromSessionSummary(summary);
38
+ if (summary.live) {
39
+ await this.options.subscribeConversationEvents(summary.sessionId);
40
+ }
41
+ }
42
+ hydrateSpan.end({
43
+ persisted: persistedCount,
44
+ live: summaries.length,
45
+ });
46
+ }
47
+ }
@@ -0,0 +1,49 @@
1
+ interface DirectoryRecordLike {
2
+ readonly directoryId: string;
3
+ readonly path: string;
4
+ }
5
+
6
+ interface DirectoryHydrationControlPlane<TDirectoryRecord extends DirectoryRecordLike> {
7
+ listDirectories(): Promise<readonly TDirectoryRecord[]>;
8
+ upsertDirectory(input: { directoryId: string; path: string }): Promise<TDirectoryRecord>;
9
+ }
10
+
11
+ interface DirectoryHydrationServiceOptions<TDirectoryRecord extends DirectoryRecordLike> {
12
+ readonly controlPlaneService: DirectoryHydrationControlPlane<TDirectoryRecord>;
13
+ readonly resolveWorkspacePathForMux: (rawPath: string) => string;
14
+ readonly clearDirectories: () => void;
15
+ readonly setDirectory: (directoryId: string, directory: TDirectoryRecord) => void;
16
+ readonly hasDirectory: (directoryId: string) => boolean;
17
+ readonly persistedDirectory: TDirectoryRecord;
18
+ readonly resolveActiveDirectoryId: () => string | null;
19
+ }
20
+
21
+ export class DirectoryHydrationService<TDirectoryRecord extends DirectoryRecordLike> {
22
+ constructor(private readonly options: DirectoryHydrationServiceOptions<TDirectoryRecord>) {}
23
+
24
+ async hydrate(): Promise<void> {
25
+ const rows = await this.options.controlPlaneService.listDirectories();
26
+ this.options.clearDirectories();
27
+ for (const row of rows) {
28
+ const normalizedPath = this.options.resolveWorkspacePathForMux(row.path);
29
+ if (normalizedPath !== row.path) {
30
+ const repairedRecord = await this.options.controlPlaneService.upsertDirectory({
31
+ directoryId: row.directoryId,
32
+ path: normalizedPath,
33
+ });
34
+ this.options.setDirectory(row.directoryId, repairedRecord);
35
+ continue;
36
+ }
37
+ this.options.setDirectory(row.directoryId, row);
38
+ }
39
+ if (!this.options.hasDirectory(this.options.persistedDirectory.directoryId)) {
40
+ this.options.setDirectory(
41
+ this.options.persistedDirectory.directoryId,
42
+ this.options.persistedDirectory,
43
+ );
44
+ }
45
+ if (this.options.resolveActiveDirectoryId() === null) {
46
+ throw new Error('no active directory available after hydrate');
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,104 @@
1
+ import type { NormalizedEventEnvelope } from '../events/normalized-events.ts';
2
+
3
+ type PerfAttrs = Record<string, boolean | number | string>;
4
+
5
+ type FlushReason = 'timer' | 'immediate' | 'shutdown';
6
+
7
+ interface PerfSpanLike {
8
+ end(attrs: PerfAttrs): void;
9
+ }
10
+
11
+ type StartPerfSpanLike = (name: string, attrs: PerfAttrs) => PerfSpanLike;
12
+
13
+ interface EventPersistenceOptions {
14
+ readonly appendEvents: (events: readonly NormalizedEventEnvelope[]) => void;
15
+ readonly startPerfSpan: StartPerfSpanLike;
16
+ readonly writeStderr: (text: string) => void;
17
+ readonly flushDelayMs?: number;
18
+ readonly flushMaxBatch?: number;
19
+ readonly setTimeoutFn?: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
20
+ readonly clearTimeoutFn?: (timer: ReturnType<typeof setTimeout>) => void;
21
+ }
22
+
23
+ const DEFAULT_FLUSH_DELAY_MS = 12;
24
+ const DEFAULT_FLUSH_MAX_BATCH = 64;
25
+
26
+ export class EventPersistence {
27
+ private readonly flushDelayMs: number;
28
+ private readonly flushMaxBatch: number;
29
+ private readonly setTimeoutFn: (
30
+ callback: () => void,
31
+ delayMs: number,
32
+ ) => ReturnType<typeof setTimeout>;
33
+ private readonly clearTimeoutFn: (timer: ReturnType<typeof setTimeout>) => void;
34
+ private pendingEvents: NormalizedEventEnvelope[] = [];
35
+ private flushTimer: ReturnType<typeof setTimeout> | null = null;
36
+
37
+ constructor(private readonly options: EventPersistenceOptions) {
38
+ this.flushDelayMs = options.flushDelayMs ?? DEFAULT_FLUSH_DELAY_MS;
39
+ this.flushMaxBatch = options.flushMaxBatch ?? DEFAULT_FLUSH_MAX_BATCH;
40
+ this.setTimeoutFn = options.setTimeoutFn ?? setTimeout;
41
+ this.clearTimeoutFn = options.clearTimeoutFn ?? clearTimeout;
42
+ }
43
+
44
+ pendingCount(): number {
45
+ return this.pendingEvents.length;
46
+ }
47
+
48
+ enqueue(event: NormalizedEventEnvelope): void {
49
+ this.pendingEvents.push(event);
50
+ if (this.pendingEvents.length >= this.flushMaxBatch) {
51
+ this.flush('immediate');
52
+ return;
53
+ }
54
+ this.scheduleFlush();
55
+ }
56
+
57
+ flush(reason: FlushReason): void {
58
+ this.clearScheduledFlush();
59
+ if (this.pendingEvents.length === 0) {
60
+ return;
61
+ }
62
+ const batch = this.pendingEvents;
63
+ this.pendingEvents = [];
64
+ const flushSpan = this.options.startPerfSpan('mux.events.flush', {
65
+ reason,
66
+ count: batch.length,
67
+ });
68
+ try {
69
+ this.options.appendEvents(batch);
70
+ flushSpan.end({
71
+ reason,
72
+ status: 'ok',
73
+ count: batch.length,
74
+ });
75
+ } catch (error: unknown) {
76
+ const message = error instanceof Error ? error.message : String(error);
77
+ flushSpan.end({
78
+ reason,
79
+ status: 'error',
80
+ count: batch.length,
81
+ message,
82
+ });
83
+ this.options.writeStderr(`[mux] event-store error ${message}\n`);
84
+ }
85
+ }
86
+
87
+ private scheduleFlush(): void {
88
+ if (this.flushTimer !== null) {
89
+ return;
90
+ }
91
+ this.flushTimer = this.setTimeoutFn(() => {
92
+ this.flushTimer = null;
93
+ this.flush('timer');
94
+ }, this.flushDelayMs);
95
+ }
96
+
97
+ private clearScheduledFlush(): void {
98
+ if (this.flushTimer === null) {
99
+ return;
100
+ }
101
+ this.clearTimeoutFn(this.flushTimer);
102
+ this.flushTimer = null;
103
+ }
104
+ }
@@ -0,0 +1,82 @@
1
+ export interface MuxUiStateSnapshot {
2
+ paneWidthPercent: number;
3
+ repositoriesCollapsed: boolean;
4
+ shortcutsCollapsed: boolean;
5
+ }
6
+
7
+ interface MuxUiStatePersistenceOptions {
8
+ readonly enabled: boolean;
9
+ readonly initialState: MuxUiStateSnapshot;
10
+ readonly debounceMs: number;
11
+ readonly persistState: (pending: MuxUiStateSnapshot) => MuxUiStateSnapshot;
12
+ readonly applyState: (state: MuxUiStateSnapshot) => void;
13
+ readonly writeStderr: (text: string) => void;
14
+ readonly setTimeoutFn?: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
15
+ readonly clearTimeoutFn?: (timer: ReturnType<typeof setTimeout>) => void;
16
+ }
17
+
18
+ export class MuxUiStatePersistence {
19
+ private persistedState: MuxUiStateSnapshot;
20
+ private pendingState: MuxUiStateSnapshot | null = null;
21
+ private persistTimer: ReturnType<typeof setTimeout> | null = null;
22
+ private readonly setTimeoutFn: (
23
+ callback: () => void,
24
+ delayMs: number,
25
+ ) => ReturnType<typeof setTimeout>;
26
+ private readonly clearTimeoutFn: (timer: ReturnType<typeof setTimeout>) => void;
27
+
28
+ constructor(private readonly options: MuxUiStatePersistenceOptions) {
29
+ this.persistedState = options.initialState;
30
+ this.setTimeoutFn = options.setTimeoutFn ?? setTimeout;
31
+ this.clearTimeoutFn = options.clearTimeoutFn ?? clearTimeout;
32
+ }
33
+
34
+ queue(nextState: MuxUiStateSnapshot): void {
35
+ if (!this.options.enabled) {
36
+ return;
37
+ }
38
+ this.pendingState = nextState;
39
+ if (this.persistTimer !== null) {
40
+ this.clearTimeoutFn(this.persistTimer);
41
+ }
42
+ this.persistTimer = this.setTimeoutFn(() => {
43
+ this.persistTimer = null;
44
+ this.persistNow();
45
+ }, this.options.debounceMs);
46
+ this.persistTimer.unref?.();
47
+ }
48
+
49
+ persistNow(): void {
50
+ if (!this.options.enabled) {
51
+ return;
52
+ }
53
+ if (this.persistTimer !== null) {
54
+ this.clearTimeoutFn(this.persistTimer);
55
+ this.persistTimer = null;
56
+ }
57
+ const pending = this.pendingState;
58
+ if (pending === null) {
59
+ return;
60
+ }
61
+ this.pendingState = null;
62
+ if (this.stateEqual(pending, this.persistedState)) {
63
+ return;
64
+ }
65
+ try {
66
+ const updated = this.options.persistState(pending);
67
+ this.persistedState = updated;
68
+ this.options.applyState(updated);
69
+ } catch (error: unknown) {
70
+ const message = error instanceof Error ? error.message : String(error);
71
+ this.options.writeStderr(`[config] unable to persist mux ui state: ${message}\n`);
72
+ }
73
+ }
74
+
75
+ private stateEqual(left: MuxUiStateSnapshot, right: MuxUiStateSnapshot): boolean {
76
+ return (
77
+ left.paneWidthPercent === right.paneWidthPercent &&
78
+ left.repositoriesCollapsed === right.repositoriesCollapsed &&
79
+ left.shortcutsCollapsed === right.shortcutsCollapsed
80
+ );
81
+ }
82
+ }
@@ -0,0 +1,231 @@
1
+ import { monitorEventLoopDelay } from 'node:perf_hooks';
2
+
3
+ type PerfAttrs = Record<string, boolean | number | string>;
4
+
5
+ interface EventLoopDelayMonitorLike {
6
+ enable(): void;
7
+ disable(): void;
8
+ reset(): void;
9
+ percentile(percentile: number): number;
10
+ readonly max: number;
11
+ }
12
+
13
+ interface ControlPlaneQueueMetrics {
14
+ readonly interactiveQueued: number;
15
+ readonly backgroundQueued: number;
16
+ readonly running: boolean;
17
+ }
18
+
19
+ interface OutputLoadPerfStatusRow {
20
+ readonly fps: number;
21
+ readonly kbPerSecond: number;
22
+ readonly renderAvgMs: number;
23
+ readonly renderMaxMs: number;
24
+ readonly outputHandleAvgMs: number;
25
+ readonly outputHandleMaxMs: number;
26
+ readonly eventLoopP95Ms: number;
27
+ }
28
+
29
+ interface OutputLoadSamplerOptions {
30
+ readonly recordPerfEvent: (name: string, attrs: PerfAttrs) => void;
31
+ readonly getControlPlaneQueueMetrics: () => ControlPlaneQueueMetrics;
32
+ readonly getActiveConversationId: () => string | null;
33
+ readonly getPendingPersistedEvents: () => number;
34
+ readonly onStatusRowChanged: () => void;
35
+ readonly nowMs?: () => number;
36
+ readonly sampleIntervalMs?: number;
37
+ readonly setIntervalFn?: (
38
+ callback: () => void,
39
+ delayMs: number,
40
+ ) => ReturnType<typeof setInterval>;
41
+ readonly clearIntervalFn?: (timer: ReturnType<typeof setInterval>) => void;
42
+ readonly createEventLoopDelayMonitor?: () => EventLoopDelayMonitorLike;
43
+ }
44
+
45
+ const DEFAULT_SAMPLE_INTERVAL_MS = 1000;
46
+
47
+ function defaultEventLoopDelayMonitor(): EventLoopDelayMonitorLike {
48
+ return monitorEventLoopDelay({
49
+ resolution: 20,
50
+ });
51
+ }
52
+
53
+ export class OutputLoadSampler {
54
+ private readonly nowMs: () => number;
55
+ private readonly sampleIntervalMs: number;
56
+ private readonly setIntervalFn: (
57
+ callback: () => void,
58
+ delayMs: number,
59
+ ) => ReturnType<typeof setInterval>;
60
+ private readonly clearIntervalFn: (timer: ReturnType<typeof setInterval>) => void;
61
+ private readonly eventLoopDelayMonitor: EventLoopDelayMonitorLike;
62
+ private sampleTimer: ReturnType<typeof setInterval> | null = null;
63
+ private windowStartedAtMs: number;
64
+ private outputActiveBytes = 0;
65
+ private outputInactiveBytes = 0;
66
+ private outputActiveChunks = 0;
67
+ private outputInactiveChunks = 0;
68
+ private outputHandleSampleCount = 0;
69
+ private outputHandleSampleTotalMs = 0;
70
+ private outputHandleSampleMaxMs = 0;
71
+ private renderSampleCount = 0;
72
+ private renderSampleTotalMs = 0;
73
+ private renderSampleMaxMs = 0;
74
+ private renderSampleChangedRows = 0;
75
+ private readonly outputSessionIds = new Set<string>();
76
+ private statusRow: OutputLoadPerfStatusRow = {
77
+ fps: 0,
78
+ kbPerSecond: 0,
79
+ renderAvgMs: 0,
80
+ renderMaxMs: 0,
81
+ outputHandleAvgMs: 0,
82
+ outputHandleMaxMs: 0,
83
+ eventLoopP95Ms: 0,
84
+ };
85
+
86
+ constructor(private readonly options: OutputLoadSamplerOptions) {
87
+ this.nowMs = options.nowMs ?? Date.now;
88
+ this.sampleIntervalMs = options.sampleIntervalMs ?? DEFAULT_SAMPLE_INTERVAL_MS;
89
+ this.setIntervalFn = options.setIntervalFn ?? setInterval;
90
+ this.clearIntervalFn = options.clearIntervalFn ?? clearInterval;
91
+ this.eventLoopDelayMonitor =
92
+ options.createEventLoopDelayMonitor?.() ?? defaultEventLoopDelayMonitor();
93
+ this.windowStartedAtMs = this.nowMs();
94
+ }
95
+
96
+ currentStatusRow(): OutputLoadPerfStatusRow {
97
+ return this.statusRow;
98
+ }
99
+
100
+ start(): void {
101
+ if (this.sampleTimer !== null) {
102
+ return;
103
+ }
104
+ this.eventLoopDelayMonitor.enable();
105
+ this.sampleTimer = this.setIntervalFn(() => {
106
+ this.sampleNow();
107
+ }, this.sampleIntervalMs);
108
+ }
109
+
110
+ stop(): void {
111
+ if (this.sampleTimer === null) {
112
+ return;
113
+ }
114
+ this.clearIntervalFn(this.sampleTimer);
115
+ this.sampleTimer = null;
116
+ this.eventLoopDelayMonitor.disable();
117
+ }
118
+
119
+ recordOutputChunk(sessionId: string, bytes: number, activeConversation: boolean): void {
120
+ this.outputSessionIds.add(sessionId);
121
+ if (activeConversation) {
122
+ this.outputActiveBytes += bytes;
123
+ this.outputActiveChunks += 1;
124
+ return;
125
+ }
126
+ this.outputInactiveBytes += bytes;
127
+ this.outputInactiveChunks += 1;
128
+ }
129
+
130
+ recordOutputHandled(durationMs: number): void {
131
+ this.outputHandleSampleCount += 1;
132
+ this.outputHandleSampleTotalMs += durationMs;
133
+ if (durationMs > this.outputHandleSampleMaxMs) {
134
+ this.outputHandleSampleMaxMs = durationMs;
135
+ }
136
+ }
137
+
138
+ recordRenderSample(durationMs: number, changedRows: number): void {
139
+ this.renderSampleCount += 1;
140
+ this.renderSampleTotalMs += durationMs;
141
+ if (durationMs > this.renderSampleMaxMs) {
142
+ this.renderSampleMaxMs = durationMs;
143
+ }
144
+ this.renderSampleChangedRows += changedRows;
145
+ }
146
+
147
+ sampleNow(): void {
148
+ const totalChunks = this.outputActiveChunks + this.outputInactiveChunks;
149
+ const hasRenderSamples = this.renderSampleCount > 0;
150
+ const nowMs = this.nowMs();
151
+ const windowMs = Math.max(1, nowMs - this.windowStartedAtMs);
152
+ const eventLoopP95Ms = Number(this.eventLoopDelayMonitor.percentile(95)) / 1e6;
153
+ const eventLoopMaxMs = Number(this.eventLoopDelayMonitor.max) / 1e6;
154
+ const outputHandleAvgMs =
155
+ this.outputHandleSampleCount === 0
156
+ ? 0
157
+ : this.outputHandleSampleTotalMs / this.outputHandleSampleCount;
158
+ const renderAvgMs =
159
+ this.renderSampleCount === 0 ? 0 : this.renderSampleTotalMs / this.renderSampleCount;
160
+ const nextStatusRow: OutputLoadPerfStatusRow = {
161
+ fps: Number(((this.renderSampleCount * 1000) / windowMs).toFixed(1)),
162
+ kbPerSecond: Number(
163
+ (((this.outputActiveBytes + this.outputInactiveBytes) * 1000) / windowMs / 1024).toFixed(1),
164
+ ),
165
+ renderAvgMs: Number(renderAvgMs.toFixed(2)),
166
+ renderMaxMs: Number(this.renderSampleMaxMs.toFixed(2)),
167
+ outputHandleAvgMs: Number(outputHandleAvgMs.toFixed(2)),
168
+ outputHandleMaxMs: Number(this.outputHandleSampleMaxMs.toFixed(2)),
169
+ eventLoopP95Ms: Number(eventLoopP95Ms.toFixed(1)),
170
+ };
171
+ if (!this.statusRowEqual(this.statusRow, nextStatusRow)) {
172
+ this.statusRow = nextStatusRow;
173
+ this.options.onStatusRowChanged();
174
+ }
175
+ if (totalChunks > 0 || hasRenderSamples) {
176
+ const controlPlaneQueueMetrics = this.options.getControlPlaneQueueMetrics();
177
+ this.options.recordPerfEvent('mux.output-load.sample', {
178
+ windowMs,
179
+ activeChunks: this.outputActiveChunks,
180
+ inactiveChunks: this.outputInactiveChunks,
181
+ activeBytes: this.outputActiveBytes,
182
+ inactiveBytes: this.outputInactiveBytes,
183
+ outputHandleCount: this.outputHandleSampleCount,
184
+ outputHandleAvgMs: Number(outputHandleAvgMs.toFixed(3)),
185
+ outputHandleMaxMs: Number(this.outputHandleSampleMaxMs.toFixed(3)),
186
+ renderCount: this.renderSampleCount,
187
+ renderAvgMs: Number(renderAvgMs.toFixed(3)),
188
+ renderMaxMs: Number(this.renderSampleMaxMs.toFixed(3)),
189
+ renderChangedRows: this.renderSampleChangedRows,
190
+ eventLoopP95Ms: Number(eventLoopP95Ms.toFixed(3)),
191
+ eventLoopMaxMs: Number(eventLoopMaxMs.toFixed(3)),
192
+ activeConversationId: this.options.getActiveConversationId() ?? 'none',
193
+ sessionsWithOutput: this.outputSessionIds.size,
194
+ pendingPersistedEvents: this.options.getPendingPersistedEvents(),
195
+ interactiveQueued: controlPlaneQueueMetrics.interactiveQueued,
196
+ backgroundQueued: controlPlaneQueueMetrics.backgroundQueued,
197
+ controlPlaneOpRunning: controlPlaneQueueMetrics.running ? 1 : 0,
198
+ });
199
+ }
200
+ this.resetWindow(nowMs);
201
+ }
202
+
203
+ private resetWindow(nowMs: number): void {
204
+ this.windowStartedAtMs = nowMs;
205
+ this.outputActiveBytes = 0;
206
+ this.outputInactiveBytes = 0;
207
+ this.outputActiveChunks = 0;
208
+ this.outputInactiveChunks = 0;
209
+ this.outputHandleSampleCount = 0;
210
+ this.outputHandleSampleTotalMs = 0;
211
+ this.outputHandleSampleMaxMs = 0;
212
+ this.renderSampleCount = 0;
213
+ this.renderSampleTotalMs = 0;
214
+ this.renderSampleMaxMs = 0;
215
+ this.renderSampleChangedRows = 0;
216
+ this.outputSessionIds.clear();
217
+ this.eventLoopDelayMonitor.reset();
218
+ }
219
+
220
+ private statusRowEqual(left: OutputLoadPerfStatusRow, right: OutputLoadPerfStatusRow): boolean {
221
+ return (
222
+ left.fps === right.fps &&
223
+ left.kbPerSecond === right.kbPerSecond &&
224
+ left.renderAvgMs === right.renderAvgMs &&
225
+ left.renderMaxMs === right.renderMaxMs &&
226
+ left.outputHandleAvgMs === right.outputHandleAvgMs &&
227
+ left.outputHandleMaxMs === right.outputHandleMaxMs &&
228
+ left.eventLoopP95Ms === right.eventLoopP95Ms
229
+ );
230
+ }
231
+ }