@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,344 @@
1
+ interface ConversationStateLike {
2
+ readonly directoryId: string | null;
3
+ readonly live: boolean;
4
+ }
5
+
6
+ interface OpenNewThreadPromptOptions<TNewThreadPromptState> {
7
+ directoryId: string;
8
+ directoriesHas: (directoryId: string) => boolean;
9
+ clearAddDirectoryPrompt: () => void;
10
+ clearRepositoryPrompt: () => void;
11
+ hasConversationTitleEdit: boolean;
12
+ stopConversationTitleEdit: () => void;
13
+ clearConversationTitleEditClickState: () => void;
14
+ createNewThreadPromptState: (directoryId: string) => TNewThreadPromptState;
15
+ setNewThreadPrompt: (prompt: TNewThreadPromptState) => void;
16
+ markDirty: () => void;
17
+ }
18
+
19
+ export function openNewThreadPrompt<TNewThreadPromptState>(
20
+ options: OpenNewThreadPromptOptions<TNewThreadPromptState>,
21
+ ): void {
22
+ if (!options.directoriesHas(options.directoryId)) {
23
+ return;
24
+ }
25
+ options.clearAddDirectoryPrompt();
26
+ options.clearRepositoryPrompt();
27
+ if (options.hasConversationTitleEdit) {
28
+ options.stopConversationTitleEdit();
29
+ }
30
+ options.clearConversationTitleEditClickState();
31
+ options.setNewThreadPrompt(options.createNewThreadPromptState(options.directoryId));
32
+ options.markDirty();
33
+ }
34
+
35
+ interface CreateAndActivateConversationInDirectoryOptions<TAgentType> {
36
+ directoryId: string;
37
+ agentType: TAgentType;
38
+ createConversationId: () => string;
39
+ createConversationRecord: (
40
+ sessionId: string,
41
+ directoryId: string,
42
+ agentType: TAgentType,
43
+ ) => Promise<void>;
44
+ ensureConversation: (
45
+ sessionId: string,
46
+ seed: {
47
+ directoryId: string;
48
+ title: string;
49
+ agentType: string;
50
+ adapterState: Record<string, unknown>;
51
+ },
52
+ ) => void;
53
+ noteGitActivity: (directoryId: string) => void;
54
+ startConversation: (sessionId: string) => Promise<unknown>;
55
+ activateConversation: (sessionId: string) => Promise<unknown>;
56
+ }
57
+
58
+ export async function createAndActivateConversationInDirectory<TAgentType>(
59
+ options: CreateAndActivateConversationInDirectoryOptions<TAgentType>,
60
+ ): Promise<void> {
61
+ const sessionId = options.createConversationId();
62
+ const title = '';
63
+ await options.createConversationRecord(sessionId, options.directoryId, options.agentType);
64
+ options.ensureConversation(sessionId, {
65
+ directoryId: options.directoryId,
66
+ title,
67
+ agentType: String(options.agentType),
68
+ adapterState: {},
69
+ });
70
+ options.noteGitActivity(options.directoryId);
71
+ await options.startConversation(sessionId);
72
+ await options.activateConversation(sessionId);
73
+ }
74
+
75
+ interface OpenOrCreateCritiqueConversationInDirectoryOptions {
76
+ directoryId: string;
77
+ orderedConversationIds: () => readonly string[];
78
+ conversationById: (sessionId: string) => {
79
+ readonly directoryId: string | null;
80
+ readonly agentType: string;
81
+ } | null;
82
+ activateConversation: (sessionId: string) => Promise<unknown>;
83
+ createAndActivateCritiqueConversationInDirectory: (directoryId: string) => Promise<unknown>;
84
+ }
85
+
86
+ function isCritiqueAgentType(agentType: string): boolean {
87
+ return agentType.trim().toLowerCase() === 'critique';
88
+ }
89
+
90
+ export async function openOrCreateCritiqueConversationInDirectory(
91
+ options: OpenOrCreateCritiqueConversationInDirectoryOptions,
92
+ ): Promise<void> {
93
+ const existingConversationId =
94
+ options.orderedConversationIds().find((sessionId) => {
95
+ const conversation = options.conversationById(sessionId);
96
+ if (conversation === null) {
97
+ return false;
98
+ }
99
+ return (
100
+ conversation.directoryId === options.directoryId &&
101
+ isCritiqueAgentType(conversation.agentType)
102
+ );
103
+ }) ?? null;
104
+ if (existingConversationId !== null) {
105
+ await options.activateConversation(existingConversationId);
106
+ return;
107
+ }
108
+ await options.createAndActivateCritiqueConversationInDirectory(options.directoryId);
109
+ }
110
+
111
+ interface ArchiveConversationOptions {
112
+ sessionId: string;
113
+ conversations: ReadonlyMap<string, ConversationStateLike>;
114
+ closePtySession: (sessionId: string) => Promise<void>;
115
+ removeSession: (sessionId: string) => Promise<void>;
116
+ isSessionNotFoundError: (error: unknown) => boolean;
117
+ archiveConversationRecord: (sessionId: string) => Promise<void>;
118
+ isConversationNotFoundError: (error: unknown) => boolean;
119
+ unsubscribeConversationEvents: (sessionId: string) => Promise<void>;
120
+ removeConversationState: (sessionId: string) => void;
121
+ activeConversationId: string | null;
122
+ setActiveConversationId: (sessionId: string | null) => void;
123
+ orderedConversationIds: () => readonly string[];
124
+ conversationDirectoryId: (sessionId: string) => string | null;
125
+ resolveActiveDirectoryId: () => string | null;
126
+ enterProjectPane: (directoryId: string) => void;
127
+ activateConversation: (sessionId: string) => Promise<unknown>;
128
+ markDirty: () => void;
129
+ }
130
+
131
+ export async function archiveConversation(options: ArchiveConversationOptions): Promise<void> {
132
+ const target = options.conversations.get(options.sessionId);
133
+ if (target === undefined) {
134
+ return;
135
+ }
136
+ if (target.live) {
137
+ try {
138
+ await options.closePtySession(options.sessionId);
139
+ } catch {
140
+ // Best-effort close only.
141
+ }
142
+ }
143
+
144
+ try {
145
+ await options.removeSession(options.sessionId);
146
+ } catch (error: unknown) {
147
+ if (!options.isSessionNotFoundError(error)) {
148
+ throw error;
149
+ }
150
+ }
151
+
152
+ try {
153
+ await options.archiveConversationRecord(options.sessionId);
154
+ } catch (error: unknown) {
155
+ if (!options.isConversationNotFoundError(error)) {
156
+ throw error;
157
+ }
158
+ }
159
+ await options.unsubscribeConversationEvents(options.sessionId);
160
+ options.removeConversationState(options.sessionId);
161
+
162
+ if (options.activeConversationId === options.sessionId) {
163
+ const archivedDirectoryId = target.directoryId;
164
+ const ordered = options.orderedConversationIds();
165
+ const sameDirectoryConversationId =
166
+ ordered.find(
167
+ (candidateId) => options.conversationDirectoryId(candidateId) === archivedDirectoryId,
168
+ ) ?? null;
169
+ options.setActiveConversationId(null);
170
+ if (sameDirectoryConversationId !== null) {
171
+ await options.activateConversation(sameDirectoryConversationId);
172
+ return;
173
+ }
174
+ const fallbackDirectoryId = archivedDirectoryId ?? options.resolveActiveDirectoryId();
175
+ if (fallbackDirectoryId !== null) {
176
+ options.enterProjectPane(fallbackDirectoryId);
177
+ options.markDirty();
178
+ return;
179
+ }
180
+ options.markDirty();
181
+ return;
182
+ }
183
+
184
+ options.markDirty();
185
+ }
186
+
187
+ interface TakeoverConversationOptions<TControllerRecord> {
188
+ sessionId: string;
189
+ conversationsHas: (sessionId: string) => boolean;
190
+ claimSession: (sessionId: string) => Promise<TControllerRecord | null>;
191
+ applyController: (sessionId: string, controller: TControllerRecord) => void;
192
+ setLastEventNow: (sessionId: string) => void;
193
+ markDirty: () => void;
194
+ }
195
+
196
+ export async function takeoverConversation<TControllerRecord>(
197
+ options: TakeoverConversationOptions<TControllerRecord>,
198
+ ): Promise<void> {
199
+ if (!options.conversationsHas(options.sessionId)) {
200
+ return;
201
+ }
202
+ const controller = await options.claimSession(options.sessionId);
203
+ if (controller !== null) {
204
+ options.applyController(options.sessionId, controller);
205
+ }
206
+ options.setLastEventNow(options.sessionId);
207
+ options.markDirty();
208
+ }
209
+
210
+ interface AddDirectoryByPathOptions<TDirectoryRecord extends { directoryId: string }> {
211
+ rawPath: string;
212
+ resolveWorkspacePathForMux: (rawPath: string) => string;
213
+ upsertDirectory: (path: string) => Promise<TDirectoryRecord | null>;
214
+ setDirectory: (directory: TDirectoryRecord) => void;
215
+ directoryIdOf: (directory: TDirectoryRecord) => string;
216
+ setActiveDirectoryId: (directoryId: string) => void;
217
+ syncGitStateWithDirectories: () => void;
218
+ noteGitActivity: (directoryId: string) => void;
219
+ hydratePersistedConversationsForDirectory: (directoryId: string) => Promise<unknown>;
220
+ findConversationIdByDirectory: (directoryId: string) => string | null;
221
+ activateConversation: (sessionId: string) => Promise<unknown>;
222
+ enterProjectPane: (directoryId: string) => void;
223
+ markDirty: () => void;
224
+ }
225
+
226
+ export async function addDirectoryByPath<TDirectoryRecord extends { directoryId: string }>(
227
+ options: AddDirectoryByPathOptions<TDirectoryRecord>,
228
+ ): Promise<void> {
229
+ const normalizedPath = options.resolveWorkspacePathForMux(options.rawPath);
230
+ const directory = await options.upsertDirectory(normalizedPath);
231
+ if (directory === null) {
232
+ throw new Error('control-plane directory.upsert returned malformed directory record');
233
+ }
234
+ options.setDirectory(directory);
235
+ const directoryId = options.directoryIdOf(directory);
236
+ options.setActiveDirectoryId(directoryId);
237
+ options.syncGitStateWithDirectories();
238
+ options.noteGitActivity(directoryId);
239
+ await options.hydratePersistedConversationsForDirectory(directoryId);
240
+ const targetConversationId = options.findConversationIdByDirectory(directoryId);
241
+ if (targetConversationId !== null) {
242
+ await options.activateConversation(targetConversationId);
243
+ return;
244
+ }
245
+ options.enterProjectPane(directoryId);
246
+ options.markDirty();
247
+ }
248
+
249
+ interface CloseDirectoryOptions {
250
+ directoryId: string;
251
+ directoriesHas: (directoryId: string) => boolean;
252
+ orderedConversationIds: () => readonly string[];
253
+ conversationDirectoryId: (sessionId: string) => string | null;
254
+ conversationLive: (sessionId: string) => boolean;
255
+ closePtySession: (sessionId: string) => Promise<void>;
256
+ archiveConversationRecord: (sessionId: string) => Promise<void>;
257
+ unsubscribeConversationEvents: (sessionId: string) => Promise<void>;
258
+ removeConversationState: (sessionId: string) => void;
259
+ activeConversationId: string | null;
260
+ setActiveConversationId: (sessionId: string | null) => void;
261
+ archiveDirectory: (directoryId: string) => Promise<void>;
262
+ deleteDirectory: (directoryId: string) => void;
263
+ deleteDirectoryGitState: (directoryId: string) => void;
264
+ projectPaneSnapshotDirectoryId: string | null;
265
+ clearProjectPaneSnapshot: () => void;
266
+ directoriesSize: () => number;
267
+ addDirectoryByPath: (path: string) => Promise<void>;
268
+ invocationDirectory: string;
269
+ activeDirectoryId: string | null;
270
+ setActiveDirectoryId: (directoryId: string | null) => void;
271
+ firstDirectoryId: () => string | null;
272
+ noteGitActivity: (directoryId: string) => void;
273
+ resolveActiveDirectoryId: () => string | null;
274
+ activateConversation: (sessionId: string) => Promise<unknown>;
275
+ enterProjectPane: (directoryId: string) => void;
276
+ markDirty: () => void;
277
+ }
278
+
279
+ export async function closeDirectory(options: CloseDirectoryOptions): Promise<void> {
280
+ if (!options.directoriesHas(options.directoryId)) {
281
+ return;
282
+ }
283
+ const sessionIds = options
284
+ .orderedConversationIds()
285
+ .filter((sessionId) => options.conversationDirectoryId(sessionId) === options.directoryId);
286
+
287
+ for (const sessionId of sessionIds) {
288
+ if (options.conversationLive(sessionId)) {
289
+ try {
290
+ await options.closePtySession(sessionId);
291
+ } catch {
292
+ // Best-effort close only.
293
+ }
294
+ }
295
+ await options.archiveConversationRecord(sessionId);
296
+ await options.unsubscribeConversationEvents(sessionId);
297
+ options.removeConversationState(sessionId);
298
+ if (options.activeConversationId === sessionId) {
299
+ options.setActiveConversationId(null);
300
+ }
301
+ }
302
+
303
+ await options.archiveDirectory(options.directoryId);
304
+ options.deleteDirectory(options.directoryId);
305
+ options.deleteDirectoryGitState(options.directoryId);
306
+ if (options.projectPaneSnapshotDirectoryId === options.directoryId) {
307
+ options.clearProjectPaneSnapshot();
308
+ }
309
+
310
+ if (options.directoriesSize() === 0) {
311
+ await options.addDirectoryByPath(options.invocationDirectory);
312
+ return;
313
+ }
314
+
315
+ if (
316
+ options.activeDirectoryId === options.directoryId ||
317
+ options.activeDirectoryId === null ||
318
+ !options.directoriesHas(options.activeDirectoryId)
319
+ ) {
320
+ options.setActiveDirectoryId(options.firstDirectoryId());
321
+ }
322
+ if (options.activeDirectoryId !== null) {
323
+ options.noteGitActivity(options.activeDirectoryId);
324
+ }
325
+
326
+ const fallbackDirectoryId = options.resolveActiveDirectoryId();
327
+ const fallbackConversationId =
328
+ options
329
+ .orderedConversationIds()
330
+ .find((sessionId) => options.conversationDirectoryId(sessionId) === fallbackDirectoryId) ??
331
+ options.orderedConversationIds()[0] ??
332
+ null;
333
+ if (fallbackConversationId !== null) {
334
+ await options.activateConversation(fallbackConversationId);
335
+ return;
336
+ }
337
+ if (fallbackDirectoryId !== null) {
338
+ options.enterProjectPane(fallbackDirectoryId);
339
+ options.markDirty();
340
+ return;
341
+ }
342
+
343
+ options.markDirty();
344
+ }
@@ -0,0 +1,246 @@
1
+ interface RepositoryRecordWithMetadata {
2
+ readonly repositoryId: string;
3
+ readonly remoteUrl: string;
4
+ readonly metadata: Record<string, unknown>;
5
+ }
6
+
7
+ interface RepositoryPromptState {
8
+ readonly mode: 'add' | 'edit';
9
+ readonly repositoryId: string | null;
10
+ readonly value: string;
11
+ readonly error: string | null;
12
+ }
13
+
14
+ interface OpenRepositoryPromptForCreateOptions {
15
+ clearNewThreadPrompt: () => void;
16
+ clearAddDirectoryPrompt: () => void;
17
+ hasConversationTitleEdit: boolean;
18
+ stopConversationTitleEdit: () => void;
19
+ clearConversationTitleEditClickState: () => void;
20
+ setRepositoryPrompt: (prompt: RepositoryPromptState) => void;
21
+ markDirty: () => void;
22
+ }
23
+
24
+ export function openRepositoryPromptForCreate(options: OpenRepositoryPromptForCreateOptions): void {
25
+ options.clearNewThreadPrompt();
26
+ options.clearAddDirectoryPrompt();
27
+ if (options.hasConversationTitleEdit) {
28
+ options.stopConversationTitleEdit();
29
+ }
30
+ options.clearConversationTitleEditClickState();
31
+ options.setRepositoryPrompt({
32
+ mode: 'add',
33
+ repositoryId: null,
34
+ value: '',
35
+ error: null,
36
+ });
37
+ options.markDirty();
38
+ }
39
+
40
+ interface OpenRepositoryPromptForEditOptions {
41
+ repositoryId: string;
42
+ repositories: ReadonlyMap<string, RepositoryRecordWithMetadata>;
43
+ clearNewThreadPrompt: () => void;
44
+ clearAddDirectoryPrompt: () => void;
45
+ hasConversationTitleEdit: boolean;
46
+ stopConversationTitleEdit: () => void;
47
+ clearConversationTitleEditClickState: () => void;
48
+ setRepositoryPrompt: (prompt: RepositoryPromptState) => void;
49
+ setTaskPaneSelectionFocusRepository: () => void;
50
+ markDirty: () => void;
51
+ }
52
+
53
+ export function openRepositoryPromptForEdit(options: OpenRepositoryPromptForEditOptions): void {
54
+ const repository = options.repositories.get(options.repositoryId);
55
+ if (repository === undefined) {
56
+ return;
57
+ }
58
+ options.clearNewThreadPrompt();
59
+ options.clearAddDirectoryPrompt();
60
+ if (options.hasConversationTitleEdit) {
61
+ options.stopConversationTitleEdit();
62
+ }
63
+ options.clearConversationTitleEditClickState();
64
+ options.setRepositoryPrompt({
65
+ mode: 'edit',
66
+ repositoryId: options.repositoryId,
67
+ value: repository.remoteUrl,
68
+ error: null,
69
+ });
70
+ options.setTaskPaneSelectionFocusRepository();
71
+ options.markDirty();
72
+ }
73
+
74
+ function repositoryHomePriority(repository: RepositoryRecordWithMetadata): number | null {
75
+ const raw = repository.metadata['homePriority'];
76
+ if (typeof raw !== 'number' || !Number.isFinite(raw)) {
77
+ return null;
78
+ }
79
+ if (!Number.isInteger(raw) || raw < 0) {
80
+ return null;
81
+ }
82
+ return raw;
83
+ }
84
+
85
+ interface QueueRepositoryPriorityOrderOptions<TRepository extends RepositoryRecordWithMetadata> {
86
+ orderedRepositoryIds: readonly string[];
87
+ repositories: ReadonlyMap<string, TRepository>;
88
+ queueControlPlaneOp: (task: () => Promise<void>, label: string) => void;
89
+ updateRepositoryMetadata: (
90
+ repositoryId: string,
91
+ metadata: Record<string, unknown>,
92
+ ) => Promise<TRepository>;
93
+ upsertRepository: (repository: TRepository) => void;
94
+ syncTaskPaneRepositorySelection: () => void;
95
+ markDirty: () => void;
96
+ label: string;
97
+ }
98
+
99
+ export function queueRepositoryPriorityOrder<TRepository extends RepositoryRecordWithMetadata>(
100
+ options: QueueRepositoryPriorityOrderOptions<TRepository>,
101
+ ): void {
102
+ const updates: Array<{ repositoryId: string; metadata: Record<string, unknown> }> = [];
103
+ for (let index = 0; index < options.orderedRepositoryIds.length; index += 1) {
104
+ const repositoryId = options.orderedRepositoryIds[index]!;
105
+ const repository = options.repositories.get(repositoryId);
106
+ if (repository === undefined) {
107
+ continue;
108
+ }
109
+ if (repositoryHomePriority(repository) === index) {
110
+ continue;
111
+ }
112
+ updates.push({
113
+ repositoryId,
114
+ metadata: {
115
+ ...repository.metadata,
116
+ homePriority: index,
117
+ },
118
+ });
119
+ }
120
+ if (updates.length === 0) {
121
+ return;
122
+ }
123
+ options.queueControlPlaneOp(async () => {
124
+ for (const update of updates) {
125
+ const repository = await options.updateRepositoryMetadata(
126
+ update.repositoryId,
127
+ update.metadata,
128
+ );
129
+ options.upsertRepository(repository);
130
+ }
131
+ options.syncTaskPaneRepositorySelection();
132
+ options.markDirty();
133
+ }, options.label);
134
+ }
135
+
136
+ interface ReorderRepositoryByDropOptions {
137
+ draggedRepositoryId: string;
138
+ targetRepositoryId: string;
139
+ orderedRepositoryIds: readonly string[];
140
+ reorderIdsByMove: (
141
+ ids: readonly string[],
142
+ draggedId: string,
143
+ targetId: string,
144
+ ) => readonly string[] | null;
145
+ queueRepositoryPriorityOrder: (orderedRepositoryIds: readonly string[], label: string) => void;
146
+ }
147
+
148
+ export function reorderRepositoryByDrop(options: ReorderRepositoryByDropOptions): void {
149
+ const reordered = options.reorderIdsByMove(
150
+ options.orderedRepositoryIds,
151
+ options.draggedRepositoryId,
152
+ options.targetRepositoryId,
153
+ );
154
+ if (reordered === null) {
155
+ return;
156
+ }
157
+ options.queueRepositoryPriorityOrder(reordered, 'repositories-reorder-drag');
158
+ }
159
+
160
+ interface UpsertRepositoryByRemoteUrlScope {
161
+ readonly tenantId: string;
162
+ readonly userId: string;
163
+ readonly workspaceId: string;
164
+ }
165
+
166
+ interface UpsertRepositoryByRemoteUrlOptions<TRepository extends RepositoryRecordWithMetadata> {
167
+ remoteUrl: string;
168
+ existingRepositoryId: string | null;
169
+ normalizeGitHubRemoteUrl: (value: string) => string | null;
170
+ repositoryNameFromGitHubRemoteUrl: (value: string) => string;
171
+ createRepositoryId: () => string;
172
+ scope: UpsertRepositoryByRemoteUrlScope;
173
+ createRepository: (payload: {
174
+ repositoryId: string;
175
+ tenantId: string;
176
+ userId: string;
177
+ workspaceId: string;
178
+ name: string;
179
+ remoteUrl: string;
180
+ defaultBranch: string;
181
+ metadata: Record<string, unknown>;
182
+ }) => Promise<Record<string, unknown>>;
183
+ updateRepository: (payload: {
184
+ repositoryId: string;
185
+ name: string;
186
+ remoteUrl: string;
187
+ }) => Promise<Record<string, unknown>>;
188
+ parseRepositoryRecord: (value: unknown) => TRepository | null;
189
+ upsertRepository: (repository: TRepository) => void;
190
+ syncRepositoryAssociationsWithDirectorySnapshots: () => void;
191
+ syncTaskPaneRepositorySelection: () => void;
192
+ markDirty: () => void;
193
+ }
194
+
195
+ export async function upsertRepositoryByRemoteUrl<TRepository extends RepositoryRecordWithMetadata>(
196
+ options: UpsertRepositoryByRemoteUrlOptions<TRepository>,
197
+ ): Promise<void> {
198
+ const normalizedRemoteUrl = options.normalizeGitHubRemoteUrl(options.remoteUrl);
199
+ if (normalizedRemoteUrl === null) {
200
+ throw new Error('github url required');
201
+ }
202
+ const result =
203
+ options.existingRepositoryId === null
204
+ ? await options.createRepository({
205
+ repositoryId: options.createRepositoryId(),
206
+ tenantId: options.scope.tenantId,
207
+ userId: options.scope.userId,
208
+ workspaceId: options.scope.workspaceId,
209
+ name: options.repositoryNameFromGitHubRemoteUrl(normalizedRemoteUrl),
210
+ remoteUrl: normalizedRemoteUrl,
211
+ defaultBranch: 'main',
212
+ metadata: {
213
+ source: 'mux-manual',
214
+ },
215
+ })
216
+ : await options.updateRepository({
217
+ repositoryId: options.existingRepositoryId,
218
+ name: options.repositoryNameFromGitHubRemoteUrl(normalizedRemoteUrl),
219
+ remoteUrl: normalizedRemoteUrl,
220
+ });
221
+ const repository = options.parseRepositoryRecord(result['repository']);
222
+ if (repository === null) {
223
+ throw new Error('control-plane repository command returned malformed repository record');
224
+ }
225
+ options.upsertRepository(repository);
226
+ options.syncRepositoryAssociationsWithDirectorySnapshots();
227
+ options.syncTaskPaneRepositorySelection();
228
+ options.markDirty();
229
+ }
230
+
231
+ interface ArchiveRepositoryByIdOptions {
232
+ repositoryId: string;
233
+ archiveRepository: (repositoryId: string) => Promise<unknown>;
234
+ deleteRepository: (repositoryId: string) => void;
235
+ syncRepositoryAssociationsWithDirectorySnapshots: () => void;
236
+ syncTaskPaneRepositorySelection: () => void;
237
+ markDirty: () => void;
238
+ }
239
+
240
+ export async function archiveRepositoryById(options: ArchiveRepositoryByIdOptions): Promise<void> {
241
+ await options.archiveRepository(options.repositoryId);
242
+ options.deleteRepository(options.repositoryId);
243
+ options.syncRepositoryAssociationsWithDirectorySnapshots();
244
+ options.syncTaskPaneRepositorySelection();
245
+ options.markDirty();
246
+ }
@@ -0,0 +1,115 @@
1
+ import type { TaskPaneAction } from '../harness-core-ui.ts';
2
+
3
+ interface TaskRecordActionState {
4
+ readonly taskId: string;
5
+ readonly status: string;
6
+ }
7
+
8
+ interface RunTaskPaneActionOptions {
9
+ action: TaskPaneAction;
10
+ openTaskCreatePrompt: () => void;
11
+ openRepositoryPromptForCreate: () => void;
12
+ selectedRepositoryId: string | null;
13
+ repositoryExists: (repositoryId: string) => boolean;
14
+ setTaskPaneNotice: (notice: string | null) => void;
15
+ markDirty: () => void;
16
+ setTaskPaneSelectionFocus: (focus: 'task' | 'repository') => void;
17
+ openRepositoryPromptForEdit: (repositoryId: string) => void;
18
+ queueArchiveRepository: (repositoryId: string) => void;
19
+ selectedTask: TaskRecordActionState | null;
20
+ openTaskEditPrompt: (taskId: string) => void;
21
+ queueDeleteTask: (taskId: string) => void;
22
+ queueTaskReady: (taskId: string) => void;
23
+ queueTaskDraft: (taskId: string) => void;
24
+ queueTaskComplete: (taskId: string) => void;
25
+ orderedTaskRecords: () => readonly TaskRecordActionState[];
26
+ queueTaskReorderByIds: (orderedTaskIds: readonly string[], label: string) => void;
27
+ }
28
+
29
+ export function runTaskPaneAction(options: RunTaskPaneActionOptions): void {
30
+ if (options.action === 'task.create') {
31
+ options.openTaskCreatePrompt();
32
+ return;
33
+ }
34
+ if (options.action === 'repository.create') {
35
+ options.setTaskPaneNotice(null);
36
+ options.openRepositoryPromptForCreate();
37
+ return;
38
+ }
39
+ if (options.action === 'repository.edit') {
40
+ const selectedRepositoryId = options.selectedRepositoryId;
41
+ if (selectedRepositoryId === null || !options.repositoryExists(selectedRepositoryId)) {
42
+ options.setTaskPaneNotice('select a repository first');
43
+ options.markDirty();
44
+ return;
45
+ }
46
+ options.setTaskPaneSelectionFocus('repository');
47
+ options.setTaskPaneNotice(null);
48
+ options.openRepositoryPromptForEdit(selectedRepositoryId);
49
+ return;
50
+ }
51
+ if (options.action === 'repository.archive') {
52
+ const selectedRepositoryId = options.selectedRepositoryId;
53
+ if (selectedRepositoryId === null || !options.repositoryExists(selectedRepositoryId)) {
54
+ options.setTaskPaneNotice('select a repository first');
55
+ options.markDirty();
56
+ return;
57
+ }
58
+ options.setTaskPaneSelectionFocus('repository');
59
+ options.queueArchiveRepository(selectedRepositoryId);
60
+ return;
61
+ }
62
+ const selected = options.selectedTask;
63
+ if (selected === null) {
64
+ options.setTaskPaneNotice('select a task first');
65
+ options.markDirty();
66
+ return;
67
+ }
68
+ if (options.action === 'task.edit') {
69
+ options.setTaskPaneSelectionFocus('task');
70
+ options.openTaskEditPrompt(selected.taskId);
71
+ return;
72
+ }
73
+ if (options.action === 'task.delete') {
74
+ options.setTaskPaneSelectionFocus('task');
75
+ options.queueDeleteTask(selected.taskId);
76
+ return;
77
+ }
78
+ if (options.action === 'task.ready') {
79
+ options.setTaskPaneSelectionFocus('task');
80
+ options.queueTaskReady(selected.taskId);
81
+ return;
82
+ }
83
+ if (options.action === 'task.draft') {
84
+ options.setTaskPaneSelectionFocus('task');
85
+ options.queueTaskDraft(selected.taskId);
86
+ return;
87
+ }
88
+ if (options.action === 'task.complete') {
89
+ options.setTaskPaneSelectionFocus('task');
90
+ options.queueTaskComplete(selected.taskId);
91
+ return;
92
+ }
93
+ if (options.action === 'task.reorder-up' || options.action === 'task.reorder-down') {
94
+ const activeTasks = options.orderedTaskRecords().filter((task) => task.status !== 'completed');
95
+ const selectedIndex = activeTasks.findIndex((task) => task.taskId === selected.taskId);
96
+ if (selectedIndex < 0) {
97
+ options.setTaskPaneNotice('cannot reorder completed tasks');
98
+ options.markDirty();
99
+ return;
100
+ }
101
+ const swapIndex = options.action === 'task.reorder-up' ? selectedIndex - 1 : selectedIndex + 1;
102
+ if (swapIndex < 0 || swapIndex >= activeTasks.length) {
103
+ return;
104
+ }
105
+ const reordered = [...activeTasks];
106
+ const currentTask = reordered[selectedIndex]!;
107
+ reordered[selectedIndex] = reordered[swapIndex]!;
108
+ reordered[swapIndex] = currentTask;
109
+ options.setTaskPaneSelectionFocus('task');
110
+ options.queueTaskReorderByIds(
111
+ reordered.map((task) => task.taskId),
112
+ options.action === 'task.reorder-up' ? 'tasks-reorder-up' : 'tasks-reorder-down',
113
+ );
114
+ }
115
+ }