@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,54 @@
1
+ type PerfAttrs = Record<string, boolean | number | string>;
2
+
3
+ interface StartupSequencerLike {
4
+ snapshot(): {
5
+ firstOutputObserved: boolean;
6
+ };
7
+ markFirstOutput(sessionId: string): boolean;
8
+ }
9
+
10
+ interface StartupSpanTrackerLike {
11
+ readonly firstPaintTargetSessionId: string | null;
12
+ endFirstOutputSpan(attrs: PerfAttrs): void;
13
+ }
14
+
15
+ interface StartupOutputTrackerOptions {
16
+ readonly startupSequencer: StartupSequencerLike;
17
+ readonly startupSpanTracker: StartupSpanTrackerLike;
18
+ readonly recordPerfEvent: (name: string, attrs: PerfAttrs) => void;
19
+ }
20
+
21
+ export class StartupOutputTracker {
22
+ private readonly sessionFirstOutputObserved = new Set<string>();
23
+
24
+ constructor(private readonly options: StartupOutputTrackerOptions) {}
25
+
26
+ onOutputChunk(sessionId: string, bytes: number): void {
27
+ if (!this.sessionFirstOutputObserved.has(sessionId)) {
28
+ this.sessionFirstOutputObserved.add(sessionId);
29
+ this.options.recordPerfEvent('mux.session.first-output', {
30
+ sessionId,
31
+ bytes,
32
+ });
33
+ }
34
+
35
+ const targetSessionId = this.options.startupSpanTracker.firstPaintTargetSessionId;
36
+ if (targetSessionId === null || sessionId !== targetSessionId) {
37
+ return;
38
+ }
39
+ if (this.options.startupSequencer.snapshot().firstOutputObserved) {
40
+ return;
41
+ }
42
+ if (!this.options.startupSequencer.markFirstOutput(sessionId)) {
43
+ return;
44
+ }
45
+ this.options.recordPerfEvent('mux.startup.active-first-output', {
46
+ sessionId,
47
+ bytes,
48
+ });
49
+ this.options.startupSpanTracker.endFirstOutputSpan({
50
+ observed: true,
51
+ bytes,
52
+ });
53
+ }
54
+ }
@@ -0,0 +1,115 @@
1
+ import type { ConversationState } from '../mux/live-mux/conversation-state.ts';
2
+
3
+ type PerfAttrs = Record<string, boolean | number | string>;
4
+
5
+ interface StartupSequencerLike {
6
+ snapshot(): {
7
+ firstOutputObserved: boolean;
8
+ firstPaintObserved: boolean;
9
+ };
10
+ markFirstPaintVisible(sessionId: string, glyphCells: number): boolean;
11
+ markHeaderVisible(sessionId: string, visible: boolean): boolean;
12
+ maybeSelectSettleGate(sessionId: string, glyphCells: number): string | null;
13
+ }
14
+
15
+ interface StartupSpanTrackerLike {
16
+ readonly firstPaintTargetSessionId: string | null;
17
+ endFirstPaintSpan(attrs: PerfAttrs): void;
18
+ }
19
+
20
+ interface StartupVisibilityLike {
21
+ visibleGlyphCellCount(conversation: ConversationState): number;
22
+ codexHeaderVisible(conversation: ConversationState): boolean;
23
+ }
24
+
25
+ interface StartupSettledGateLike {
26
+ scheduleProbe(sessionId: string): void;
27
+ }
28
+
29
+ interface StartupPaintTrackerOptions {
30
+ readonly startupSequencer: StartupSequencerLike;
31
+ readonly startupSpanTracker: StartupSpanTrackerLike;
32
+ readonly startupVisibility: StartupVisibilityLike;
33
+ readonly startupSettledGate: StartupSettledGateLike;
34
+ readonly recordPerfEvent: (name: string, attrs: PerfAttrs) => void;
35
+ }
36
+
37
+ interface StartupRenderFlushInput {
38
+ readonly activeConversation: ConversationState | null;
39
+ readonly activeConversationId: string | null;
40
+ readonly rightFrameVisible: boolean;
41
+ readonly changedRowCount: number;
42
+ }
43
+
44
+ export class StartupPaintTracker {
45
+ constructor(private readonly options: StartupPaintTrackerOptions) {}
46
+
47
+ onRenderFlush(input: StartupRenderFlushInput): void {
48
+ const targetSessionId = this.options.startupSpanTracker.firstPaintTargetSessionId;
49
+ if (targetSessionId === null) {
50
+ return;
51
+ }
52
+ if (
53
+ input.activeConversation === null ||
54
+ !input.rightFrameVisible ||
55
+ input.activeConversationId !== targetSessionId
56
+ ) {
57
+ return;
58
+ }
59
+ const startupSnapshot = this.options.startupSequencer.snapshot();
60
+ if (!startupSnapshot.firstOutputObserved) {
61
+ return;
62
+ }
63
+
64
+ const glyphCells = this.options.startupVisibility.visibleGlyphCellCount(
65
+ input.activeConversation,
66
+ );
67
+ if (
68
+ !startupSnapshot.firstPaintObserved &&
69
+ this.options.startupSequencer.markFirstPaintVisible(targetSessionId, glyphCells)
70
+ ) {
71
+ this.options.recordPerfEvent('mux.startup.active-first-visible-paint', {
72
+ sessionId: targetSessionId,
73
+ changedRows: input.changedRowCount,
74
+ glyphCells,
75
+ });
76
+ this.options.startupSpanTracker.endFirstPaintSpan({
77
+ observed: true,
78
+ changedRows: input.changedRowCount,
79
+ glyphCells,
80
+ });
81
+ }
82
+
83
+ if (
84
+ this.options.startupSequencer.markHeaderVisible(
85
+ targetSessionId,
86
+ this.options.startupVisibility.codexHeaderVisible(input.activeConversation),
87
+ )
88
+ ) {
89
+ this.options.recordPerfEvent('mux.startup.active-header-visible', {
90
+ sessionId: targetSessionId,
91
+ glyphCells,
92
+ });
93
+ }
94
+ const selectedGate = this.options.startupSequencer.maybeSelectSettleGate(
95
+ targetSessionId,
96
+ glyphCells,
97
+ );
98
+ if (selectedGate !== null) {
99
+ this.options.recordPerfEvent('mux.startup.active-settle-gate', {
100
+ sessionId: targetSessionId,
101
+ gate: selectedGate,
102
+ glyphCells,
103
+ });
104
+ }
105
+ this.options.startupSettledGate.scheduleProbe(targetSessionId);
106
+ }
107
+
108
+ onOutputChunk(sessionId: string): void {
109
+ const targetSessionId = this.options.startupSpanTracker.firstPaintTargetSessionId;
110
+ if (targetSessionId === null || sessionId !== targetSessionId) {
111
+ return;
112
+ }
113
+ this.options.startupSettledGate.scheduleProbe(sessionId);
114
+ }
115
+ }
@@ -0,0 +1,45 @@
1
+ export interface StartupQueueConversationRecord {
2
+ readonly live: boolean;
3
+ }
4
+
5
+ export interface StartupPersistedConversationQueueServiceOptions<
6
+ TConversation extends StartupQueueConversationRecord,
7
+ > {
8
+ readonly orderedConversationIds: () => readonly string[];
9
+ readonly conversationById: (sessionId: string) => TConversation | undefined;
10
+ readonly queueBackgroundOp: (task: () => Promise<void>, label: string) => void;
11
+ readonly startConversation: (sessionId: string) => Promise<unknown>;
12
+ readonly markDirty: () => void;
13
+ }
14
+
15
+ export class StartupPersistedConversationQueueService<
16
+ TConversation extends StartupQueueConversationRecord,
17
+ > {
18
+ constructor(
19
+ private readonly options: StartupPersistedConversationQueueServiceOptions<TConversation>,
20
+ ) {}
21
+
22
+ queuePersistedConversationsInBackground(activeSessionId: string | null): number {
23
+ const ordered = this.options.orderedConversationIds();
24
+ let queued = 0;
25
+ for (const sessionId of ordered) {
26
+ if (activeSessionId !== null && sessionId === activeSessionId) {
27
+ continue;
28
+ }
29
+ const conversation = this.options.conversationById(sessionId);
30
+ if (conversation === undefined || conversation.live) {
31
+ continue;
32
+ }
33
+ this.options.queueBackgroundOp(async () => {
34
+ const latest = this.options.conversationById(sessionId);
35
+ if (latest === undefined || latest.live) {
36
+ return;
37
+ }
38
+ await this.options.startConversation(sessionId);
39
+ this.options.markDirty();
40
+ }, `background-start:${sessionId}`);
41
+ queued += 1;
42
+ }
43
+ return queued;
44
+ }
45
+ }
@@ -0,0 +1,67 @@
1
+ import type { ConversationState } from '../mux/live-mux/conversation-state.ts';
2
+
3
+ type PerfAttrs = Record<string, boolean | number | string>;
4
+
5
+ interface StartupSettledProbeEvent {
6
+ readonly sessionId: string;
7
+ readonly gate: string;
8
+ readonly quietMs: number;
9
+ }
10
+
11
+ interface StartupSequencerLike {
12
+ clearSettledTimer(): void;
13
+ signalSettled(): void;
14
+ scheduleSettledProbe(
15
+ sessionId: string,
16
+ onSettled: (event: StartupSettledProbeEvent) => void,
17
+ ): void;
18
+ }
19
+
20
+ interface StartupSpanTrackerLike {
21
+ readonly firstPaintTargetSessionId: string | null;
22
+ endSettledSpan(attrs: PerfAttrs): void;
23
+ }
24
+
25
+ interface StartupSettledGateOptions {
26
+ readonly startupSequencer: StartupSequencerLike;
27
+ readonly startupSpanTracker: StartupSpanTrackerLike;
28
+ readonly getConversation: (sessionId: string) => ConversationState | undefined;
29
+ readonly visibleGlyphCellCount: (conversation: ConversationState) => number;
30
+ readonly recordPerfEvent: (name: string, attrs: PerfAttrs) => void;
31
+ }
32
+
33
+ export class StartupSettledGate {
34
+ constructor(private readonly options: StartupSettledGateOptions) {}
35
+
36
+ clearTimer(): void {
37
+ this.options.startupSequencer.clearSettledTimer();
38
+ }
39
+
40
+ signalSettled(): void {
41
+ this.options.startupSequencer.signalSettled();
42
+ }
43
+
44
+ scheduleProbe(sessionId: string): void {
45
+ this.options.startupSequencer.scheduleSettledProbe(sessionId, (event) => {
46
+ if (this.options.startupSpanTracker.firstPaintTargetSessionId !== event.sessionId) {
47
+ return;
48
+ }
49
+ const conversation = this.options.getConversation(event.sessionId);
50
+ const glyphCells =
51
+ conversation === undefined ? 0 : this.options.visibleGlyphCellCount(conversation);
52
+ this.options.recordPerfEvent('mux.startup.active-settled', {
53
+ sessionId: event.sessionId,
54
+ gate: event.gate,
55
+ quietMs: event.quietMs,
56
+ glyphCells,
57
+ });
58
+ this.options.startupSpanTracker.endSettledSpan({
59
+ observed: true,
60
+ gate: event.gate,
61
+ quietMs: event.quietMs,
62
+ glyphCells,
63
+ });
64
+ this.options.startupSequencer.signalSettled();
65
+ });
66
+ }
67
+ }
@@ -0,0 +1,53 @@
1
+ type SpanAttrs = Record<string, boolean | number | string>;
2
+
3
+ interface StartupSnapshotLike {
4
+ firstOutputObserved: boolean;
5
+ firstPaintObserved: boolean;
6
+ settledObserved: boolean;
7
+ settleGate: string | null;
8
+ }
9
+
10
+ interface StartupSequencerLike {
11
+ snapshot(): StartupSnapshotLike;
12
+ }
13
+
14
+ interface StartupSpanTrackerLike {
15
+ endStartCommandSpan(attrs: SpanAttrs): void;
16
+ endFirstOutputSpan(attrs: SpanAttrs): void;
17
+ endFirstPaintSpan(attrs: SpanAttrs): void;
18
+ endSettledSpan(attrs: SpanAttrs): void;
19
+ }
20
+
21
+ interface StartupSettledGateLike {
22
+ clearTimer(): void;
23
+ signalSettled(): void;
24
+ }
25
+
26
+ interface StartupShutdownServiceOptions {
27
+ readonly startupSequencer: StartupSequencerLike;
28
+ readonly startupSpanTracker: StartupSpanTrackerLike;
29
+ readonly startupSettledGate: StartupSettledGateLike;
30
+ }
31
+
32
+ export class StartupShutdownService {
33
+ constructor(private readonly options: StartupShutdownServiceOptions) {}
34
+
35
+ finalize(): void {
36
+ this.options.startupSpanTracker.endStartCommandSpan({
37
+ observed: false,
38
+ });
39
+ const startupSnapshot = this.options.startupSequencer.snapshot();
40
+ this.options.startupSpanTracker.endFirstOutputSpan({
41
+ observed: startupSnapshot.firstOutputObserved,
42
+ });
43
+ this.options.startupSpanTracker.endFirstPaintSpan({
44
+ observed: startupSnapshot.firstPaintObserved,
45
+ });
46
+ this.options.startupSettledGate.clearTimer();
47
+ this.options.startupSpanTracker.endSettledSpan({
48
+ observed: startupSnapshot.settledObserved,
49
+ gate: startupSnapshot.settleGate ?? 'none',
50
+ });
51
+ this.options.startupSettledGate.signalSettled();
52
+ }
53
+ }
@@ -0,0 +1,77 @@
1
+ type SpanAttributes = Record<string, boolean | number | string>;
2
+
3
+ interface PerfSpanLike {
4
+ end(attrs: SpanAttributes): void;
5
+ }
6
+
7
+ type StartPerfSpanLike = (name: string, attrs: SpanAttributes) => PerfSpanLike;
8
+
9
+ export class StartupSpanTracker {
10
+ private activeStartCommandSpan: PerfSpanLike | null = null;
11
+ private activeFirstOutputSpan: PerfSpanLike | null = null;
12
+ private activeFirstPaintSpan: PerfSpanLike | null = null;
13
+ private activeSettledSpan: PerfSpanLike | null = null;
14
+ private activeFirstPaintTargetSessionId: string | null = null;
15
+
16
+ constructor(
17
+ private readonly startPerfSpan: StartPerfSpanLike,
18
+ private readonly startupSettleQuietMs: number,
19
+ ) {}
20
+
21
+ get firstPaintTargetSessionId(): string | null {
22
+ return this.activeFirstPaintTargetSessionId;
23
+ }
24
+
25
+ beginForSession(sessionId: string): void {
26
+ this.activeFirstPaintTargetSessionId = sessionId;
27
+ this.activeStartCommandSpan = this.startPerfSpan('mux.startup.active-start-command', {
28
+ sessionId,
29
+ });
30
+ this.activeFirstOutputSpan = this.startPerfSpan('mux.startup.active-first-output', {
31
+ sessionId,
32
+ });
33
+ this.activeFirstPaintSpan = this.startPerfSpan('mux.startup.active-first-visible-paint', {
34
+ sessionId,
35
+ });
36
+ this.activeSettledSpan = this.startPerfSpan('mux.startup.active-settled', {
37
+ sessionId,
38
+ quietMs: this.startupSettleQuietMs,
39
+ });
40
+ }
41
+
42
+ clearTargetSession(): void {
43
+ this.activeFirstPaintTargetSessionId = null;
44
+ }
45
+
46
+ endStartCommandSpan(attrs: SpanAttributes): void {
47
+ if (this.activeStartCommandSpan === null) {
48
+ return;
49
+ }
50
+ this.activeStartCommandSpan.end(attrs);
51
+ this.activeStartCommandSpan = null;
52
+ }
53
+
54
+ endFirstOutputSpan(attrs: SpanAttributes): void {
55
+ if (this.activeFirstOutputSpan === null) {
56
+ return;
57
+ }
58
+ this.activeFirstOutputSpan.end(attrs);
59
+ this.activeFirstOutputSpan = null;
60
+ }
61
+
62
+ endFirstPaintSpan(attrs: SpanAttributes): void {
63
+ if (this.activeFirstPaintSpan === null) {
64
+ return;
65
+ }
66
+ this.activeFirstPaintSpan.end(attrs);
67
+ this.activeFirstPaintSpan = null;
68
+ }
69
+
70
+ endSettledSpan(attrs: SpanAttributes): void {
71
+ if (this.activeSettledSpan === null) {
72
+ return;
73
+ }
74
+ this.activeSettledSpan.end(attrs);
75
+ this.activeSettledSpan = null;
76
+ }
77
+ }
@@ -0,0 +1,94 @@
1
+ interface RepositoryRecordLike {
2
+ readonly repositoryId: string;
3
+ }
4
+
5
+ interface DirectoryGitStatusLike<TRepository, TSummary, TSnapshot> {
6
+ readonly directoryId: string;
7
+ readonly summary: TSummary;
8
+ readonly repositorySnapshot: TSnapshot;
9
+ readonly repositoryId: string | null;
10
+ readonly repository: TRepository | null;
11
+ }
12
+
13
+ interface StartupStateHydrationServiceOptions<
14
+ TRepository extends RepositoryRecordLike,
15
+ TSummary,
16
+ TSnapshot,
17
+ TDirectoryGitStatus extends DirectoryGitStatusLike<TRepository, TSummary, TSnapshot>,
18
+ > {
19
+ readonly hydrateConversationList: () => Promise<void>;
20
+ readonly listRepositories: () => Promise<readonly TRepository[]>;
21
+ readonly clearRepositories: () => void;
22
+ readonly setRepository: (repositoryId: string, repository: TRepository) => void;
23
+ readonly syncRepositoryAssociationsWithDirectorySnapshots: () => void;
24
+ readonly gitHydrationEnabled: boolean;
25
+ readonly listDirectoryGitStatuses: () => Promise<readonly TDirectoryGitStatus[]>;
26
+ readonly setDirectoryGitSummary: (directoryId: string, summary: TSummary) => void;
27
+ readonly setDirectoryRepositorySnapshot: (directoryId: string, snapshot: TSnapshot) => void;
28
+ readonly setDirectoryRepositoryAssociation: (
29
+ directoryId: string,
30
+ repositoryId: string | null,
31
+ ) => void;
32
+ readonly hydrateTaskPlanningState: () => Promise<void>;
33
+ readonly subscribeTaskPlanningEvents: (afterCursor: number | null) => Promise<void>;
34
+ readonly ensureActiveConversationId: () => void;
35
+ readonly activeConversationId: () => string | null;
36
+ readonly selectLeftNavConversation: (sessionId: string) => void;
37
+ readonly enterHomePane: () => void;
38
+ }
39
+
40
+ export class StartupStateHydrationService<
41
+ TRepository extends RepositoryRecordLike,
42
+ TSummary,
43
+ TSnapshot,
44
+ TDirectoryGitStatus extends DirectoryGitStatusLike<TRepository, TSummary, TSnapshot>,
45
+ > {
46
+ constructor(
47
+ private readonly options: StartupStateHydrationServiceOptions<
48
+ TRepository,
49
+ TSummary,
50
+ TSnapshot,
51
+ TDirectoryGitStatus
52
+ >,
53
+ ) {}
54
+
55
+ async hydrateRepositoryList(): Promise<void> {
56
+ const rows = await this.options.listRepositories();
57
+ this.options.clearRepositories();
58
+ for (const record of rows) {
59
+ this.options.setRepository(record.repositoryId, record);
60
+ }
61
+ this.options.syncRepositoryAssociationsWithDirectorySnapshots();
62
+ }
63
+
64
+ async hydrateDirectoryGitStatus(): Promise<void> {
65
+ if (!this.options.gitHydrationEnabled) {
66
+ return;
67
+ }
68
+ const rows = await this.options.listDirectoryGitStatuses();
69
+ for (const record of rows) {
70
+ this.options.setDirectoryGitSummary(record.directoryId, record.summary);
71
+ this.options.setDirectoryRepositorySnapshot(record.directoryId, record.repositorySnapshot);
72
+ this.options.setDirectoryRepositoryAssociation(record.directoryId, record.repositoryId);
73
+ if (record.repository !== null) {
74
+ this.options.setRepository(record.repository.repositoryId, record.repository);
75
+ }
76
+ }
77
+ this.options.syncRepositoryAssociationsWithDirectorySnapshots();
78
+ }
79
+
80
+ async hydrateStartupState(afterCursor: number | null): Promise<void> {
81
+ await this.options.hydrateConversationList();
82
+ await this.hydrateRepositoryList();
83
+ await this.options.hydrateTaskPlanningState();
84
+ await this.hydrateDirectoryGitStatus();
85
+ await this.options.subscribeTaskPlanningEvents(afterCursor);
86
+ this.options.ensureActiveConversationId();
87
+ const activeConversationId = this.options.activeConversationId();
88
+ if (activeConversationId !== null) {
89
+ this.options.selectLeftNavConversation(activeConversationId);
90
+ return;
91
+ }
92
+ this.options.enterHomePane();
93
+ }
94
+ }
@@ -0,0 +1,35 @@
1
+ import type { ConversationState } from '../mux/live-mux/conversation-state.ts';
2
+
3
+ export class StartupVisibility {
4
+ constructor() {}
5
+
6
+ visibleGlyphCellCount(conversation: ConversationState): number {
7
+ const frame = conversation.oracle.snapshotWithoutHash();
8
+ let count = 0;
9
+ for (const line of frame.richLines) {
10
+ for (const cell of line.cells) {
11
+ if (!cell.continued && cell.glyph.trim().length > 0) {
12
+ count += 1;
13
+ }
14
+ }
15
+ }
16
+ return count;
17
+ }
18
+
19
+ codexHeaderVisible(conversation: ConversationState): boolean {
20
+ const frame = conversation.oracle.snapshotWithoutHash();
21
+ const rows: string[] = [];
22
+ for (const line of frame.richLines) {
23
+ let row = '';
24
+ for (const cell of line.cells) {
25
+ if (cell.continued) {
26
+ continue;
27
+ }
28
+ row += cell.glyph;
29
+ }
30
+ rows.push(row.trimEnd());
31
+ }
32
+ const text = rows.join('\n');
33
+ return text.includes('OpenAI Codex') && text.includes('model:') && text.includes('directory:');
34
+ }
35
+ }
@@ -0,0 +1,144 @@
1
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, writeSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import {
4
+ parseActiveStatusTimelineState,
5
+ type ActiveStatusTimelineState,
6
+ } from '../mux/live-mux/status-timeline-state.ts';
7
+
8
+ export interface StatusTimelineLabels {
9
+ readonly repositoryId: string | null;
10
+ readonly repositoryName: string | null;
11
+ readonly projectId: string | null;
12
+ readonly projectPath: string | null;
13
+ readonly threadId: string | null;
14
+ readonly threadTitle: string | null;
15
+ readonly agentType: string | null;
16
+ readonly conversationId: string | null;
17
+ }
18
+
19
+ interface StatusTimelineRecordInput {
20
+ readonly direction: 'incoming' | 'outgoing';
21
+ readonly source: string;
22
+ readonly eventType: string;
23
+ readonly labels: StatusTimelineLabels;
24
+ readonly payload: unknown;
25
+ readonly dedupeKey?: string;
26
+ readonly dedupeValue?: string;
27
+ }
28
+
29
+ interface StatusTimelineRecorderOptions {
30
+ readonly statePath: string;
31
+ readonly nowMs?: () => number;
32
+ readonly nowIso?: () => string;
33
+ readonly refreshIntervalMs?: number;
34
+ }
35
+
36
+ const DEFAULT_REFRESH_INTERVAL_MS = 250;
37
+
38
+ export class StatusTimelineRecorder {
39
+ private readonly nowMs: () => number;
40
+ private readonly nowIso: () => string;
41
+ private readonly refreshIntervalMs: number;
42
+ private nextRefreshAtMs = 0;
43
+ private activeState: ActiveStatusTimelineState | null = null;
44
+ private activeOutputPath: string | null = null;
45
+ private outputFd: number | null = null;
46
+ private readonly dedupeValueByKey = new Map<string, string>();
47
+
48
+ constructor(private readonly options: StatusTimelineRecorderOptions) {
49
+ this.nowMs = options.nowMs ?? Date.now;
50
+ this.nowIso = options.nowIso ?? (() => new Date().toISOString());
51
+ this.refreshIntervalMs = options.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS;
52
+ }
53
+
54
+ record(input: StatusTimelineRecordInput): void {
55
+ this.refreshIfDue();
56
+ if (this.outputFd === null || this.activeState === null) {
57
+ return;
58
+ }
59
+ if (
60
+ input.dedupeKey !== undefined &&
61
+ input.dedupeValue !== undefined &&
62
+ this.dedupeValueByKey.get(input.dedupeKey) === input.dedupeValue
63
+ ) {
64
+ return;
65
+ }
66
+ if (input.dedupeKey !== undefined && input.dedupeValue !== undefined) {
67
+ this.dedupeValueByKey.set(input.dedupeKey, input.dedupeValue);
68
+ }
69
+ const record = {
70
+ ts: this.nowIso(),
71
+ direction: input.direction,
72
+ source: input.source,
73
+ eventType: input.eventType,
74
+ labels: input.labels,
75
+ payload: input.payload,
76
+ };
77
+ try {
78
+ writeSync(this.outputFd, `${JSON.stringify(record)}\n`);
79
+ } catch {
80
+ this.deactivate();
81
+ }
82
+ }
83
+
84
+ close(): void {
85
+ this.deactivate();
86
+ }
87
+
88
+ private refreshIfDue(): void {
89
+ const now = this.nowMs();
90
+ if (now < this.nextRefreshAtMs) {
91
+ return;
92
+ }
93
+ this.nextRefreshAtMs = now + this.refreshIntervalMs;
94
+ const nextState = this.readState();
95
+ if (nextState === null) {
96
+ this.deactivate();
97
+ return;
98
+ }
99
+ const nextOutputPath = resolve(nextState.outputPath);
100
+ if (this.activeOutputPath === nextOutputPath && this.outputFd !== null) {
101
+ this.activeState = nextState;
102
+ return;
103
+ }
104
+ this.activate(nextState, nextOutputPath);
105
+ }
106
+
107
+ private activate(state: ActiveStatusTimelineState, outputPath: string): void {
108
+ this.deactivate();
109
+ try {
110
+ mkdirSync(dirname(outputPath), { recursive: true });
111
+ this.outputFd = openSync(outputPath, 'a');
112
+ this.activeOutputPath = outputPath;
113
+ this.activeState = state;
114
+ } catch {
115
+ this.deactivate();
116
+ }
117
+ }
118
+
119
+ private deactivate(): void {
120
+ if (this.outputFd !== null) {
121
+ try {
122
+ closeSync(this.outputFd);
123
+ } catch {
124
+ // Best-effort close only.
125
+ }
126
+ }
127
+ this.outputFd = null;
128
+ this.activeOutputPath = null;
129
+ this.activeState = null;
130
+ this.dedupeValueByKey.clear();
131
+ }
132
+
133
+ private readState(): ActiveStatusTimelineState | null {
134
+ if (!existsSync(this.options.statePath)) {
135
+ return null;
136
+ }
137
+ try {
138
+ const raw = JSON.parse(readFileSync(this.options.statePath, 'utf8')) as unknown;
139
+ return parseActiveStatusTimelineState(raw);
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+ }