@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,2557 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type {
3
+ StreamCommand,
4
+ StreamObservedEvent,
5
+ StreamSessionController,
6
+ StreamSessionRuntimeStatus,
7
+ } from './stream-protocol.ts';
8
+ import type {
9
+ ControlPlaneAutomationPolicyRecord,
10
+ ControlPlaneAutomationPolicyScope,
11
+ ControlPlaneConversationRecord,
12
+ ControlPlaneDirectoryRecord,
13
+ ControlPlaneGitHubCiRollup,
14
+ ControlPlaneGitHubPrJobRecord,
15
+ ControlPlaneGitHubPullRequestRecord,
16
+ ControlPlaneGitHubSyncStateRecord,
17
+ ControlPlaneProjectSettingsRecord,
18
+ ControlPlaneRepositoryRecord,
19
+ ControlPlaneTaskRecord,
20
+ } from '../store/control-plane-store.ts';
21
+ import type { TerminalBufferTail, TerminalSnapshotFrame } from '../terminal/snapshot-oracle.ts';
22
+ import type { PtyExit } from '../pty/pty_host.ts';
23
+
24
+ const DEFAULT_TENANT_ID = 'tenant-local';
25
+ const DEFAULT_USER_ID = 'user-local';
26
+ const DEFAULT_WORKSPACE_ID = 'workspace-local';
27
+
28
+ interface StreamSubscriptionFilter {
29
+ tenantId?: string;
30
+ userId?: string;
31
+ workspaceId?: string;
32
+ repositoryId?: string;
33
+ taskId?: string;
34
+ directoryId?: string;
35
+ conversationId?: string;
36
+ includeOutput: boolean;
37
+ }
38
+
39
+ interface StreamObservedScope {
40
+ tenantId: string;
41
+ userId: string;
42
+ workspaceId: string;
43
+ directoryId: string | null;
44
+ conversationId: string | null;
45
+ }
46
+
47
+ interface StreamJournalEntry {
48
+ cursor: number;
49
+ scope: StreamObservedScope;
50
+ event: StreamObservedEvent;
51
+ }
52
+
53
+ interface SessionControllerState extends StreamSessionController {
54
+ connectionId: string;
55
+ }
56
+
57
+ interface StartSessionRuntimeInput {
58
+ readonly sessionId: string;
59
+ readonly args: readonly string[];
60
+ readonly initialCols: number;
61
+ readonly initialRows: number;
62
+ readonly env?: Record<string, string>;
63
+ readonly cwd?: string;
64
+ readonly tenantId?: string;
65
+ readonly userId?: string;
66
+ readonly workspaceId?: string;
67
+ readonly worktreeId?: string;
68
+ readonly terminalForegroundHex?: string;
69
+ readonly terminalBackgroundHex?: string;
70
+ }
71
+
72
+ interface LiveSessionLike {
73
+ attach(
74
+ handlers: {
75
+ onData: (event: { cursor: number; chunk: Buffer }) => void;
76
+ onExit: (exit: PtyExit) => void;
77
+ },
78
+ sinceCursor?: number,
79
+ ): string;
80
+ detach(attachmentId: string): void;
81
+ latestCursorValue(): number;
82
+ write(data: string | Uint8Array): void;
83
+ snapshot(): TerminalSnapshotFrame;
84
+ bufferTail?(tailLines?: number): TerminalBufferTail;
85
+ }
86
+
87
+ interface SessionState {
88
+ id: string;
89
+ directoryId: string | null;
90
+ tenantId: string;
91
+ userId: string;
92
+ workspaceId: string;
93
+ worktreeId: string;
94
+ session: LiveSessionLike | null;
95
+ eventSubscriberConnectionIds: Set<string>;
96
+ attachmentByConnectionId: Map<string, string>;
97
+ status: StreamSessionRuntimeStatus;
98
+ attentionReason: string | null;
99
+ lastEventAt: string | null;
100
+ lastExit: PtyExit | null;
101
+ lastSnapshot: Record<string, unknown> | null;
102
+ startedAt: string;
103
+ exitedAt: string | null;
104
+ lastObservedOutputCursor: number;
105
+ controller: SessionControllerState | null;
106
+ }
107
+
108
+ interface ConnectionState {
109
+ id: string;
110
+ attachedSessionIds: Set<string>;
111
+ eventSessionIds: Set<string>;
112
+ streamSubscriptionIds: Set<string>;
113
+ }
114
+
115
+ interface DirectoryGitStatusCacheEntry {
116
+ readonly summary: {
117
+ branch: string | null;
118
+ changedFiles: number;
119
+ additions: number;
120
+ deletions: number;
121
+ };
122
+ readonly repositorySnapshot: {
123
+ normalizedRemoteUrl: string | null;
124
+ commitCount: number | null;
125
+ lastCommitAt: string | null;
126
+ shortCommitHash: string | null;
127
+ inferredName: string | null;
128
+ defaultBranch: string | null;
129
+ };
130
+ readonly repositoryId: string | null;
131
+ readonly lastRefreshedAtMs: number;
132
+ }
133
+
134
+ interface ExecuteCommandContext {
135
+ readonly stateStore: {
136
+ upsertDirectory(input: {
137
+ directoryId: string;
138
+ tenantId: string;
139
+ userId: string;
140
+ workspaceId: string;
141
+ path: string;
142
+ }): ControlPlaneDirectoryRecord;
143
+ getDirectory(directoryId: string): ControlPlaneDirectoryRecord | null;
144
+ listDirectories(query: {
145
+ tenantId?: string;
146
+ userId?: string;
147
+ workspaceId?: string;
148
+ includeArchived?: boolean;
149
+ limit?: number;
150
+ }): ControlPlaneDirectoryRecord[];
151
+ archiveDirectory(directoryId: string): ControlPlaneDirectoryRecord;
152
+ createConversation(input: {
153
+ conversationId: string;
154
+ directoryId: string;
155
+ title: string;
156
+ agentType: string;
157
+ adapterState?: Record<string, unknown>;
158
+ }): ControlPlaneConversationRecord;
159
+ listConversations(query: {
160
+ directoryId?: string;
161
+ tenantId?: string;
162
+ userId?: string;
163
+ workspaceId?: string;
164
+ includeArchived?: boolean;
165
+ limit?: number;
166
+ }): ControlPlaneConversationRecord[];
167
+ archiveConversation(conversationId: string): ControlPlaneConversationRecord;
168
+ updateConversationTitle(
169
+ conversationId: string,
170
+ title: string,
171
+ ): ControlPlaneConversationRecord | null;
172
+ getConversation(conversationId: string): ControlPlaneConversationRecord | null;
173
+ deleteConversation(conversationId: string): void;
174
+ upsertRepository(input: {
175
+ repositoryId: string;
176
+ tenantId: string;
177
+ userId: string;
178
+ workspaceId: string;
179
+ name: string;
180
+ remoteUrl: string;
181
+ defaultBranch?: string;
182
+ metadata?: Record<string, unknown>;
183
+ }): ControlPlaneRepositoryRecord;
184
+ getRepository(repositoryId: string): ControlPlaneRepositoryRecord | null;
185
+ listRepositories(query: {
186
+ tenantId?: string;
187
+ userId?: string;
188
+ workspaceId?: string;
189
+ includeArchived?: boolean;
190
+ limit?: number;
191
+ }): ControlPlaneRepositoryRecord[];
192
+ updateRepository(
193
+ repositoryId: string,
194
+ input: {
195
+ name?: string;
196
+ remoteUrl?: string;
197
+ defaultBranch?: string;
198
+ metadata?: Record<string, unknown>;
199
+ },
200
+ ): ControlPlaneRepositoryRecord | null;
201
+ archiveRepository(repositoryId: string): ControlPlaneRepositoryRecord;
202
+ createTask(input: {
203
+ taskId: string;
204
+ tenantId: string;
205
+ userId: string;
206
+ workspaceId: string;
207
+ repositoryId?: string;
208
+ projectId?: string;
209
+ title: string;
210
+ description?: string;
211
+ linear?: Record<string, unknown>;
212
+ }): ControlPlaneTaskRecord;
213
+ getTask(taskId: string): ControlPlaneTaskRecord | null;
214
+ listTasks(query: {
215
+ tenantId?: string;
216
+ userId?: string;
217
+ workspaceId?: string;
218
+ repositoryId?: string;
219
+ projectId?: string;
220
+ scopeKind?: 'global' | 'repository' | 'project';
221
+ status?: 'draft' | 'ready' | 'in-progress' | 'completed';
222
+ limit?: number;
223
+ }): ControlPlaneTaskRecord[];
224
+ updateTask(
225
+ taskId: string,
226
+ input: {
227
+ title?: string;
228
+ description?: string;
229
+ repositoryId?: string | null;
230
+ projectId?: string | null;
231
+ linear?: Record<string, unknown> | null;
232
+ },
233
+ ): ControlPlaneTaskRecord | null;
234
+ deleteTask(taskId: string): void;
235
+ claimTask(input: {
236
+ taskId: string;
237
+ controllerId: string;
238
+ directoryId?: string;
239
+ branchName?: string;
240
+ baseBranch?: string;
241
+ }): ControlPlaneTaskRecord;
242
+ completeTask(taskId: string): ControlPlaneTaskRecord;
243
+ readyTask(taskId: string): ControlPlaneTaskRecord;
244
+ draftTask(taskId: string): ControlPlaneTaskRecord;
245
+ reorderTasks(input: {
246
+ tenantId: string;
247
+ userId: string;
248
+ workspaceId: string;
249
+ orderedTaskIds: readonly string[];
250
+ }): ControlPlaneTaskRecord[];
251
+ getProjectSettings(directoryId: string): ControlPlaneProjectSettingsRecord;
252
+ updateProjectSettings(input: {
253
+ directoryId: string;
254
+ pinnedBranch?: string | null;
255
+ taskFocusMode?: 'balanced' | 'own-only';
256
+ threadSpawnMode?: 'new-thread' | 'reuse-thread';
257
+ }): ControlPlaneProjectSettingsRecord;
258
+ getAutomationPolicy(input: {
259
+ tenantId: string;
260
+ userId: string;
261
+ workspaceId: string;
262
+ scope: ControlPlaneAutomationPolicyScope;
263
+ scopeId?: string | null;
264
+ }): ControlPlaneAutomationPolicyRecord | null;
265
+ updateAutomationPolicy(input: {
266
+ tenantId: string;
267
+ userId: string;
268
+ workspaceId: string;
269
+ scope: ControlPlaneAutomationPolicyScope;
270
+ scopeId?: string | null;
271
+ automationEnabled?: boolean;
272
+ frozen?: boolean;
273
+ }): ControlPlaneAutomationPolicyRecord;
274
+ upsertGitHubPullRequest(input: {
275
+ prRecordId: string;
276
+ tenantId: string;
277
+ userId: string;
278
+ workspaceId: string;
279
+ repositoryId: string;
280
+ directoryId?: string | null;
281
+ owner: string;
282
+ repo: string;
283
+ number: number;
284
+ title: string;
285
+ url: string;
286
+ authorLogin?: string | null;
287
+ headBranch: string;
288
+ headSha: string;
289
+ baseBranch: string;
290
+ state: 'open' | 'closed';
291
+ isDraft: boolean;
292
+ ciRollup?: ControlPlaneGitHubCiRollup;
293
+ closedAt?: string | null;
294
+ observedAt: string;
295
+ }): ControlPlaneGitHubPullRequestRecord;
296
+ getGitHubPullRequest(prRecordId: string): ControlPlaneGitHubPullRequestRecord | null;
297
+ listGitHubPullRequests(query?: {
298
+ tenantId?: string;
299
+ userId?: string;
300
+ workspaceId?: string;
301
+ repositoryId?: string;
302
+ directoryId?: string;
303
+ headBranch?: string;
304
+ state?: 'open' | 'closed';
305
+ limit?: number;
306
+ }): ControlPlaneGitHubPullRequestRecord[];
307
+ updateGitHubPullRequestCiRollup(
308
+ prRecordId: string,
309
+ ciRollup: ControlPlaneGitHubCiRollup,
310
+ observedAt: string,
311
+ ): ControlPlaneGitHubPullRequestRecord | null;
312
+ replaceGitHubPrJobs(input: {
313
+ tenantId: string;
314
+ userId: string;
315
+ workspaceId: string;
316
+ repositoryId: string;
317
+ prRecordId: string;
318
+ observedAt: string;
319
+ jobs: readonly {
320
+ jobRecordId: string;
321
+ provider: 'check-run' | 'status-context';
322
+ externalId: string;
323
+ name: string;
324
+ status: string;
325
+ conclusion?: string | null;
326
+ url?: string | null;
327
+ startedAt?: string | null;
328
+ completedAt?: string | null;
329
+ }[];
330
+ }): ControlPlaneGitHubPrJobRecord[];
331
+ listGitHubPrJobs(query?: {
332
+ tenantId?: string;
333
+ userId?: string;
334
+ workspaceId?: string;
335
+ repositoryId?: string;
336
+ prRecordId?: string;
337
+ limit?: number;
338
+ }): ControlPlaneGitHubPrJobRecord[];
339
+ upsertGitHubSyncState(input: {
340
+ stateId: string;
341
+ tenantId: string;
342
+ userId: string;
343
+ workspaceId: string;
344
+ repositoryId: string;
345
+ directoryId?: string | null;
346
+ branchName: string;
347
+ lastSyncAt: string;
348
+ lastSuccessAt?: string | null;
349
+ lastError?: string | null;
350
+ lastErrorAt?: string | null;
351
+ }): ControlPlaneGitHubSyncStateRecord;
352
+ listGitHubSyncState(query?: {
353
+ tenantId?: string;
354
+ userId?: string;
355
+ workspaceId?: string;
356
+ repositoryId?: string;
357
+ directoryId?: string;
358
+ branchName?: string;
359
+ limit?: number;
360
+ }): ControlPlaneGitHubSyncStateRecord[];
361
+ };
362
+ readonly gitStatusDirectoriesById: Map<string, ControlPlaneDirectoryRecord>;
363
+ readonly gitStatusMonitor: {
364
+ enabled: boolean;
365
+ };
366
+ readonly gitStatusByDirectoryId: Map<string, DirectoryGitStatusCacheEntry>;
367
+ readonly streamSubscriptions: Map<
368
+ string,
369
+ {
370
+ id: string;
371
+ connectionId: string;
372
+ filter: StreamSubscriptionFilter;
373
+ }
374
+ >;
375
+ readonly connections: Map<string, ConnectionState>;
376
+ readonly streamJournal: StreamJournalEntry[];
377
+ readonly sessions: Map<string, SessionState>;
378
+ readonly github: {
379
+ enabled: boolean;
380
+ branchStrategy: 'pinned-then-current' | 'current-only' | 'pinned-only';
381
+ viewerLogin: string | null;
382
+ };
383
+ readonly githubApi: {
384
+ openPullRequestForBranch(input: { owner: string; repo: string; headBranch: string }): Promise<{
385
+ number: number;
386
+ title: string;
387
+ url: string;
388
+ authorLogin: string | null;
389
+ headBranch: string;
390
+ headSha: string;
391
+ baseBranch: string;
392
+ state: 'open' | 'closed';
393
+ isDraft: boolean;
394
+ updatedAt: string;
395
+ createdAt: string;
396
+ closedAt: string | null;
397
+ } | null>;
398
+ createPullRequest(input: {
399
+ owner: string;
400
+ repo: string;
401
+ title: string;
402
+ body: string;
403
+ head: string;
404
+ base: string;
405
+ draft: boolean;
406
+ }): Promise<{
407
+ number: number;
408
+ title: string;
409
+ url: string;
410
+ authorLogin: string | null;
411
+ headBranch: string;
412
+ headSha: string;
413
+ baseBranch: string;
414
+ state: 'open' | 'closed';
415
+ isDraft: boolean;
416
+ updatedAt: string;
417
+ createdAt: string;
418
+ closedAt: string | null;
419
+ }>;
420
+ };
421
+ readonly streamCursor: number;
422
+ refreshGitStatusForDirectory(
423
+ directory: ControlPlaneDirectoryRecord,
424
+ options?: {
425
+ readonly forcePublish?: boolean;
426
+ },
427
+ ): Promise<void>;
428
+ directoryRecord(directory: ControlPlaneDirectoryRecord): Record<string, unknown>;
429
+ conversationRecord(conversation: ControlPlaneConversationRecord): Record<string, unknown>;
430
+ repositoryRecord(repository: ControlPlaneRepositoryRecord): Record<string, unknown>;
431
+ taskRecord(task: ControlPlaneTaskRecord): Record<string, unknown>;
432
+ publishObservedEvent(scope: StreamObservedScope, event: StreamObservedEvent): void;
433
+ matchesObservedFilter(
434
+ scope: StreamObservedScope,
435
+ event: StreamObservedEvent,
436
+ filter: StreamSubscriptionFilter,
437
+ ): boolean;
438
+ diagnosticSessionIdForObservedEvent(
439
+ scope: StreamObservedScope,
440
+ event: StreamObservedEvent,
441
+ ): string | null;
442
+ sendToConnection(
443
+ connectionId: string,
444
+ envelope: Record<string, unknown>,
445
+ diagnosticSessionId?: string | null,
446
+ ): void;
447
+ sortSessionSummaries(
448
+ sessions: readonly SessionState[],
449
+ sort: 'attention-first' | 'started-desc' | 'started-asc',
450
+ ): ReadonlyArray<Record<string, unknown>>;
451
+ requireSession(sessionId: string): SessionState;
452
+ requireLiveSession(sessionId: string): SessionState & { session: LiveSessionLike };
453
+ sessionSummaryRecord(state: SessionState): Record<string, unknown>;
454
+ snapshotRecordFromFrame(frame: TerminalSnapshotFrame): Record<string, unknown>;
455
+ toPublicSessionController(
456
+ controller: SessionControllerState | null,
457
+ ): StreamSessionController | null;
458
+ controllerDisplayName(controller: SessionControllerState): string;
459
+ publishSessionControlObservedEvent(
460
+ state: SessionState,
461
+ action: 'claimed' | 'taken-over' | 'released',
462
+ controller: StreamSessionController | null,
463
+ previousController: StreamSessionController | null,
464
+ reason: string | null,
465
+ ): void;
466
+ publishStatusObservedEvent(state: SessionState): void;
467
+ assertConnectionCanMutateSession(connectionId: string, state: SessionState): void;
468
+ setSessionStatus(
469
+ state: SessionState,
470
+ status: StreamSessionRuntimeStatus,
471
+ attentionReason: string | null,
472
+ ts: string,
473
+ ): void;
474
+ destroySession(sessionId: string, closeSession: boolean): void;
475
+ startSessionRuntime(command: StartSessionRuntimeInput): void;
476
+ detachConnectionFromSession(connectionId: string, sessionId: string): void;
477
+ sessionScope(state: SessionState): StreamObservedScope;
478
+ }
479
+
480
+ function normalizeAdapterState(value: unknown): Record<string, unknown> {
481
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
482
+ return {};
483
+ }
484
+ return value as Record<string, unknown>;
485
+ }
486
+
487
+ function asRecord(value: unknown): Record<string, unknown> | null {
488
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
489
+ return null;
490
+ }
491
+ return value as Record<string, unknown>;
492
+ }
493
+
494
+ function asStringArray(value: unknown): string[] | null {
495
+ if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) {
496
+ return null;
497
+ }
498
+ return [...value];
499
+ }
500
+
501
+ function bufferTailFromVisibleLines(
502
+ lines: readonly string[],
503
+ totalRows: number,
504
+ tailLines: number,
505
+ ): TerminalBufferTail {
506
+ const availableCount = lines.length;
507
+ const rowCount = Math.min(availableCount, tailLines);
508
+ const startRow = Math.max(0, totalRows - rowCount);
509
+ return {
510
+ totalRows,
511
+ startRow,
512
+ lines: lines.slice(Math.max(0, availableCount - rowCount)),
513
+ };
514
+ }
515
+
516
+ function bufferTailFromFrame(frame: TerminalSnapshotFrame, tailLines: number): TerminalBufferTail {
517
+ return bufferTailFromVisibleLines(frame.lines, frame.viewport.totalRows, tailLines);
518
+ }
519
+
520
+ function bufferTailFromSnapshotRecord(
521
+ snapshot: Record<string, unknown>,
522
+ tailLines: number,
523
+ ): TerminalBufferTail {
524
+ const lines = asStringArray(snapshot['lines']) ?? [];
525
+ const viewport = asRecord(snapshot['viewport']);
526
+ const totalRowsRaw = viewport?.['totalRows'];
527
+ const totalRows =
528
+ typeof totalRowsRaw === 'number' && Number.isInteger(totalRowsRaw) && totalRowsRaw >= 0
529
+ ? totalRowsRaw
530
+ : lines.length;
531
+ return bufferTailFromVisibleLines(lines, totalRows, tailLines);
532
+ }
533
+
534
+ function parseGitHubOwnerRepo(remoteUrl: string): { owner: string; repo: string } | null {
535
+ const trimmed = remoteUrl.trim();
536
+ if (trimmed.length === 0) {
537
+ return null;
538
+ }
539
+ const httpsMatch = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/iu.exec(trimmed);
540
+ if (httpsMatch !== null) {
541
+ return {
542
+ owner: httpsMatch[1] as string,
543
+ repo: httpsMatch[2] as string,
544
+ };
545
+ }
546
+ const sshMatch = /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/iu.exec(trimmed);
547
+ if (sshMatch !== null) {
548
+ return {
549
+ owner: sshMatch[1] as string,
550
+ repo: sshMatch[2] as string,
551
+ };
552
+ }
553
+ return null;
554
+ }
555
+
556
+ function resolveTrackedBranch(input: {
557
+ strategy: 'pinned-then-current' | 'current-only' | 'pinned-only';
558
+ pinnedBranch: string | null;
559
+ currentBranch: string | null;
560
+ }): {
561
+ branchName: string | null;
562
+ source: 'pinned' | 'current' | null;
563
+ } {
564
+ if (input.strategy === 'pinned-only') {
565
+ return {
566
+ branchName: input.pinnedBranch,
567
+ source: input.pinnedBranch === null ? null : 'pinned',
568
+ };
569
+ }
570
+ if (input.strategy === 'current-only') {
571
+ return {
572
+ branchName: input.currentBranch,
573
+ source: input.currentBranch === null ? null : 'current',
574
+ };
575
+ }
576
+ if (input.pinnedBranch !== null) {
577
+ return {
578
+ branchName: input.pinnedBranch,
579
+ source: 'pinned',
580
+ };
581
+ }
582
+ return {
583
+ branchName: input.currentBranch,
584
+ source: input.currentBranch === null ? null : 'current',
585
+ };
586
+ }
587
+
588
+ function ciRollupFromJobs(
589
+ jobs: readonly {
590
+ status: string;
591
+ conclusion: string | null;
592
+ }[],
593
+ ): ControlPlaneGitHubCiRollup {
594
+ if (jobs.length === 0) {
595
+ return 'none';
596
+ }
597
+ let hasPending = false;
598
+ let hasFailure = false;
599
+ let hasCancelled = false;
600
+ for (const job of jobs) {
601
+ const status = job.status.toLowerCase();
602
+ const conclusion = job.conclusion?.toLowerCase() ?? null;
603
+ if (status !== 'completed') {
604
+ hasPending = true;
605
+ continue;
606
+ }
607
+ if (
608
+ conclusion === 'failure' ||
609
+ conclusion === 'timed_out' ||
610
+ conclusion === 'action_required'
611
+ ) {
612
+ hasFailure = true;
613
+ continue;
614
+ }
615
+ if (conclusion === 'cancelled') {
616
+ hasCancelled = true;
617
+ }
618
+ }
619
+ if (hasFailure) {
620
+ return 'failure';
621
+ }
622
+ if (hasPending) {
623
+ return 'pending';
624
+ }
625
+ if (hasCancelled) {
626
+ return 'cancelled';
627
+ }
628
+ if (
629
+ jobs.some((job) => {
630
+ const conclusion = job.conclusion?.toLowerCase() ?? null;
631
+ return conclusion === 'success';
632
+ })
633
+ ) {
634
+ return 'success';
635
+ }
636
+ return 'neutral';
637
+ }
638
+
639
+ export const streamServerCommandTestInternals = {
640
+ parseGitHubOwnerRepo,
641
+ resolveTrackedBranch,
642
+ ciRollupFromJobs,
643
+ };
644
+
645
+ export async function executeStreamServerCommand(
646
+ ctx: ExecuteCommandContext,
647
+ connection: ConnectionState,
648
+ command: StreamCommand,
649
+ ): Promise<Record<string, unknown>> {
650
+ const liveThreadCountByDirectory = (directoryId: string): number =>
651
+ [...ctx.sessions.values()].filter(
652
+ (sessionState) => sessionState.directoryId === directoryId && sessionState.session !== null,
653
+ ).length;
654
+
655
+ const effectiveAutomationPolicy = (input: {
656
+ tenantId: string;
657
+ userId: string;
658
+ workspaceId: string;
659
+ repositoryId: string | null;
660
+ directoryId: string;
661
+ }): {
662
+ automationEnabled: boolean;
663
+ frozen: boolean;
664
+ source: 'default' | 'global' | 'repository' | 'project';
665
+ } => {
666
+ const globalPolicy = ctx.stateStore.getAutomationPolicy({
667
+ tenantId: input.tenantId,
668
+ userId: input.userId,
669
+ workspaceId: input.workspaceId,
670
+ scope: 'global',
671
+ scopeId: null,
672
+ });
673
+ const repositoryPolicy =
674
+ input.repositoryId === null
675
+ ? null
676
+ : ctx.stateStore.getAutomationPolicy({
677
+ tenantId: input.tenantId,
678
+ userId: input.userId,
679
+ workspaceId: input.workspaceId,
680
+ scope: 'repository',
681
+ scopeId: input.repositoryId,
682
+ });
683
+ const projectPolicy = ctx.stateStore.getAutomationPolicy({
684
+ tenantId: input.tenantId,
685
+ userId: input.userId,
686
+ workspaceId: input.workspaceId,
687
+ scope: 'project',
688
+ scopeId: input.directoryId,
689
+ });
690
+ if (projectPolicy !== null) {
691
+ return {
692
+ automationEnabled: projectPolicy.automationEnabled,
693
+ frozen: projectPolicy.frozen,
694
+ source: 'project',
695
+ };
696
+ }
697
+ if (repositoryPolicy !== null) {
698
+ return {
699
+ automationEnabled: repositoryPolicy.automationEnabled,
700
+ frozen: repositoryPolicy.frozen,
701
+ source: 'repository',
702
+ };
703
+ }
704
+ if (globalPolicy !== null) {
705
+ return {
706
+ automationEnabled: globalPolicy.automationEnabled,
707
+ frozen: globalPolicy.frozen,
708
+ source: 'global',
709
+ };
710
+ }
711
+ return {
712
+ automationEnabled: true,
713
+ frozen: false,
714
+ source: 'default',
715
+ };
716
+ };
717
+
718
+ const evaluateProjectAvailability = (input: {
719
+ directory: ControlPlaneDirectoryRecord;
720
+ requiredRepositoryId: string | null;
721
+ }): {
722
+ availability:
723
+ | 'ready'
724
+ | 'blocked-disabled'
725
+ | 'blocked-frozen'
726
+ | 'blocked-untracked'
727
+ | 'blocked-pinned-branch'
728
+ | 'blocked-dirty'
729
+ | 'blocked-occupied'
730
+ | 'blocked-repository-mismatch';
731
+ reason: string | null;
732
+ settings: ControlPlaneProjectSettingsRecord;
733
+ repositoryId: string | null;
734
+ branch: string | null;
735
+ changedFiles: number;
736
+ liveThreadCount: number;
737
+ automationEnabled: boolean;
738
+ frozen: boolean;
739
+ automationSource: 'default' | 'global' | 'repository' | 'project';
740
+ } => {
741
+ const settings = ctx.stateStore.getProjectSettings(input.directory.directoryId);
742
+ const gitStatus = ctx.gitStatusByDirectoryId.get(input.directory.directoryId);
743
+ const repositoryId = gitStatus?.repositoryId ?? null;
744
+ const branch = gitStatus?.summary.branch ?? null;
745
+ const changedFiles = gitStatus?.summary.changedFiles ?? 0;
746
+ const liveThreadCount = liveThreadCountByDirectory(input.directory.directoryId);
747
+ const automation = effectiveAutomationPolicy({
748
+ tenantId: input.directory.tenantId,
749
+ userId: input.directory.userId,
750
+ workspaceId: input.directory.workspaceId,
751
+ repositoryId,
752
+ directoryId: input.directory.directoryId,
753
+ });
754
+ if (!automation.automationEnabled) {
755
+ return {
756
+ availability: 'blocked-disabled',
757
+ reason: 'automation disabled',
758
+ settings,
759
+ repositoryId,
760
+ branch,
761
+ changedFiles,
762
+ liveThreadCount,
763
+ automationEnabled: automation.automationEnabled,
764
+ frozen: automation.frozen,
765
+ automationSource: automation.source,
766
+ };
767
+ }
768
+ if (automation.frozen) {
769
+ return {
770
+ availability: 'blocked-frozen',
771
+ reason: 'project/repository/global automation freeze',
772
+ settings,
773
+ repositoryId,
774
+ branch,
775
+ changedFiles,
776
+ liveThreadCount,
777
+ automationEnabled: automation.automationEnabled,
778
+ frozen: automation.frozen,
779
+ automationSource: automation.source,
780
+ };
781
+ }
782
+ if (gitStatus === undefined || repositoryId === null || branch === null) {
783
+ return {
784
+ availability: 'blocked-untracked',
785
+ reason: 'project has no tracked repository status',
786
+ settings,
787
+ repositoryId,
788
+ branch,
789
+ changedFiles,
790
+ liveThreadCount,
791
+ automationEnabled: automation.automationEnabled,
792
+ frozen: automation.frozen,
793
+ automationSource: automation.source,
794
+ };
795
+ }
796
+ if (input.requiredRepositoryId !== null && repositoryId !== input.requiredRepositoryId) {
797
+ return {
798
+ availability: 'blocked-repository-mismatch',
799
+ reason: 'project repository does not match requested repository',
800
+ settings,
801
+ repositoryId,
802
+ branch,
803
+ changedFiles,
804
+ liveThreadCount,
805
+ automationEnabled: automation.automationEnabled,
806
+ frozen: automation.frozen,
807
+ automationSource: automation.source,
808
+ };
809
+ }
810
+ if (settings.pinnedBranch !== null && settings.pinnedBranch !== branch) {
811
+ return {
812
+ availability: 'blocked-pinned-branch',
813
+ reason: `project pinned to ${settings.pinnedBranch} but current branch is ${branch}`,
814
+ settings,
815
+ repositoryId,
816
+ branch,
817
+ changedFiles,
818
+ liveThreadCount,
819
+ automationEnabled: automation.automationEnabled,
820
+ frozen: automation.frozen,
821
+ automationSource: automation.source,
822
+ };
823
+ }
824
+ if (changedFiles > 0) {
825
+ return {
826
+ availability: 'blocked-dirty',
827
+ reason: 'project has pending git changes',
828
+ settings,
829
+ repositoryId,
830
+ branch,
831
+ changedFiles,
832
+ liveThreadCount,
833
+ automationEnabled: automation.automationEnabled,
834
+ frozen: automation.frozen,
835
+ automationSource: automation.source,
836
+ };
837
+ }
838
+ if (liveThreadCount > 0) {
839
+ return {
840
+ availability: 'blocked-occupied',
841
+ reason: 'project has a live thread',
842
+ settings,
843
+ repositoryId,
844
+ branch,
845
+ changedFiles,
846
+ liveThreadCount,
847
+ automationEnabled: automation.automationEnabled,
848
+ frozen: automation.frozen,
849
+ automationSource: automation.source,
850
+ };
851
+ }
852
+ return {
853
+ availability: 'ready',
854
+ reason: null,
855
+ settings,
856
+ repositoryId,
857
+ branch,
858
+ changedFiles,
859
+ liveThreadCount,
860
+ automationEnabled: automation.automationEnabled,
861
+ frozen: automation.frozen,
862
+ automationSource: automation.source,
863
+ };
864
+ };
865
+
866
+ const resolveProjectGitHubContext = (
867
+ directoryId: string,
868
+ ): {
869
+ directory: ControlPlaneDirectoryRecord;
870
+ repository: ControlPlaneRepositoryRecord | null;
871
+ ownerRepo: { owner: string; repo: string } | null;
872
+ currentBranch: string | null;
873
+ trackedBranch: string | null;
874
+ trackedBranchSource: 'pinned' | 'current' | null;
875
+ settings: ControlPlaneProjectSettingsRecord;
876
+ } => {
877
+ const directory = ctx.stateStore.getDirectory(directoryId);
878
+ if (directory === null || directory.archivedAt !== null) {
879
+ throw new Error(`directory not found: ${directoryId}`);
880
+ }
881
+ const settings = ctx.stateStore.getProjectSettings(directoryId);
882
+ const gitStatus = ctx.gitStatusByDirectoryId.get(directoryId);
883
+ const repository =
884
+ gitStatus?.repositoryId === null || gitStatus?.repositoryId === undefined
885
+ ? null
886
+ : ctx.stateStore.getRepository(gitStatus.repositoryId);
887
+ const activeRepository =
888
+ repository === null || repository.archivedAt !== null ? null : repository;
889
+ const ownerRepo =
890
+ activeRepository === null ? null : parseGitHubOwnerRepo(activeRepository.remoteUrl);
891
+ const currentBranch = gitStatus?.summary.branch ?? null;
892
+ const tracked = resolveTrackedBranch({
893
+ strategy: ctx.github.branchStrategy,
894
+ pinnedBranch: settings.pinnedBranch,
895
+ currentBranch,
896
+ });
897
+ return {
898
+ directory,
899
+ repository: activeRepository,
900
+ ownerRepo,
901
+ currentBranch,
902
+ trackedBranch: tracked.branchName,
903
+ trackedBranchSource: tracked.source,
904
+ settings,
905
+ };
906
+ };
907
+
908
+ if (command.type === 'directory.upsert') {
909
+ const directory = ctx.stateStore.upsertDirectory({
910
+ directoryId: command.directoryId ?? `directory-${randomUUID()}`,
911
+ tenantId: command.tenantId ?? DEFAULT_TENANT_ID,
912
+ userId: command.userId ?? DEFAULT_USER_ID,
913
+ workspaceId: command.workspaceId ?? DEFAULT_WORKSPACE_ID,
914
+ path: command.path,
915
+ });
916
+ const record = ctx.directoryRecord(directory);
917
+ ctx.gitStatusDirectoriesById.set(directory.directoryId, directory);
918
+ ctx.publishObservedEvent(
919
+ {
920
+ tenantId: directory.tenantId,
921
+ userId: directory.userId,
922
+ workspaceId: directory.workspaceId,
923
+ directoryId: directory.directoryId,
924
+ conversationId: null,
925
+ },
926
+ {
927
+ type: 'directory-upserted',
928
+ directory: record,
929
+ },
930
+ );
931
+ if (ctx.gitStatusMonitor.enabled) {
932
+ void ctx.refreshGitStatusForDirectory(directory, {
933
+ forcePublish: true,
934
+ });
935
+ }
936
+ return {
937
+ directory: record,
938
+ };
939
+ }
940
+
941
+ if (command.type === 'directory.list') {
942
+ const query: {
943
+ tenantId?: string;
944
+ userId?: string;
945
+ workspaceId?: string;
946
+ includeArchived?: boolean;
947
+ limit?: number;
948
+ } = {};
949
+ if (command.tenantId !== undefined) {
950
+ query.tenantId = command.tenantId;
951
+ }
952
+ if (command.userId !== undefined) {
953
+ query.userId = command.userId;
954
+ }
955
+ if (command.workspaceId !== undefined) {
956
+ query.workspaceId = command.workspaceId;
957
+ }
958
+ if (command.includeArchived !== undefined) {
959
+ query.includeArchived = command.includeArchived;
960
+ }
961
+ if (command.limit !== undefined) {
962
+ query.limit = command.limit;
963
+ }
964
+ const directories = ctx.stateStore
965
+ .listDirectories(query)
966
+ .map((directory) => ctx.directoryRecord(directory));
967
+ return {
968
+ directories,
969
+ };
970
+ }
971
+
972
+ if (command.type === 'directory.archive') {
973
+ const archived = ctx.stateStore.archiveDirectory(command.directoryId);
974
+ const record = ctx.directoryRecord(archived);
975
+ ctx.publishObservedEvent(
976
+ {
977
+ tenantId: archived.tenantId,
978
+ userId: archived.userId,
979
+ workspaceId: archived.workspaceId,
980
+ directoryId: archived.directoryId,
981
+ conversationId: null,
982
+ },
983
+ {
984
+ type: 'directory-archived',
985
+ directoryId: archived.directoryId,
986
+ ts: archived.archivedAt as string,
987
+ },
988
+ );
989
+ ctx.gitStatusByDirectoryId.delete(archived.directoryId);
990
+ ctx.gitStatusDirectoriesById.delete(archived.directoryId);
991
+ return {
992
+ directory: record,
993
+ };
994
+ }
995
+
996
+ if (command.type === 'directory.git-status') {
997
+ const query: {
998
+ tenantId?: string;
999
+ userId?: string;
1000
+ workspaceId?: string;
1001
+ includeArchived: boolean;
1002
+ limit: number;
1003
+ } = {
1004
+ includeArchived: false,
1005
+ limit: 1000,
1006
+ };
1007
+ if (command.tenantId !== undefined) {
1008
+ query.tenantId = command.tenantId;
1009
+ }
1010
+ if (command.userId !== undefined) {
1011
+ query.userId = command.userId;
1012
+ }
1013
+ if (command.workspaceId !== undefined) {
1014
+ query.workspaceId = command.workspaceId;
1015
+ }
1016
+ const listedDirectories = ctx.stateStore
1017
+ .listDirectories(query)
1018
+ .filter((directory) =>
1019
+ command.directoryId === undefined ? true : directory.directoryId === command.directoryId,
1020
+ );
1021
+ for (const directory of listedDirectories) {
1022
+ ctx.gitStatusDirectoriesById.set(directory.directoryId, directory);
1023
+ }
1024
+ const gitStatuses = listedDirectories.flatMap((directory) => {
1025
+ const cached = ctx.gitStatusByDirectoryId.get(directory.directoryId);
1026
+ if (cached === undefined) {
1027
+ return [];
1028
+ }
1029
+ const repositoryRecord =
1030
+ cached.repositoryId === null
1031
+ ? null
1032
+ : (() => {
1033
+ const repository = ctx.stateStore.getRepository(cached.repositoryId);
1034
+ if (repository === null || repository.archivedAt !== null) {
1035
+ return null;
1036
+ }
1037
+ return ctx.repositoryRecord(repository);
1038
+ })();
1039
+ return [
1040
+ {
1041
+ directoryId: directory.directoryId,
1042
+ summary: {
1043
+ branch: cached.summary.branch,
1044
+ changedFiles: cached.summary.changedFiles,
1045
+ additions: cached.summary.additions,
1046
+ deletions: cached.summary.deletions,
1047
+ },
1048
+ repositorySnapshot: {
1049
+ normalizedRemoteUrl: cached.repositorySnapshot.normalizedRemoteUrl,
1050
+ commitCount: cached.repositorySnapshot.commitCount,
1051
+ lastCommitAt: cached.repositorySnapshot.lastCommitAt,
1052
+ shortCommitHash: cached.repositorySnapshot.shortCommitHash,
1053
+ inferredName: cached.repositorySnapshot.inferredName,
1054
+ defaultBranch: cached.repositorySnapshot.defaultBranch,
1055
+ },
1056
+ repositoryId: cached.repositoryId,
1057
+ repository: repositoryRecord,
1058
+ observedAt: new Date(cached.lastRefreshedAtMs).toISOString(),
1059
+ },
1060
+ ];
1061
+ });
1062
+ return {
1063
+ gitStatuses,
1064
+ };
1065
+ }
1066
+
1067
+ if (command.type === 'project.settings-get') {
1068
+ const settings = ctx.stateStore.getProjectSettings(command.directoryId);
1069
+ return {
1070
+ settings,
1071
+ };
1072
+ }
1073
+
1074
+ if (command.type === 'project.settings-update') {
1075
+ const settings = ctx.stateStore.updateProjectSettings({
1076
+ directoryId: command.directoryId,
1077
+ ...(command.pinnedBranch !== undefined ? { pinnedBranch: command.pinnedBranch } : {}),
1078
+ ...(command.taskFocusMode !== undefined ? { taskFocusMode: command.taskFocusMode } : {}),
1079
+ ...(command.threadSpawnMode !== undefined
1080
+ ? { threadSpawnMode: command.threadSpawnMode }
1081
+ : {}),
1082
+ });
1083
+ return {
1084
+ settings,
1085
+ };
1086
+ }
1087
+
1088
+ if (command.type === 'automation.policy-get') {
1089
+ const tenantId = command.tenantId ?? DEFAULT_TENANT_ID;
1090
+ const userId = command.userId ?? DEFAULT_USER_ID;
1091
+ const workspaceId = command.workspaceId ?? DEFAULT_WORKSPACE_ID;
1092
+ const policy = ctx.stateStore.getAutomationPolicy({
1093
+ tenantId,
1094
+ userId,
1095
+ workspaceId,
1096
+ scope: command.scope,
1097
+ scopeId: command.scope === 'global' ? null : (command.scopeId ?? null),
1098
+ }) ?? {
1099
+ policyId: `policy-default-${command.scope}`,
1100
+ tenantId,
1101
+ userId,
1102
+ workspaceId,
1103
+ scope: command.scope,
1104
+ scopeId: command.scope === 'global' ? null : (command.scopeId ?? null),
1105
+ automationEnabled: true,
1106
+ frozen: false,
1107
+ createdAt: new Date(0).toISOString(),
1108
+ updatedAt: new Date(0).toISOString(),
1109
+ };
1110
+ return {
1111
+ policy,
1112
+ };
1113
+ }
1114
+
1115
+ if (command.type === 'automation.policy-set') {
1116
+ const policy = ctx.stateStore.updateAutomationPolicy({
1117
+ tenantId: command.tenantId ?? DEFAULT_TENANT_ID,
1118
+ userId: command.userId ?? DEFAULT_USER_ID,
1119
+ workspaceId: command.workspaceId ?? DEFAULT_WORKSPACE_ID,
1120
+ scope: command.scope,
1121
+ scopeId: command.scope === 'global' ? null : (command.scopeId ?? null),
1122
+ ...(command.automationEnabled !== undefined
1123
+ ? { automationEnabled: command.automationEnabled }
1124
+ : {}),
1125
+ ...(command.frozen !== undefined ? { frozen: command.frozen } : {}),
1126
+ });
1127
+ return {
1128
+ policy,
1129
+ };
1130
+ }
1131
+
1132
+ if (command.type === 'github.project-pr') {
1133
+ const resolved = resolveProjectGitHubContext(command.directoryId);
1134
+ const pr =
1135
+ resolved.repository === null || resolved.trackedBranch === null
1136
+ ? null
1137
+ : (ctx.stateStore.listGitHubPullRequests({
1138
+ repositoryId: resolved.repository.repositoryId,
1139
+ headBranch: resolved.trackedBranch,
1140
+ state: 'open',
1141
+ limit: 1,
1142
+ })[0] ?? null);
1143
+ return {
1144
+ directoryId: resolved.directory.directoryId,
1145
+ repositoryId: resolved.repository?.repositoryId ?? null,
1146
+ branchName: resolved.trackedBranch,
1147
+ branchSource: resolved.trackedBranchSource,
1148
+ repository: resolved.repository === null ? null : ctx.repositoryRecord(resolved.repository),
1149
+ pr,
1150
+ };
1151
+ }
1152
+
1153
+ if (command.type === 'github.pr-list') {
1154
+ const prs = ctx.stateStore.listGitHubPullRequests({
1155
+ ...(command.tenantId === undefined ? {} : { tenantId: command.tenantId }),
1156
+ ...(command.userId === undefined ? {} : { userId: command.userId }),
1157
+ ...(command.workspaceId === undefined ? {} : { workspaceId: command.workspaceId }),
1158
+ ...(command.repositoryId === undefined ? {} : { repositoryId: command.repositoryId }),
1159
+ ...(command.directoryId === undefined ? {} : { directoryId: command.directoryId }),
1160
+ ...(command.headBranch === undefined ? {} : { headBranch: command.headBranch }),
1161
+ ...(command.state === undefined ? {} : { state: command.state }),
1162
+ ...(command.limit === undefined ? {} : { limit: command.limit }),
1163
+ });
1164
+ return {
1165
+ prs,
1166
+ };
1167
+ }
1168
+
1169
+ if (command.type === 'github.pr-create') {
1170
+ if (!ctx.github.enabled) {
1171
+ throw new Error('github integration is disabled');
1172
+ }
1173
+ const resolved = resolveProjectGitHubContext(command.directoryId);
1174
+ if (resolved.repository === null || resolved.ownerRepo === null) {
1175
+ throw new Error('project has no tracked github repository');
1176
+ }
1177
+ const headBranch = command.headBranch ?? resolved.trackedBranch;
1178
+ if (headBranch === null) {
1179
+ throw new Error('project has no tracked branch for github pr');
1180
+ }
1181
+ const existing = ctx.stateStore.listGitHubPullRequests({
1182
+ repositoryId: resolved.repository.repositoryId,
1183
+ headBranch,
1184
+ state: 'open',
1185
+ limit: 1,
1186
+ })[0];
1187
+ if (existing !== undefined) {
1188
+ return {
1189
+ created: false,
1190
+ existing: true,
1191
+ pr: existing,
1192
+ };
1193
+ }
1194
+ const createdAt = new Date().toISOString();
1195
+ const title = command.title ?? `PR: ${headBranch}`;
1196
+ const body = command.body ?? '';
1197
+ const baseBranch = command.baseBranch ?? resolved.repository.defaultBranch;
1198
+ let remotePr: Awaited<ReturnType<ExecuteCommandContext['githubApi']['createPullRequest']>>;
1199
+ try {
1200
+ remotePr = await ctx.githubApi.createPullRequest({
1201
+ owner: resolved.ownerRepo.owner,
1202
+ repo: resolved.ownerRepo.repo,
1203
+ title,
1204
+ body,
1205
+ head: headBranch,
1206
+ base: baseBranch,
1207
+ draft: command.draft ?? false,
1208
+ });
1209
+ } catch {
1210
+ const fallback = await ctx.githubApi.openPullRequestForBranch({
1211
+ owner: resolved.ownerRepo.owner,
1212
+ repo: resolved.ownerRepo.repo,
1213
+ headBranch,
1214
+ });
1215
+ if (fallback === null) {
1216
+ throw new Error('github pr creation failed');
1217
+ }
1218
+ remotePr = fallback;
1219
+ }
1220
+ const stored = ctx.stateStore.upsertGitHubPullRequest({
1221
+ prRecordId: `github-pr-${randomUUID()}`,
1222
+ tenantId: resolved.directory.tenantId,
1223
+ userId: resolved.directory.userId,
1224
+ workspaceId: resolved.directory.workspaceId,
1225
+ repositoryId: resolved.repository.repositoryId,
1226
+ directoryId: resolved.directory.directoryId,
1227
+ owner: resolved.ownerRepo.owner,
1228
+ repo: resolved.ownerRepo.repo,
1229
+ number: remotePr.number,
1230
+ title: remotePr.title,
1231
+ url: remotePr.url,
1232
+ authorLogin: remotePr.authorLogin,
1233
+ headBranch: remotePr.headBranch,
1234
+ headSha: remotePr.headSha,
1235
+ baseBranch: remotePr.baseBranch,
1236
+ state: remotePr.state,
1237
+ isDraft: remotePr.isDraft,
1238
+ ciRollup: 'pending',
1239
+ closedAt: remotePr.closedAt,
1240
+ observedAt: remotePr.updatedAt || createdAt,
1241
+ });
1242
+ ctx.publishObservedEvent(
1243
+ {
1244
+ tenantId: stored.tenantId,
1245
+ userId: stored.userId,
1246
+ workspaceId: stored.workspaceId,
1247
+ directoryId: stored.directoryId,
1248
+ conversationId: null,
1249
+ },
1250
+ {
1251
+ type: 'github-pr-upserted',
1252
+ pr: asRecord(stored) ?? {},
1253
+ },
1254
+ );
1255
+ return {
1256
+ created: true,
1257
+ existing: false,
1258
+ pr: stored,
1259
+ };
1260
+ }
1261
+
1262
+ if (command.type === 'github.pr-jobs-list') {
1263
+ const jobs = ctx.stateStore.listGitHubPrJobs({
1264
+ ...(command.tenantId === undefined ? {} : { tenantId: command.tenantId }),
1265
+ ...(command.userId === undefined ? {} : { userId: command.userId }),
1266
+ ...(command.workspaceId === undefined ? {} : { workspaceId: command.workspaceId }),
1267
+ ...(command.repositoryId === undefined ? {} : { repositoryId: command.repositoryId }),
1268
+ ...(command.prRecordId === undefined ? {} : { prRecordId: command.prRecordId }),
1269
+ ...(command.limit === undefined ? {} : { limit: command.limit }),
1270
+ });
1271
+ return {
1272
+ jobs,
1273
+ ciRollup: ciRollupFromJobs(jobs),
1274
+ };
1275
+ }
1276
+
1277
+ if (command.type === 'github.repo-my-prs-url') {
1278
+ const repository = ctx.stateStore.getRepository(command.repositoryId);
1279
+ if (repository === null || repository.archivedAt !== null) {
1280
+ throw new Error(`repository not found: ${command.repositoryId}`);
1281
+ }
1282
+ const ownerRepo = parseGitHubOwnerRepo(repository.remoteUrl);
1283
+ if (ownerRepo === null) {
1284
+ throw new Error('repository is not a github remote');
1285
+ }
1286
+ const authorQuery = ctx.github.viewerLogin === null ? '@me' : ctx.github.viewerLogin;
1287
+ const query = encodeURIComponent(`is:pr is:open author:${authorQuery}`);
1288
+ return {
1289
+ url: `https://github.com/${ownerRepo.owner}/${ownerRepo.repo}/pulls?q=${query}`,
1290
+ };
1291
+ }
1292
+
1293
+ if (command.type === 'conversation.create') {
1294
+ const conversation = ctx.stateStore.createConversation({
1295
+ conversationId: command.conversationId ?? `conversation-${randomUUID()}`,
1296
+ directoryId: command.directoryId,
1297
+ title: command.title,
1298
+ agentType: command.agentType,
1299
+ adapterState: normalizeAdapterState(command.adapterState),
1300
+ });
1301
+ const record = ctx.conversationRecord(conversation);
1302
+ ctx.publishObservedEvent(
1303
+ {
1304
+ tenantId: conversation.tenantId,
1305
+ userId: conversation.userId,
1306
+ workspaceId: conversation.workspaceId,
1307
+ directoryId: conversation.directoryId,
1308
+ conversationId: conversation.conversationId,
1309
+ },
1310
+ {
1311
+ type: 'conversation-created',
1312
+ conversation: record,
1313
+ },
1314
+ );
1315
+ return {
1316
+ conversation: record,
1317
+ };
1318
+ }
1319
+
1320
+ if (command.type === 'conversation.list') {
1321
+ const query: {
1322
+ directoryId?: string;
1323
+ tenantId?: string;
1324
+ userId?: string;
1325
+ workspaceId?: string;
1326
+ includeArchived?: boolean;
1327
+ limit?: number;
1328
+ } = {};
1329
+ if (command.directoryId !== undefined) {
1330
+ query.directoryId = command.directoryId;
1331
+ }
1332
+ if (command.tenantId !== undefined) {
1333
+ query.tenantId = command.tenantId;
1334
+ }
1335
+ if (command.userId !== undefined) {
1336
+ query.userId = command.userId;
1337
+ }
1338
+ if (command.workspaceId !== undefined) {
1339
+ query.workspaceId = command.workspaceId;
1340
+ }
1341
+ if (command.includeArchived !== undefined) {
1342
+ query.includeArchived = command.includeArchived;
1343
+ }
1344
+ if (command.limit !== undefined) {
1345
+ query.limit = command.limit;
1346
+ }
1347
+ const conversations = ctx.stateStore
1348
+ .listConversations(query)
1349
+ .map((conversation) => ctx.conversationRecord(conversation));
1350
+ return {
1351
+ conversations,
1352
+ };
1353
+ }
1354
+
1355
+ if (command.type === 'conversation.archive') {
1356
+ const archived = ctx.stateStore.archiveConversation(command.conversationId);
1357
+ ctx.publishObservedEvent(
1358
+ {
1359
+ tenantId: archived.tenantId,
1360
+ userId: archived.userId,
1361
+ workspaceId: archived.workspaceId,
1362
+ directoryId: archived.directoryId,
1363
+ conversationId: archived.conversationId,
1364
+ },
1365
+ {
1366
+ type: 'conversation-archived',
1367
+ conversationId: archived.conversationId,
1368
+ ts: archived.archivedAt as string,
1369
+ },
1370
+ );
1371
+ return {
1372
+ conversation: ctx.conversationRecord(archived),
1373
+ };
1374
+ }
1375
+
1376
+ if (command.type === 'conversation.update') {
1377
+ const updated = ctx.stateStore.updateConversationTitle(command.conversationId, command.title);
1378
+ if (updated === null) {
1379
+ throw new Error(`conversation not found: ${command.conversationId}`);
1380
+ }
1381
+ const record = ctx.conversationRecord(updated);
1382
+ ctx.publishObservedEvent(
1383
+ {
1384
+ tenantId: updated.tenantId,
1385
+ userId: updated.userId,
1386
+ workspaceId: updated.workspaceId,
1387
+ directoryId: updated.directoryId,
1388
+ conversationId: updated.conversationId,
1389
+ },
1390
+ {
1391
+ type: 'conversation-updated',
1392
+ conversation: record,
1393
+ },
1394
+ );
1395
+ return {
1396
+ conversation: record,
1397
+ };
1398
+ }
1399
+
1400
+ if (command.type === 'conversation.delete') {
1401
+ const existing = ctx.stateStore.getConversation(command.conversationId);
1402
+ if (existing === null) {
1403
+ throw new Error(`conversation not found: ${command.conversationId}`);
1404
+ }
1405
+ ctx.destroySession(command.conversationId, true);
1406
+ ctx.stateStore.deleteConversation(command.conversationId);
1407
+ ctx.publishObservedEvent(
1408
+ {
1409
+ tenantId: existing.tenantId,
1410
+ userId: existing.userId,
1411
+ workspaceId: existing.workspaceId,
1412
+ directoryId: existing.directoryId,
1413
+ conversationId: existing.conversationId,
1414
+ },
1415
+ {
1416
+ type: 'conversation-deleted',
1417
+ conversationId: existing.conversationId,
1418
+ ts: new Date().toISOString(),
1419
+ },
1420
+ );
1421
+ return {
1422
+ deleted: true,
1423
+ };
1424
+ }
1425
+
1426
+ if (command.type === 'repository.upsert') {
1427
+ const input: {
1428
+ repositoryId: string;
1429
+ tenantId: string;
1430
+ userId: string;
1431
+ workspaceId: string;
1432
+ name: string;
1433
+ remoteUrl: string;
1434
+ defaultBranch?: string;
1435
+ metadata?: Record<string, unknown>;
1436
+ } = {
1437
+ repositoryId: command.repositoryId ?? `repository-${randomUUID()}`,
1438
+ tenantId: command.tenantId ?? DEFAULT_TENANT_ID,
1439
+ userId: command.userId ?? DEFAULT_USER_ID,
1440
+ workspaceId: command.workspaceId ?? DEFAULT_WORKSPACE_ID,
1441
+ name: command.name,
1442
+ remoteUrl: command.remoteUrl,
1443
+ };
1444
+ if (command.defaultBranch !== undefined) {
1445
+ input.defaultBranch = command.defaultBranch;
1446
+ }
1447
+ if (command.metadata !== undefined) {
1448
+ input.metadata = command.metadata;
1449
+ }
1450
+ const repository = ctx.stateStore.upsertRepository(input);
1451
+ ctx.publishObservedEvent(
1452
+ {
1453
+ tenantId: repository.tenantId,
1454
+ userId: repository.userId,
1455
+ workspaceId: repository.workspaceId,
1456
+ directoryId: null,
1457
+ conversationId: null,
1458
+ },
1459
+ {
1460
+ type: 'repository-upserted',
1461
+ repository: ctx.repositoryRecord(repository),
1462
+ },
1463
+ );
1464
+ return {
1465
+ repository: ctx.repositoryRecord(repository),
1466
+ };
1467
+ }
1468
+
1469
+ if (command.type === 'repository.get') {
1470
+ const repository = ctx.stateStore.getRepository(command.repositoryId);
1471
+ if (repository === null) {
1472
+ throw new Error(`repository not found: ${command.repositoryId}`);
1473
+ }
1474
+ return {
1475
+ repository: ctx.repositoryRecord(repository),
1476
+ };
1477
+ }
1478
+
1479
+ if (command.type === 'repository.list') {
1480
+ const query: {
1481
+ tenantId?: string;
1482
+ userId?: string;
1483
+ workspaceId?: string;
1484
+ includeArchived?: boolean;
1485
+ limit?: number;
1486
+ } = {};
1487
+ if (command.tenantId !== undefined) {
1488
+ query.tenantId = command.tenantId;
1489
+ }
1490
+ if (command.userId !== undefined) {
1491
+ query.userId = command.userId;
1492
+ }
1493
+ if (command.workspaceId !== undefined) {
1494
+ query.workspaceId = command.workspaceId;
1495
+ }
1496
+ if (command.includeArchived !== undefined) {
1497
+ query.includeArchived = command.includeArchived;
1498
+ }
1499
+ if (command.limit !== undefined) {
1500
+ query.limit = command.limit;
1501
+ }
1502
+ const repositories = ctx.stateStore
1503
+ .listRepositories(query)
1504
+ .map((repository) => ctx.repositoryRecord(repository));
1505
+ return {
1506
+ repositories,
1507
+ };
1508
+ }
1509
+
1510
+ if (command.type === 'repository.update') {
1511
+ const update: {
1512
+ name?: string;
1513
+ remoteUrl?: string;
1514
+ defaultBranch?: string;
1515
+ metadata?: Record<string, unknown>;
1516
+ } = {};
1517
+ if (command.name !== undefined) {
1518
+ update.name = command.name;
1519
+ }
1520
+ if (command.remoteUrl !== undefined) {
1521
+ update.remoteUrl = command.remoteUrl;
1522
+ }
1523
+ if (command.defaultBranch !== undefined) {
1524
+ update.defaultBranch = command.defaultBranch;
1525
+ }
1526
+ if (command.metadata !== undefined) {
1527
+ update.metadata = command.metadata;
1528
+ }
1529
+ const updated = ctx.stateStore.updateRepository(command.repositoryId, update);
1530
+ if (updated === null) {
1531
+ throw new Error(`repository not found: ${command.repositoryId}`);
1532
+ }
1533
+ ctx.publishObservedEvent(
1534
+ {
1535
+ tenantId: updated.tenantId,
1536
+ userId: updated.userId,
1537
+ workspaceId: updated.workspaceId,
1538
+ directoryId: null,
1539
+ conversationId: null,
1540
+ },
1541
+ {
1542
+ type: 'repository-updated',
1543
+ repository: ctx.repositoryRecord(updated),
1544
+ },
1545
+ );
1546
+ return {
1547
+ repository: ctx.repositoryRecord(updated),
1548
+ };
1549
+ }
1550
+
1551
+ if (command.type === 'repository.archive') {
1552
+ const archived = ctx.stateStore.archiveRepository(command.repositoryId);
1553
+ ctx.publishObservedEvent(
1554
+ {
1555
+ tenantId: archived.tenantId,
1556
+ userId: archived.userId,
1557
+ workspaceId: archived.workspaceId,
1558
+ directoryId: null,
1559
+ conversationId: null,
1560
+ },
1561
+ {
1562
+ type: 'repository-archived',
1563
+ repositoryId: archived.repositoryId,
1564
+ ts: archived.archivedAt as string,
1565
+ },
1566
+ );
1567
+ return {
1568
+ repository: ctx.repositoryRecord(archived),
1569
+ };
1570
+ }
1571
+
1572
+ if (command.type === 'task.create') {
1573
+ const input: {
1574
+ taskId: string;
1575
+ tenantId: string;
1576
+ userId: string;
1577
+ workspaceId: string;
1578
+ repositoryId?: string;
1579
+ projectId?: string;
1580
+ title: string;
1581
+ description?: string;
1582
+ linear?: {
1583
+ issueId?: string | null;
1584
+ identifier?: string | null;
1585
+ url?: string | null;
1586
+ teamId?: string | null;
1587
+ projectId?: string | null;
1588
+ projectMilestoneId?: string | null;
1589
+ cycleId?: string | null;
1590
+ stateId?: string | null;
1591
+ assigneeId?: string | null;
1592
+ priority?: number | null;
1593
+ estimate?: number | null;
1594
+ dueDate?: string | null;
1595
+ labelIds?: readonly string[] | null;
1596
+ };
1597
+ } = {
1598
+ taskId: command.taskId ?? `task-${randomUUID()}`,
1599
+ tenantId: command.tenantId ?? DEFAULT_TENANT_ID,
1600
+ userId: command.userId ?? DEFAULT_USER_ID,
1601
+ workspaceId: command.workspaceId ?? DEFAULT_WORKSPACE_ID,
1602
+ title: command.title,
1603
+ };
1604
+ if (command.repositoryId !== undefined) {
1605
+ input.repositoryId = command.repositoryId;
1606
+ }
1607
+ if (command.projectId !== undefined) {
1608
+ input.projectId = command.projectId;
1609
+ }
1610
+ if (command.description !== undefined) {
1611
+ input.description = command.description;
1612
+ }
1613
+ if (command.linear !== undefined) {
1614
+ input.linear = command.linear;
1615
+ }
1616
+ const task = ctx.stateStore.createTask(input);
1617
+ ctx.publishObservedEvent(
1618
+ {
1619
+ tenantId: task.tenantId,
1620
+ userId: task.userId,
1621
+ workspaceId: task.workspaceId,
1622
+ directoryId: null,
1623
+ conversationId: null,
1624
+ },
1625
+ {
1626
+ type: 'task-created',
1627
+ task: ctx.taskRecord(task),
1628
+ },
1629
+ );
1630
+ return {
1631
+ task: ctx.taskRecord(task),
1632
+ };
1633
+ }
1634
+
1635
+ if (command.type === 'task.get') {
1636
+ const task = ctx.stateStore.getTask(command.taskId);
1637
+ if (task === null) {
1638
+ throw new Error(`task not found: ${command.taskId}`);
1639
+ }
1640
+ return {
1641
+ task: ctx.taskRecord(task),
1642
+ };
1643
+ }
1644
+
1645
+ if (command.type === 'task.list') {
1646
+ const query: {
1647
+ tenantId?: string;
1648
+ userId?: string;
1649
+ workspaceId?: string;
1650
+ repositoryId?: string;
1651
+ projectId?: string;
1652
+ scopeKind?: 'global' | 'repository' | 'project';
1653
+ status?: 'draft' | 'ready' | 'in-progress' | 'completed';
1654
+ limit?: number;
1655
+ } = {};
1656
+ if (command.tenantId !== undefined) {
1657
+ query.tenantId = command.tenantId;
1658
+ }
1659
+ if (command.userId !== undefined) {
1660
+ query.userId = command.userId;
1661
+ }
1662
+ if (command.workspaceId !== undefined) {
1663
+ query.workspaceId = command.workspaceId;
1664
+ }
1665
+ if (command.repositoryId !== undefined) {
1666
+ query.repositoryId = command.repositoryId;
1667
+ }
1668
+ if (command.projectId !== undefined) {
1669
+ query.projectId = command.projectId;
1670
+ }
1671
+ if (command.scopeKind !== undefined) {
1672
+ query.scopeKind = command.scopeKind;
1673
+ }
1674
+ if (command.status !== undefined) {
1675
+ query.status = command.status;
1676
+ }
1677
+ if (command.limit !== undefined) {
1678
+ query.limit = command.limit;
1679
+ }
1680
+ const tasks = ctx.stateStore.listTasks(query).map((task) => ctx.taskRecord(task));
1681
+ return {
1682
+ tasks,
1683
+ };
1684
+ }
1685
+
1686
+ if (command.type === 'task.update') {
1687
+ const update: {
1688
+ title?: string;
1689
+ description?: string;
1690
+ repositoryId?: string | null;
1691
+ projectId?: string | null;
1692
+ linear?: {
1693
+ issueId?: string | null;
1694
+ identifier?: string | null;
1695
+ url?: string | null;
1696
+ teamId?: string | null;
1697
+ projectId?: string | null;
1698
+ projectMilestoneId?: string | null;
1699
+ cycleId?: string | null;
1700
+ stateId?: string | null;
1701
+ assigneeId?: string | null;
1702
+ priority?: number | null;
1703
+ estimate?: number | null;
1704
+ dueDate?: string | null;
1705
+ labelIds?: readonly string[] | null;
1706
+ } | null;
1707
+ } = {};
1708
+ if (command.title !== undefined) {
1709
+ update.title = command.title;
1710
+ }
1711
+ if (command.description !== undefined) {
1712
+ update.description = command.description;
1713
+ }
1714
+ if (command.repositoryId !== undefined) {
1715
+ update.repositoryId = command.repositoryId;
1716
+ }
1717
+ if (command.projectId !== undefined) {
1718
+ update.projectId = command.projectId;
1719
+ }
1720
+ if (command.linear !== undefined) {
1721
+ update.linear = command.linear;
1722
+ }
1723
+ const updated = ctx.stateStore.updateTask(command.taskId, update);
1724
+ if (updated === null) {
1725
+ throw new Error(`task not found: ${command.taskId}`);
1726
+ }
1727
+ ctx.publishObservedEvent(
1728
+ {
1729
+ tenantId: updated.tenantId,
1730
+ userId: updated.userId,
1731
+ workspaceId: updated.workspaceId,
1732
+ directoryId: null,
1733
+ conversationId: null,
1734
+ },
1735
+ {
1736
+ type: 'task-updated',
1737
+ task: ctx.taskRecord(updated),
1738
+ },
1739
+ );
1740
+ return {
1741
+ task: ctx.taskRecord(updated),
1742
+ };
1743
+ }
1744
+
1745
+ if (command.type === 'task.delete') {
1746
+ const existing = ctx.stateStore.getTask(command.taskId);
1747
+ if (existing === null) {
1748
+ throw new Error(`task not found: ${command.taskId}`);
1749
+ }
1750
+ ctx.stateStore.deleteTask(command.taskId);
1751
+ ctx.publishObservedEvent(
1752
+ {
1753
+ tenantId: existing.tenantId,
1754
+ userId: existing.userId,
1755
+ workspaceId: existing.workspaceId,
1756
+ directoryId: null,
1757
+ conversationId: null,
1758
+ },
1759
+ {
1760
+ type: 'task-deleted',
1761
+ taskId: existing.taskId,
1762
+ ts: new Date().toISOString(),
1763
+ },
1764
+ );
1765
+ return {
1766
+ deleted: true,
1767
+ };
1768
+ }
1769
+
1770
+ if (command.type === 'task.claim') {
1771
+ const input: {
1772
+ taskId: string;
1773
+ controllerId: string;
1774
+ directoryId?: string;
1775
+ branchName?: string;
1776
+ baseBranch?: string;
1777
+ } = {
1778
+ taskId: command.taskId,
1779
+ controllerId: command.controllerId,
1780
+ };
1781
+ if (command.directoryId !== undefined) {
1782
+ input.directoryId = command.directoryId;
1783
+ }
1784
+ if (command.branchName !== undefined) {
1785
+ input.branchName = command.branchName;
1786
+ }
1787
+ if (command.baseBranch !== undefined) {
1788
+ input.baseBranch = command.baseBranch;
1789
+ }
1790
+ const task = ctx.stateStore.claimTask(input);
1791
+ ctx.publishObservedEvent(
1792
+ {
1793
+ tenantId: task.tenantId,
1794
+ userId: task.userId,
1795
+ workspaceId: task.workspaceId,
1796
+ directoryId: null,
1797
+ conversationId: null,
1798
+ },
1799
+ {
1800
+ type: 'task-updated',
1801
+ task: ctx.taskRecord(task),
1802
+ },
1803
+ );
1804
+ return {
1805
+ task: ctx.taskRecord(task),
1806
+ };
1807
+ }
1808
+
1809
+ if (command.type === 'project.status') {
1810
+ const directory = ctx.stateStore.getDirectory(command.directoryId);
1811
+ if (directory === null || directory.archivedAt !== null) {
1812
+ throw new Error(`directory not found: ${command.directoryId}`);
1813
+ }
1814
+ const availability = evaluateProjectAvailability({
1815
+ directory,
1816
+ requiredRepositoryId: null,
1817
+ });
1818
+ const gitStatus = ctx.gitStatusByDirectoryId.get(directory.directoryId);
1819
+ return {
1820
+ project: ctx.directoryRecord(directory),
1821
+ repositoryId: availability.repositoryId,
1822
+ git:
1823
+ gitStatus === undefined
1824
+ ? null
1825
+ : {
1826
+ branch: gitStatus.summary.branch,
1827
+ changedFiles: gitStatus.summary.changedFiles,
1828
+ additions: gitStatus.summary.additions,
1829
+ deletions: gitStatus.summary.deletions,
1830
+ },
1831
+ settings: availability.settings,
1832
+ automation: {
1833
+ enabled: availability.automationEnabled,
1834
+ frozen: availability.frozen,
1835
+ source: availability.automationSource,
1836
+ },
1837
+ liveThreadCount: availability.liveThreadCount,
1838
+ availability: availability.availability,
1839
+ reason: availability.reason,
1840
+ };
1841
+ }
1842
+
1843
+ if (command.type === 'task.pull') {
1844
+ const tenantId = command.tenantId ?? DEFAULT_TENANT_ID;
1845
+ const userId = command.userId ?? DEFAULT_USER_ID;
1846
+ const workspaceId = command.workspaceId ?? DEFAULT_WORKSPACE_ID;
1847
+ const controllerId = command.controllerId;
1848
+
1849
+ const tryClaimTask = (
1850
+ task: ControlPlaneTaskRecord,
1851
+ directoryId: string,
1852
+ settings: ControlPlaneProjectSettingsRecord,
1853
+ ): ControlPlaneTaskRecord | null => {
1854
+ try {
1855
+ return ctx.stateStore.claimTask({
1856
+ taskId: task.taskId,
1857
+ controllerId,
1858
+ directoryId,
1859
+ ...(command.branchName !== undefined
1860
+ ? { branchName: command.branchName }
1861
+ : settings.pinnedBranch === null
1862
+ ? {}
1863
+ : { branchName: settings.pinnedBranch }),
1864
+ ...(command.baseBranch !== undefined
1865
+ ? { baseBranch: command.baseBranch }
1866
+ : settings.pinnedBranch === null
1867
+ ? {}
1868
+ : { baseBranch: settings.pinnedBranch }),
1869
+ });
1870
+ } catch (error) {
1871
+ const message = error instanceof Error ? error.message : String(error);
1872
+ if (message.startsWith('task already claimed:')) {
1873
+ return null;
1874
+ }
1875
+ throw error;
1876
+ }
1877
+ };
1878
+
1879
+ const pullForDirectory = (
1880
+ directory: ControlPlaneDirectoryRecord,
1881
+ requiredRepositoryId: string | null,
1882
+ ):
1883
+ | {
1884
+ task: ControlPlaneTaskRecord;
1885
+ availability: ReturnType<typeof evaluateProjectAvailability>;
1886
+ }
1887
+ | {
1888
+ task: null;
1889
+ availability: ReturnType<typeof evaluateProjectAvailability>;
1890
+ } => {
1891
+ const availability = evaluateProjectAvailability({
1892
+ directory,
1893
+ requiredRepositoryId,
1894
+ });
1895
+ if (availability.availability !== 'ready') {
1896
+ return {
1897
+ task: null,
1898
+ availability,
1899
+ };
1900
+ }
1901
+ const readyProjectTasks = ctx.stateStore.listTasks({
1902
+ tenantId,
1903
+ userId,
1904
+ workspaceId,
1905
+ scopeKind: 'project',
1906
+ projectId: directory.directoryId,
1907
+ status: 'ready',
1908
+ limit: 10000,
1909
+ });
1910
+ for (const task of readyProjectTasks) {
1911
+ const claimed = tryClaimTask(task, directory.directoryId, availability.settings);
1912
+ if (claimed !== null) {
1913
+ return {
1914
+ task: claimed,
1915
+ availability,
1916
+ };
1917
+ }
1918
+ }
1919
+
1920
+ if (availability.settings.taskFocusMode !== 'own-only') {
1921
+ const repositoryId = requiredRepositoryId ?? availability.repositoryId;
1922
+ if (repositoryId !== null) {
1923
+ const readyRepositoryTasks = ctx.stateStore.listTasks({
1924
+ tenantId,
1925
+ userId,
1926
+ workspaceId,
1927
+ scopeKind: 'repository',
1928
+ repositoryId,
1929
+ status: 'ready',
1930
+ limit: 10000,
1931
+ });
1932
+ for (const task of readyRepositoryTasks) {
1933
+ const claimed = tryClaimTask(task, directory.directoryId, availability.settings);
1934
+ if (claimed !== null) {
1935
+ return {
1936
+ task: claimed,
1937
+ availability,
1938
+ };
1939
+ }
1940
+ }
1941
+ }
1942
+
1943
+ const readyGlobalTasks = ctx.stateStore.listTasks({
1944
+ tenantId,
1945
+ userId,
1946
+ workspaceId,
1947
+ scopeKind: 'global',
1948
+ status: 'ready',
1949
+ limit: 10000,
1950
+ });
1951
+ for (const task of readyGlobalTasks) {
1952
+ const claimed = tryClaimTask(task, directory.directoryId, availability.settings);
1953
+ if (claimed !== null) {
1954
+ return {
1955
+ task: claimed,
1956
+ availability,
1957
+ };
1958
+ }
1959
+ }
1960
+ }
1961
+
1962
+ return {
1963
+ task: null,
1964
+ availability,
1965
+ };
1966
+ };
1967
+
1968
+ const publishPulledTask = (task: ControlPlaneTaskRecord): void => {
1969
+ ctx.publishObservedEvent(
1970
+ {
1971
+ tenantId: task.tenantId,
1972
+ userId: task.userId,
1973
+ workspaceId: task.workspaceId,
1974
+ directoryId: null,
1975
+ conversationId: null,
1976
+ },
1977
+ {
1978
+ type: 'task-updated',
1979
+ task: ctx.taskRecord(task),
1980
+ },
1981
+ );
1982
+ };
1983
+
1984
+ if (command.directoryId !== undefined) {
1985
+ const directory = ctx.stateStore.getDirectory(command.directoryId);
1986
+ if (directory === null || directory.archivedAt !== null) {
1987
+ throw new Error(`directory not found: ${command.directoryId}`);
1988
+ }
1989
+ if (
1990
+ directory.tenantId !== tenantId ||
1991
+ directory.userId !== userId ||
1992
+ directory.workspaceId !== workspaceId
1993
+ ) {
1994
+ throw new Error('task pull scope mismatch');
1995
+ }
1996
+ const result = pullForDirectory(directory, command.repositoryId ?? null);
1997
+ if (result.task !== null) {
1998
+ publishPulledTask(result.task);
1999
+ }
2000
+ return {
2001
+ task: result.task === null ? null : ctx.taskRecord(result.task),
2002
+ directoryId: directory.directoryId,
2003
+ availability: result.availability.availability,
2004
+ reason:
2005
+ result.task === null ? (result.availability.reason ?? 'no ready task available') : null,
2006
+ settings: result.availability.settings,
2007
+ repositoryId: result.availability.repositoryId,
2008
+ };
2009
+ }
2010
+
2011
+ if (command.repositoryId === undefined) {
2012
+ throw new Error('task pull requires directoryId or repositoryId');
2013
+ }
2014
+
2015
+ const repositoryId = command.repositoryId;
2016
+ const directories = ctx.stateStore
2017
+ .listDirectories({
2018
+ tenantId,
2019
+ userId,
2020
+ workspaceId,
2021
+ includeArchived: false,
2022
+ limit: 10000,
2023
+ })
2024
+ .sort(
2025
+ (left, right) =>
2026
+ Date.parse(left.createdAt) - Date.parse(right.createdAt) ||
2027
+ left.directoryId.localeCompare(right.directoryId),
2028
+ );
2029
+
2030
+ let bestBlocked: {
2031
+ directoryId: string;
2032
+ availability: string;
2033
+ reason: string | null;
2034
+ settings: ControlPlaneProjectSettingsRecord;
2035
+ repositoryId: string | null;
2036
+ } | null = null;
2037
+ for (const directory of directories) {
2038
+ const pulled = pullForDirectory(directory, repositoryId);
2039
+ if (pulled.task !== null) {
2040
+ publishPulledTask(pulled.task);
2041
+ return {
2042
+ task: ctx.taskRecord(pulled.task),
2043
+ directoryId: directory.directoryId,
2044
+ availability: pulled.availability.availability,
2045
+ reason: null,
2046
+ settings: pulled.availability.settings,
2047
+ repositoryId: pulled.availability.repositoryId,
2048
+ };
2049
+ }
2050
+ if (bestBlocked === null) {
2051
+ bestBlocked = {
2052
+ directoryId: directory.directoryId,
2053
+ availability: pulled.availability.availability,
2054
+ reason: pulled.availability.reason,
2055
+ settings: pulled.availability.settings,
2056
+ repositoryId: pulled.availability.repositoryId,
2057
+ };
2058
+ }
2059
+ }
2060
+ return {
2061
+ task: null,
2062
+ directoryId: bestBlocked?.directoryId ?? null,
2063
+ availability: bestBlocked?.availability ?? 'blocked-untracked',
2064
+ reason: bestBlocked?.reason ?? 'no eligible project available',
2065
+ settings: bestBlocked?.settings ?? null,
2066
+ repositoryId: bestBlocked?.repositoryId ?? null,
2067
+ };
2068
+ }
2069
+
2070
+ if (command.type === 'task.complete') {
2071
+ const task = ctx.stateStore.completeTask(command.taskId);
2072
+ ctx.publishObservedEvent(
2073
+ {
2074
+ tenantId: task.tenantId,
2075
+ userId: task.userId,
2076
+ workspaceId: task.workspaceId,
2077
+ directoryId: null,
2078
+ conversationId: null,
2079
+ },
2080
+ {
2081
+ type: 'task-updated',
2082
+ task: ctx.taskRecord(task),
2083
+ },
2084
+ );
2085
+ return {
2086
+ task: ctx.taskRecord(task),
2087
+ };
2088
+ }
2089
+
2090
+ if (command.type === 'task.queue') {
2091
+ const task = ctx.stateStore.readyTask(command.taskId);
2092
+ ctx.publishObservedEvent(
2093
+ {
2094
+ tenantId: task.tenantId,
2095
+ userId: task.userId,
2096
+ workspaceId: task.workspaceId,
2097
+ directoryId: null,
2098
+ conversationId: null,
2099
+ },
2100
+ {
2101
+ type: 'task-updated',
2102
+ task: ctx.taskRecord(task),
2103
+ },
2104
+ );
2105
+ return {
2106
+ task: ctx.taskRecord(task),
2107
+ };
2108
+ }
2109
+
2110
+ if (command.type === 'task.ready') {
2111
+ const task = ctx.stateStore.readyTask(command.taskId);
2112
+ ctx.publishObservedEvent(
2113
+ {
2114
+ tenantId: task.tenantId,
2115
+ userId: task.userId,
2116
+ workspaceId: task.workspaceId,
2117
+ directoryId: null,
2118
+ conversationId: null,
2119
+ },
2120
+ {
2121
+ type: 'task-updated',
2122
+ task: ctx.taskRecord(task),
2123
+ },
2124
+ );
2125
+ return {
2126
+ task: ctx.taskRecord(task),
2127
+ };
2128
+ }
2129
+
2130
+ if (command.type === 'task.draft') {
2131
+ const task = ctx.stateStore.draftTask(command.taskId);
2132
+ ctx.publishObservedEvent(
2133
+ {
2134
+ tenantId: task.tenantId,
2135
+ userId: task.userId,
2136
+ workspaceId: task.workspaceId,
2137
+ directoryId: null,
2138
+ conversationId: null,
2139
+ },
2140
+ {
2141
+ type: 'task-updated',
2142
+ task: ctx.taskRecord(task),
2143
+ },
2144
+ );
2145
+ return {
2146
+ task: ctx.taskRecord(task),
2147
+ };
2148
+ }
2149
+
2150
+ if (command.type === 'task.reorder') {
2151
+ const tasks = ctx.stateStore
2152
+ .reorderTasks({
2153
+ tenantId: command.tenantId,
2154
+ userId: command.userId,
2155
+ workspaceId: command.workspaceId,
2156
+ orderedTaskIds: command.orderedTaskIds,
2157
+ })
2158
+ .map((task) => ctx.taskRecord(task));
2159
+ ctx.publishObservedEvent(
2160
+ {
2161
+ tenantId: command.tenantId,
2162
+ userId: command.userId,
2163
+ workspaceId: command.workspaceId,
2164
+ directoryId: null,
2165
+ conversationId: null,
2166
+ },
2167
+ {
2168
+ type: 'task-reordered',
2169
+ tasks,
2170
+ ts: new Date().toISOString(),
2171
+ },
2172
+ );
2173
+ return {
2174
+ tasks,
2175
+ };
2176
+ }
2177
+
2178
+ if (command.type === 'stream.subscribe') {
2179
+ const subscriptionId = `subscription-${randomUUID()}`;
2180
+ const filter: StreamSubscriptionFilter = {
2181
+ includeOutput: command.includeOutput ?? false,
2182
+ };
2183
+ if (command.tenantId !== undefined) {
2184
+ filter.tenantId = command.tenantId;
2185
+ }
2186
+ if (command.userId !== undefined) {
2187
+ filter.userId = command.userId;
2188
+ }
2189
+ if (command.workspaceId !== undefined) {
2190
+ filter.workspaceId = command.workspaceId;
2191
+ }
2192
+ if (command.repositoryId !== undefined) {
2193
+ filter.repositoryId = command.repositoryId;
2194
+ }
2195
+ if (command.taskId !== undefined) {
2196
+ filter.taskId = command.taskId;
2197
+ }
2198
+ if (command.directoryId !== undefined) {
2199
+ filter.directoryId = command.directoryId;
2200
+ }
2201
+ if (command.conversationId !== undefined) {
2202
+ filter.conversationId = command.conversationId;
2203
+ }
2204
+
2205
+ ctx.streamSubscriptions.set(subscriptionId, {
2206
+ id: subscriptionId,
2207
+ connectionId: connection.id,
2208
+ filter,
2209
+ });
2210
+ connection.streamSubscriptionIds.add(subscriptionId);
2211
+
2212
+ const afterCursor = command.afterCursor ?? 0;
2213
+ for (const entry of ctx.streamJournal) {
2214
+ if (entry.cursor <= afterCursor) {
2215
+ continue;
2216
+ }
2217
+ if (!ctx.matchesObservedFilter(entry.scope, entry.event, filter)) {
2218
+ continue;
2219
+ }
2220
+ const diagnosticSessionId = ctx.diagnosticSessionIdForObservedEvent(entry.scope, entry.event);
2221
+ ctx.sendToConnection(
2222
+ connection.id,
2223
+ {
2224
+ kind: 'stream.event',
2225
+ subscriptionId,
2226
+ cursor: entry.cursor,
2227
+ event: entry.event,
2228
+ },
2229
+ diagnosticSessionId,
2230
+ );
2231
+ }
2232
+
2233
+ return {
2234
+ subscriptionId,
2235
+ cursor: ctx.streamCursor,
2236
+ };
2237
+ }
2238
+
2239
+ if (command.type === 'stream.unsubscribe') {
2240
+ const subscription = ctx.streamSubscriptions.get(command.subscriptionId);
2241
+ if (subscription !== undefined) {
2242
+ const subscriptionConnection = ctx.connections.get(subscription.connectionId);
2243
+ subscriptionConnection?.streamSubscriptionIds.delete(command.subscriptionId);
2244
+ ctx.streamSubscriptions.delete(command.subscriptionId);
2245
+ }
2246
+ return {
2247
+ unsubscribed: true,
2248
+ };
2249
+ }
2250
+
2251
+ if (command.type === 'session.list') {
2252
+ const sort = command.sort ?? 'attention-first';
2253
+ const filtered = [...ctx.sessions.values()].filter((state) => {
2254
+ if (command.tenantId !== undefined && state.tenantId !== command.tenantId) {
2255
+ return false;
2256
+ }
2257
+ if (command.userId !== undefined && state.userId !== command.userId) {
2258
+ return false;
2259
+ }
2260
+ if (command.workspaceId !== undefined && state.workspaceId !== command.workspaceId) {
2261
+ return false;
2262
+ }
2263
+ if (command.worktreeId !== undefined && state.worktreeId !== command.worktreeId) {
2264
+ return false;
2265
+ }
2266
+ if (command.status !== undefined && state.status !== command.status) {
2267
+ return false;
2268
+ }
2269
+ if (command.live !== undefined && (state.session !== null) !== command.live) {
2270
+ return false;
2271
+ }
2272
+ return true;
2273
+ });
2274
+ const sessions = ctx.sortSessionSummaries(filtered, sort);
2275
+ const limited = command.limit === undefined ? sessions : sessions.slice(0, command.limit);
2276
+ return {
2277
+ sessions: limited,
2278
+ };
2279
+ }
2280
+
2281
+ if (command.type === 'attention.list') {
2282
+ return {
2283
+ sessions: ctx.sortSessionSummaries(
2284
+ [...ctx.sessions.values()].filter((state) => state.status === 'needs-input'),
2285
+ 'attention-first',
2286
+ ),
2287
+ };
2288
+ }
2289
+
2290
+ if (command.type === 'session.status') {
2291
+ const state = ctx.requireSession(command.sessionId);
2292
+ return ctx.sessionSummaryRecord(state);
2293
+ }
2294
+
2295
+ if (command.type === 'session.snapshot') {
2296
+ const state = ctx.requireSession(command.sessionId);
2297
+ if (state.session === null) {
2298
+ if (state.lastSnapshot === null) {
2299
+ throw new Error(`session snapshot unavailable: ${command.sessionId}`);
2300
+ }
2301
+ const result: Record<string, unknown> = {
2302
+ sessionId: command.sessionId,
2303
+ snapshot: state.lastSnapshot,
2304
+ stale: true,
2305
+ };
2306
+ if (command.tailLines !== undefined) {
2307
+ result['buffer'] = bufferTailFromSnapshotRecord(state.lastSnapshot, command.tailLines);
2308
+ }
2309
+ return result;
2310
+ }
2311
+ const frame = state.session.snapshot();
2312
+ const snapshot = ctx.snapshotRecordFromFrame(frame);
2313
+ state.lastSnapshot = snapshot;
2314
+ const result: Record<string, unknown> = {
2315
+ sessionId: command.sessionId,
2316
+ snapshot,
2317
+ stale: false,
2318
+ };
2319
+ if (command.tailLines !== undefined) {
2320
+ result['buffer'] =
2321
+ state.session.bufferTail?.(command.tailLines) ??
2322
+ bufferTailFromFrame(frame, command.tailLines);
2323
+ }
2324
+ return result;
2325
+ }
2326
+
2327
+ if (command.type === 'session.claim') {
2328
+ const state = ctx.requireSession(command.sessionId);
2329
+ const claimedAt = new Date().toISOString();
2330
+ const previousController = state.controller;
2331
+ const nextController: SessionControllerState = {
2332
+ controllerId: command.controllerId,
2333
+ controllerType: command.controllerType,
2334
+ controllerLabel: command.controllerLabel ?? null,
2335
+ claimedAt,
2336
+ connectionId: connection.id,
2337
+ };
2338
+ if (previousController === null) {
2339
+ state.controller = nextController;
2340
+ ctx.publishSessionControlObservedEvent(
2341
+ state,
2342
+ 'claimed',
2343
+ ctx.toPublicSessionController(nextController),
2344
+ null,
2345
+ command.reason ?? null,
2346
+ );
2347
+ ctx.publishStatusObservedEvent(state);
2348
+ return {
2349
+ sessionId: command.sessionId,
2350
+ action: 'claimed',
2351
+ controller: ctx.toPublicSessionController(nextController),
2352
+ };
2353
+ }
2354
+ if (previousController.connectionId !== connection.id && command.takeover !== true) {
2355
+ throw new Error(
2356
+ `session is already claimed by ${ctx.controllerDisplayName(previousController)}`,
2357
+ );
2358
+ }
2359
+ state.controller = nextController;
2360
+ const action = previousController.connectionId === connection.id ? 'claimed' : 'taken-over';
2361
+ ctx.publishSessionControlObservedEvent(
2362
+ state,
2363
+ action,
2364
+ ctx.toPublicSessionController(nextController),
2365
+ ctx.toPublicSessionController(previousController),
2366
+ command.reason ?? null,
2367
+ );
2368
+ ctx.publishStatusObservedEvent(state);
2369
+ return {
2370
+ sessionId: command.sessionId,
2371
+ action,
2372
+ controller: ctx.toPublicSessionController(nextController),
2373
+ };
2374
+ }
2375
+
2376
+ if (command.type === 'session.release') {
2377
+ const state = ctx.requireSession(command.sessionId);
2378
+ if (state.controller === null) {
2379
+ return {
2380
+ sessionId: command.sessionId,
2381
+ released: false,
2382
+ controller: null,
2383
+ };
2384
+ }
2385
+ if (state.controller.connectionId !== connection.id) {
2386
+ throw new Error(`session is claimed by ${ctx.controllerDisplayName(state.controller)}`);
2387
+ }
2388
+ const previousController = state.controller;
2389
+ state.controller = null;
2390
+ ctx.publishSessionControlObservedEvent(
2391
+ state,
2392
+ 'released',
2393
+ null,
2394
+ ctx.toPublicSessionController(previousController),
2395
+ command.reason ?? null,
2396
+ );
2397
+ ctx.publishStatusObservedEvent(state);
2398
+ return {
2399
+ sessionId: command.sessionId,
2400
+ released: true,
2401
+ controller: null,
2402
+ };
2403
+ }
2404
+
2405
+ if (command.type === 'session.respond') {
2406
+ const state = ctx.requireLiveSession(command.sessionId);
2407
+ ctx.assertConnectionCanMutateSession(connection.id, state);
2408
+ state.session.write(command.text);
2409
+ ctx.setSessionStatus(state, 'running', null, new Date().toISOString());
2410
+ return {
2411
+ responded: true,
2412
+ sentBytes: Buffer.byteLength(command.text),
2413
+ };
2414
+ }
2415
+
2416
+ if (command.type === 'session.interrupt') {
2417
+ const state = ctx.requireLiveSession(command.sessionId);
2418
+ ctx.assertConnectionCanMutateSession(connection.id, state);
2419
+ state.session.write('\u0003');
2420
+ ctx.setSessionStatus(state, 'completed', null, new Date().toISOString());
2421
+ return {
2422
+ interrupted: true,
2423
+ };
2424
+ }
2425
+
2426
+ if (command.type === 'session.remove') {
2427
+ const state = ctx.requireSession(command.sessionId);
2428
+ ctx.assertConnectionCanMutateSession(connection.id, state);
2429
+ ctx.destroySession(command.sessionId, true);
2430
+ return {
2431
+ removed: true,
2432
+ };
2433
+ }
2434
+
2435
+ if (command.type === 'pty.start') {
2436
+ const startInput: StartSessionRuntimeInput = {
2437
+ sessionId: command.sessionId,
2438
+ args: command.args,
2439
+ initialCols: command.initialCols,
2440
+ initialRows: command.initialRows,
2441
+ ...(command.env !== undefined ? { env: command.env } : {}),
2442
+ ...(command.cwd !== undefined ? { cwd: command.cwd } : {}),
2443
+ ...(command.tenantId !== undefined ? { tenantId: command.tenantId } : {}),
2444
+ ...(command.userId !== undefined ? { userId: command.userId } : {}),
2445
+ ...(command.workspaceId !== undefined ? { workspaceId: command.workspaceId } : {}),
2446
+ ...(command.worktreeId !== undefined ? { worktreeId: command.worktreeId } : {}),
2447
+ ...(command.terminalForegroundHex !== undefined
2448
+ ? { terminalForegroundHex: command.terminalForegroundHex }
2449
+ : {}),
2450
+ ...(command.terminalBackgroundHex !== undefined
2451
+ ? { terminalBackgroundHex: command.terminalBackgroundHex }
2452
+ : {}),
2453
+ };
2454
+ ctx.startSessionRuntime(startInput);
2455
+
2456
+ return {
2457
+ sessionId: command.sessionId,
2458
+ };
2459
+ }
2460
+
2461
+ if (command.type === 'pty.attach') {
2462
+ const state = ctx.requireLiveSession(command.sessionId);
2463
+ const previous = state.attachmentByConnectionId.get(connection.id);
2464
+ if (previous !== undefined) {
2465
+ state.session.detach(previous);
2466
+ }
2467
+
2468
+ const attachmentId = state.session.attach(
2469
+ {
2470
+ onData: (event) => {
2471
+ ctx.sendToConnection(
2472
+ connection.id,
2473
+ {
2474
+ kind: 'pty.output',
2475
+ sessionId: command.sessionId,
2476
+ cursor: event.cursor,
2477
+ chunkBase64: Buffer.from(event.chunk).toString('base64'),
2478
+ },
2479
+ command.sessionId,
2480
+ );
2481
+ const sessionState = ctx.sessions.get(command.sessionId);
2482
+ if (sessionState !== undefined) {
2483
+ if (event.cursor <= sessionState.lastObservedOutputCursor) {
2484
+ return;
2485
+ }
2486
+ sessionState.lastObservedOutputCursor = event.cursor;
2487
+ ctx.publishObservedEvent(ctx.sessionScope(sessionState), {
2488
+ type: 'session-output',
2489
+ sessionId: command.sessionId,
2490
+ outputCursor: event.cursor,
2491
+ chunkBase64: Buffer.from(event.chunk).toString('base64'),
2492
+ ts: new Date().toISOString(),
2493
+ directoryId: sessionState.directoryId,
2494
+ conversationId: sessionState.id,
2495
+ });
2496
+ }
2497
+ },
2498
+ onExit: (exit) => {
2499
+ ctx.sendToConnection(
2500
+ connection.id,
2501
+ {
2502
+ kind: 'pty.exit',
2503
+ sessionId: command.sessionId,
2504
+ exit,
2505
+ },
2506
+ command.sessionId,
2507
+ );
2508
+ },
2509
+ },
2510
+ command.sinceCursor ?? 0,
2511
+ );
2512
+
2513
+ state.attachmentByConnectionId.set(connection.id, attachmentId);
2514
+ connection.attachedSessionIds.add(command.sessionId);
2515
+
2516
+ return {
2517
+ latestCursor: state.session.latestCursorValue(),
2518
+ };
2519
+ }
2520
+
2521
+ if (command.type === 'pty.detach') {
2522
+ ctx.detachConnectionFromSession(connection.id, command.sessionId);
2523
+ connection.attachedSessionIds.delete(command.sessionId);
2524
+ return {
2525
+ detached: true,
2526
+ };
2527
+ }
2528
+
2529
+ if (command.type === 'pty.subscribe-events') {
2530
+ const state = ctx.requireSession(command.sessionId);
2531
+ state.eventSubscriberConnectionIds.add(connection.id);
2532
+ connection.eventSessionIds.add(command.sessionId);
2533
+ return {
2534
+ subscribed: true,
2535
+ };
2536
+ }
2537
+
2538
+ if (command.type === 'pty.unsubscribe-events') {
2539
+ const state = ctx.requireSession(command.sessionId);
2540
+ state.eventSubscriberConnectionIds.delete(connection.id);
2541
+ connection.eventSessionIds.delete(command.sessionId);
2542
+ return {
2543
+ subscribed: false,
2544
+ };
2545
+ }
2546
+
2547
+ if (command.type === 'pty.close') {
2548
+ const state = ctx.requireLiveSession(command.sessionId);
2549
+ ctx.assertConnectionCanMutateSession(connection.id, state);
2550
+ ctx.destroySession(command.sessionId, true);
2551
+ return {
2552
+ closed: true,
2553
+ };
2554
+ }
2555
+
2556
+ throw new Error(`unsupported command type: ${(command as { type: string }).type}`);
2557
+ }