@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,188 @@
1
+ import { resolveTerminalCommandForEnvironment } from '../../control-plane/stream-server.ts';
2
+ import type { parseSessionSummaryRecord } from '../../control-plane/session-summary.ts';
3
+ import type {
4
+ StreamSessionController,
5
+ StreamSessionStatusModel,
6
+ } from '../../control-plane/stream-protocol.ts';
7
+ import type { EventScope } from '../../events/normalized-events.ts';
8
+ import type { PtyExit } from '../../pty/pty_host.ts';
9
+ import { TerminalSnapshotOracle } from '../../terminal/snapshot-oracle.ts';
10
+ import { type ConversationRailSessionSummary } from '../conversation-rail.ts';
11
+ import { normalizeThreadAgentType } from '../new-thread-prompt.ts';
12
+
13
+ type SessionSummaryRecord = NonNullable<ReturnType<typeof parseSessionSummaryRecord>>;
14
+
15
+ export interface ConversationState {
16
+ readonly sessionId: string;
17
+ directoryId: string | null;
18
+ title: string;
19
+ agentType: string;
20
+ adapterState: Record<string, unknown>;
21
+ turnId: string;
22
+ scope: EventScope;
23
+ oracle: TerminalSnapshotOracle;
24
+ status: ConversationRailSessionSummary['status'];
25
+ statusModel: StreamSessionStatusModel | null;
26
+ attentionReason: string | null;
27
+ startedAt: string;
28
+ lastEventAt: string | null;
29
+ exitedAt: string | null;
30
+ lastExit: PtyExit | null;
31
+ processId: number | null;
32
+ live: boolean;
33
+ attached: boolean;
34
+ launchCommand: string | null;
35
+ lastOutputCursor: number;
36
+ lastKnownWork: string | null;
37
+ lastKnownWorkAt: string | null;
38
+ lastTelemetrySource: string | null;
39
+ controller: StreamSessionController | null;
40
+ }
41
+
42
+ function createConversationScope(
43
+ baseScope: EventScope,
44
+ conversationId: string,
45
+ turnId: string,
46
+ ): EventScope {
47
+ return {
48
+ tenantId: baseScope.tenantId,
49
+ userId: baseScope.userId,
50
+ workspaceId: baseScope.workspaceId,
51
+ worktreeId: baseScope.worktreeId,
52
+ conversationId,
53
+ turnId,
54
+ };
55
+ }
56
+
57
+ export function createConversationState(
58
+ sessionId: string,
59
+ directoryId: string | null,
60
+ title: string,
61
+ agentType: string,
62
+ adapterState: Record<string, unknown>,
63
+ turnId: string,
64
+ baseScope: EventScope,
65
+ cols: number,
66
+ rows: number,
67
+ ): ConversationState {
68
+ const startedAt = new Date().toISOString();
69
+ const statusModel: StreamSessionStatusModel | null = null;
70
+ return {
71
+ sessionId,
72
+ directoryId,
73
+ title,
74
+ agentType,
75
+ adapterState,
76
+ turnId,
77
+ scope: createConversationScope(baseScope, sessionId, turnId),
78
+ oracle: new TerminalSnapshotOracle(cols, rows),
79
+ status: 'running',
80
+ statusModel,
81
+ attentionReason: null,
82
+ startedAt,
83
+ lastEventAt: null,
84
+ exitedAt: null,
85
+ lastExit: null,
86
+ processId: null,
87
+ live: true,
88
+ attached: false,
89
+ launchCommand: null,
90
+ lastOutputCursor: 0,
91
+ lastKnownWork: null,
92
+ lastKnownWorkAt: null,
93
+ lastTelemetrySource: null,
94
+ controller: null,
95
+ };
96
+ }
97
+
98
+ export function applySummaryToConversation(
99
+ target: ConversationState,
100
+ summary: SessionSummaryRecord | null,
101
+ ): void {
102
+ if (summary === null) {
103
+ return;
104
+ }
105
+ target.scope.tenantId = summary.tenantId;
106
+ target.scope.userId = summary.userId;
107
+ target.scope.workspaceId = summary.workspaceId;
108
+ target.scope.worktreeId = summary.worktreeId;
109
+ target.directoryId = summary.directoryId;
110
+ target.status = summary.status;
111
+ target.statusModel = summary.statusModel;
112
+ target.attentionReason = summary.attentionReason;
113
+ target.startedAt = summary.startedAt;
114
+ target.lastEventAt = summary.lastEventAt;
115
+ target.exitedAt = summary.exitedAt;
116
+ target.lastExit = summary.lastExit;
117
+ target.processId = summary.processId;
118
+ target.live = summary.live;
119
+ target.controller = summary.controller;
120
+ const statusModel = summary.statusModel;
121
+ target.lastKnownWork = statusModel?.lastKnownWork ?? null;
122
+ target.lastKnownWorkAt = statusModel?.lastKnownWorkAt ?? null;
123
+ }
124
+
125
+ export function conversationSummary(
126
+ conversation: ConversationState,
127
+ ): ConversationRailSessionSummary {
128
+ return {
129
+ sessionId: conversation.sessionId,
130
+ status: conversation.status,
131
+ statusModel: conversation.statusModel,
132
+ attentionReason: conversation.attentionReason,
133
+ live: conversation.live,
134
+ startedAt: conversation.startedAt,
135
+ lastEventAt: conversation.lastEventAt,
136
+ };
137
+ }
138
+
139
+ export function compactDebugText(value: string | null): string {
140
+ if (value === null) {
141
+ return '';
142
+ }
143
+ const normalized = value.replace(/\s+/gu, ' ').trim();
144
+ if (normalized.length <= 160) {
145
+ return normalized;
146
+ }
147
+ return `${normalized.slice(0, 159)}…`;
148
+ }
149
+
150
+ function shellQuoteToken(token: string): string {
151
+ if (token.length === 0) {
152
+ return "''";
153
+ }
154
+ if (/^[A-Za-z0-9_./:@%+=,-]+$/u.test(token)) {
155
+ return token;
156
+ }
157
+ return `'${token.replaceAll("'", "'\"'\"'")}'`;
158
+ }
159
+
160
+ export function formatCommandForDebugBar(command: string, args: readonly string[]): string {
161
+ const tokens = [command, ...args].map(shellQuoteToken);
162
+ return tokens.join(' ');
163
+ }
164
+
165
+ export function launchCommandForAgent(agentType: string): string {
166
+ const normalized = normalizeThreadAgentType(agentType);
167
+ if (normalized === 'claude') {
168
+ return 'claude';
169
+ }
170
+ if (normalized === 'critique') {
171
+ return 'critique';
172
+ }
173
+ if (normalized === 'cursor') {
174
+ return 'cursor-agent';
175
+ }
176
+ if (normalized === 'terminal') {
177
+ return resolveTerminalCommandForEnvironment(process.env, process.platform);
178
+ }
179
+ return 'codex';
180
+ }
181
+
182
+ export function debugFooterForConversation(conversation: ConversationState): string {
183
+ const launchCommand =
184
+ conversation.launchCommand === null
185
+ ? '(launch command unavailable)'
186
+ : compactDebugText(conversation.launchCommand);
187
+ return `[dbg] ${launchCommand}`;
188
+ }
@@ -0,0 +1,34 @@
1
+ interface ResolveDirectoryForActionConversationState {
2
+ readonly directoryId: string | null;
3
+ }
4
+
5
+ interface ResolveDirectoryForActionOptions {
6
+ mainPaneMode: 'conversation' | 'project' | 'home';
7
+ activeDirectoryId: string | null;
8
+ activeConversationId: string | null;
9
+ conversations: ReadonlyMap<string, ResolveDirectoryForActionConversationState>;
10
+ directoriesHas: (directoryId: string) => boolean;
11
+ }
12
+
13
+ export function resolveDirectoryForAction(
14
+ options: ResolveDirectoryForActionOptions,
15
+ ): string | null {
16
+ if (options.mainPaneMode === 'project') {
17
+ if (options.activeDirectoryId !== null && options.directoriesHas(options.activeDirectoryId)) {
18
+ return options.activeDirectoryId;
19
+ }
20
+ return null;
21
+ }
22
+ if (options.activeConversationId !== null) {
23
+ const conversation = options.conversations.get(options.activeConversationId);
24
+ if (conversation?.directoryId !== null && conversation?.directoryId !== undefined) {
25
+ if (options.directoriesHas(conversation.directoryId)) {
26
+ return conversation.directoryId;
27
+ }
28
+ }
29
+ }
30
+ if (options.activeDirectoryId !== null && options.directoriesHas(options.activeDirectoryId)) {
31
+ return options.activeDirectoryId;
32
+ }
33
+ return null;
34
+ }
@@ -0,0 +1,96 @@
1
+ import type { EventScope, NormalizedEventEnvelope } from '../../events/normalized-events.ts';
2
+ import { createNormalizedEvent } from '../../events/normalized-events.ts';
3
+ import type { PtyExit } from '../../pty/pty_host.ts';
4
+ import type { StreamSessionEvent } from '../../control-plane/stream-protocol.ts';
5
+
6
+ export function normalizeExitCode(exit: PtyExit): number {
7
+ if (exit.code !== null) {
8
+ return exit.code;
9
+ }
10
+ if (exit.signal !== null) {
11
+ return 128;
12
+ }
13
+ return 1;
14
+ }
15
+
16
+ export function isSessionNotFoundError(error: unknown): boolean {
17
+ if (!(error instanceof Error)) {
18
+ return false;
19
+ }
20
+ return /session not found/i.test(error.message);
21
+ }
22
+
23
+ export function isSessionNotLiveError(error: unknown): boolean {
24
+ if (!(error instanceof Error)) {
25
+ return false;
26
+ }
27
+ return /session is not live/i.test(error.message);
28
+ }
29
+
30
+ export function isConversationNotFoundError(error: unknown): boolean {
31
+ if (!(error instanceof Error)) {
32
+ return false;
33
+ }
34
+ return /conversation not found/i.test(error.message);
35
+ }
36
+
37
+ export function mapTerminalOutputToNormalizedEvent(
38
+ chunk: Buffer,
39
+ scope: EventScope,
40
+ idFactory: () => string,
41
+ ): NormalizedEventEnvelope {
42
+ return createNormalizedEvent(
43
+ 'provider',
44
+ 'provider-text-delta',
45
+ scope,
46
+ {
47
+ kind: 'text-delta',
48
+ threadId: scope.conversationId,
49
+ turnId: scope.turnId ?? 'turn-live',
50
+ delta: chunk.toString('utf8'),
51
+ },
52
+ () => new Date(),
53
+ idFactory,
54
+ );
55
+ }
56
+
57
+ export function mapSessionEventToNormalizedEvent(
58
+ event: StreamSessionEvent,
59
+ scope: EventScope,
60
+ idFactory: () => string,
61
+ ): NormalizedEventEnvelope | null {
62
+ if (event.type === 'session-exit') {
63
+ return createNormalizedEvent(
64
+ 'meta',
65
+ 'meta-attention-cleared',
66
+ scope,
67
+ {
68
+ kind: 'attention',
69
+ threadId: scope.conversationId,
70
+ turnId: scope.turnId ?? 'turn-live',
71
+ reason: 'stalled',
72
+ detail: 'session-exit',
73
+ },
74
+ () => new Date(),
75
+ idFactory,
76
+ );
77
+ }
78
+
79
+ return null;
80
+ }
81
+
82
+ function asRecord(value: unknown): Record<string, unknown> | null {
83
+ if (typeof value !== 'object' || value === null) {
84
+ return null;
85
+ }
86
+ return value as Record<string, unknown>;
87
+ }
88
+
89
+ export function observedAtFromSessionEvent(event: StreamSessionEvent): string {
90
+ if (event.type === 'session-exit') {
91
+ return new Date().toISOString();
92
+ }
93
+ const record = asRecord((event as { record?: unknown }).record);
94
+ const ts = record?.['ts'];
95
+ return typeof ts === 'string' ? ts : new Date().toISOString();
96
+ }
@@ -0,0 +1,152 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ type GatewayProfilerAction = 'start' | 'stop';
7
+
8
+ interface RunHarnessProfileCommandInput {
9
+ readonly invocationDirectory: string;
10
+ readonly harnessScriptPath: string;
11
+ readonly sessionName: string | null;
12
+ readonly action: GatewayProfilerAction;
13
+ }
14
+
15
+ interface RunHarnessProfileCommandResult {
16
+ readonly stdout: string;
17
+ readonly stderr: string;
18
+ }
19
+
20
+ interface ToggleGatewayProfilerOptions {
21
+ readonly invocationDirectory: string;
22
+ readonly sessionName: string | null;
23
+ readonly profileStateExists?: (profileStatePath: string) => boolean;
24
+ readonly runHarnessProfileCommand?: (
25
+ input: RunHarnessProfileCommandInput,
26
+ ) => Promise<RunHarnessProfileCommandResult>;
27
+ readonly harnessScriptPath?: string;
28
+ }
29
+
30
+ interface ToggleGatewayProfilerResult {
31
+ readonly action: GatewayProfilerAction;
32
+ readonly message: string;
33
+ readonly stdout: string;
34
+ }
35
+
36
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
37
+ const DEFAULT_HARNESS_SCRIPT_PATH = resolve(SCRIPT_DIR, '../../../scripts/harness.ts');
38
+
39
+ function firstNonEmptyLine(text: string): string | null {
40
+ const lines = text
41
+ .split('\n')
42
+ .map((line) => line.trim())
43
+ .filter((line) => line.length > 0);
44
+ return lines[0] ?? null;
45
+ }
46
+
47
+ export function resolveProfileStatePath(
48
+ invocationDirectory: string,
49
+ sessionName: string | null,
50
+ ): string {
51
+ if (sessionName === null) {
52
+ return resolve(invocationDirectory, '.harness', 'active-profile.json');
53
+ }
54
+ return resolve(invocationDirectory, '.harness', 'sessions', sessionName, 'active-profile.json');
55
+ }
56
+
57
+ export function resolveHarnessProfileCommandArgs(
58
+ action: GatewayProfilerAction,
59
+ sessionName: string | null,
60
+ ): readonly string[] {
61
+ if (sessionName === null) {
62
+ return ['profile', action];
63
+ }
64
+ return ['--session', sessionName, 'profile', action];
65
+ }
66
+
67
+ function summarizeProfileSuccess(action: GatewayProfilerAction, stdout: string): string {
68
+ const firstLine = firstNonEmptyLine(stdout);
69
+ if (firstLine !== null) {
70
+ return firstLine;
71
+ }
72
+ if (action === 'start') {
73
+ return 'profile started';
74
+ }
75
+ return 'profile stopped';
76
+ }
77
+
78
+ function summarizeProfileFailure(
79
+ action: GatewayProfilerAction,
80
+ stderr: string,
81
+ stdout: string,
82
+ ): string {
83
+ const detail = firstNonEmptyLine(stderr) ?? firstNonEmptyLine(stdout) ?? 'unknown error';
84
+ return `profile ${action} failed: ${detail}`;
85
+ }
86
+
87
+ async function runHarnessProfileCommand(
88
+ input: RunHarnessProfileCommandInput,
89
+ ): Promise<RunHarnessProfileCommandResult> {
90
+ const commandArgs = resolveHarnessProfileCommandArgs(input.action, input.sessionName);
91
+ const child = spawn(process.execPath, [input.harnessScriptPath, ...commandArgs], {
92
+ cwd: input.invocationDirectory,
93
+ env: {
94
+ ...process.env,
95
+ HARNESS_INVOKE_CWD: input.invocationDirectory,
96
+ },
97
+ stdio: ['ignore', 'pipe', 'pipe'],
98
+ });
99
+ const stdoutChunks: Buffer[] = [];
100
+ const stderrChunks: Buffer[] = [];
101
+ child.stdout?.on('data', (chunk: Buffer | string) => {
102
+ stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
103
+ });
104
+ child.stderr?.on('data', (chunk: Buffer | string) => {
105
+ stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
106
+ });
107
+
108
+ const [code, signal] = await new Promise<[number | null, NodeJS.Signals | null]>(
109
+ (resolveExit, rejectExit) => {
110
+ child.once('error', rejectExit);
111
+ child.once('exit', (exitCode, exitSignal) => {
112
+ resolveExit([exitCode, exitSignal]);
113
+ });
114
+ },
115
+ );
116
+
117
+ const stdout = Buffer.concat(stdoutChunks).toString('utf8');
118
+ const stderr = Buffer.concat(stderrChunks).toString('utf8');
119
+ const exitCode = code ?? (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 1);
120
+ if (exitCode !== 0) {
121
+ throw new Error(summarizeProfileFailure(input.action, stderr, stdout));
122
+ }
123
+
124
+ return {
125
+ stdout,
126
+ stderr,
127
+ };
128
+ }
129
+
130
+ export async function toggleGatewayProfiler(
131
+ options: ToggleGatewayProfilerOptions,
132
+ ): Promise<ToggleGatewayProfilerResult> {
133
+ const profileStatePath = resolveProfileStatePath(
134
+ options.invocationDirectory,
135
+ options.sessionName,
136
+ );
137
+ const isProfileRunning = (options.profileStateExists ?? existsSync)(profileStatePath);
138
+ const action: GatewayProfilerAction = isProfileRunning ? 'stop' : 'start';
139
+ const harnessScriptPath = options.harnessScriptPath ?? DEFAULT_HARNESS_SCRIPT_PATH;
140
+ const runCommand = options.runHarnessProfileCommand ?? runHarnessProfileCommand;
141
+ const result = await runCommand({
142
+ invocationDirectory: options.invocationDirectory,
143
+ harnessScriptPath,
144
+ sessionName: options.sessionName,
145
+ action,
146
+ });
147
+ return {
148
+ action,
149
+ message: summarizeProfileSuccess(action, result.stdout),
150
+ stdout: result.stdout,
151
+ };
152
+ }
@@ -0,0 +1,177 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { resolveRenderTraceStatePath } from './render-trace-state.ts';
6
+
7
+ type GatewayRenderTraceAction = 'start' | 'stop';
8
+
9
+ interface RunHarnessRenderTraceCommandInput {
10
+ readonly invocationDirectory: string;
11
+ readonly harnessScriptPath: string;
12
+ readonly sessionName: string | null;
13
+ readonly conversationId: string | null;
14
+ readonly action: GatewayRenderTraceAction;
15
+ }
16
+
17
+ interface RunHarnessRenderTraceCommandResult {
18
+ readonly stdout: string;
19
+ readonly stderr: string;
20
+ }
21
+
22
+ interface ToggleGatewayRenderTraceOptions {
23
+ readonly invocationDirectory: string;
24
+ readonly sessionName: string | null;
25
+ readonly conversationId: string | null;
26
+ readonly renderTraceStateExists?: (renderTraceStatePath: string) => boolean;
27
+ readonly runHarnessRenderTraceCommand?: (
28
+ input: RunHarnessRenderTraceCommandInput,
29
+ ) => Promise<RunHarnessRenderTraceCommandResult>;
30
+ readonly harnessScriptPath?: string;
31
+ }
32
+
33
+ interface ToggleGatewayRenderTraceResult {
34
+ readonly action: GatewayRenderTraceAction;
35
+ readonly message: string;
36
+ readonly stdout: string;
37
+ }
38
+
39
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
40
+ const DEFAULT_HARNESS_SCRIPT_PATH = resolve(SCRIPT_DIR, '../../../scripts/harness.ts');
41
+
42
+ function firstNonEmptyLine(text: string): string | null {
43
+ const lines = text
44
+ .split('\n')
45
+ .map((line) => line.trim())
46
+ .filter((line) => line.length > 0);
47
+ return lines[0] ?? null;
48
+ }
49
+
50
+ function lineValueForPrefix(text: string, prefix: string): string | null {
51
+ for (const rawLine of text.split('\n')) {
52
+ const line = rawLine.trim();
53
+ if (!line.startsWith(prefix)) {
54
+ continue;
55
+ }
56
+ const value = line.slice(prefix.length).trim();
57
+ if (value.length === 0) {
58
+ continue;
59
+ }
60
+ return value;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ export function resolveHarnessRenderTraceCommandArgs(
66
+ action: GatewayRenderTraceAction,
67
+ sessionName: string | null,
68
+ conversationId: string | null,
69
+ ): readonly string[] {
70
+ const base =
71
+ sessionName === null
72
+ ? ['render-trace', action]
73
+ : ['--session', sessionName, 'render-trace', action];
74
+ if (action === 'start' && conversationId !== null) {
75
+ return [...base, '--conversation-id', conversationId];
76
+ }
77
+ return base;
78
+ }
79
+
80
+ function summarizeRenderTraceSuccess(action: GatewayRenderTraceAction, stdout: string): string {
81
+ if (action === 'start') {
82
+ const outputPath = lineValueForPrefix(stdout, 'render-trace-target:');
83
+ const conversationId = lineValueForPrefix(stdout, 'render-trace-conversation-id:');
84
+ if (outputPath !== null && conversationId !== null) {
85
+ return `render: trace=${outputPath} conversation=${conversationId}`;
86
+ }
87
+ if (outputPath !== null) {
88
+ return `render: trace=${outputPath}`;
89
+ }
90
+ }
91
+ const firstLine = firstNonEmptyLine(stdout);
92
+ if (firstLine !== null) {
93
+ return firstLine;
94
+ }
95
+ if (action === 'start') {
96
+ return 'render trace started';
97
+ }
98
+ return 'render trace stopped';
99
+ }
100
+
101
+ function summarizeRenderTraceFailure(
102
+ action: GatewayRenderTraceAction,
103
+ stderr: string,
104
+ stdout: string,
105
+ ): string {
106
+ const detail = firstNonEmptyLine(stderr) ?? firstNonEmptyLine(stdout) ?? 'unknown error';
107
+ return `render trace ${action} failed: ${detail}`;
108
+ }
109
+
110
+ async function runHarnessRenderTraceCommand(
111
+ input: RunHarnessRenderTraceCommandInput,
112
+ ): Promise<RunHarnessRenderTraceCommandResult> {
113
+ const commandArgs = resolveHarnessRenderTraceCommandArgs(
114
+ input.action,
115
+ input.sessionName,
116
+ input.conversationId,
117
+ );
118
+ const child = spawn(process.execPath, [input.harnessScriptPath, ...commandArgs], {
119
+ cwd: input.invocationDirectory,
120
+ env: {
121
+ ...process.env,
122
+ HARNESS_INVOKE_CWD: input.invocationDirectory,
123
+ },
124
+ stdio: ['ignore', 'pipe', 'pipe'],
125
+ });
126
+ const stdoutChunks: Buffer[] = [];
127
+ const stderrChunks: Buffer[] = [];
128
+ child.stdout?.on('data', (chunk: Buffer | string) => {
129
+ stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
130
+ });
131
+ child.stderr?.on('data', (chunk: Buffer | string) => {
132
+ stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
133
+ });
134
+
135
+ const [code, signal] = await new Promise<[number | null, NodeJS.Signals | null]>(
136
+ (resolveExit, rejectExit) => {
137
+ child.once('error', rejectExit);
138
+ child.once('exit', (exitCode, exitSignal) => {
139
+ resolveExit([exitCode, exitSignal]);
140
+ });
141
+ },
142
+ );
143
+
144
+ const stdout = Buffer.concat(stdoutChunks).toString('utf8');
145
+ const stderr = Buffer.concat(stderrChunks).toString('utf8');
146
+ const exitCode = code ?? (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 1);
147
+ if (exitCode !== 0) {
148
+ throw new Error(summarizeRenderTraceFailure(input.action, stderr, stdout));
149
+ }
150
+
151
+ return {
152
+ stdout,
153
+ stderr,
154
+ };
155
+ }
156
+
157
+ export async function toggleGatewayRenderTrace(
158
+ options: ToggleGatewayRenderTraceOptions,
159
+ ): Promise<ToggleGatewayRenderTraceResult> {
160
+ const statePath = resolveRenderTraceStatePath(options.invocationDirectory, options.sessionName);
161
+ const isRunning = (options.renderTraceStateExists ?? existsSync)(statePath);
162
+ const action: GatewayRenderTraceAction = isRunning ? 'stop' : 'start';
163
+ const harnessScriptPath = options.harnessScriptPath ?? DEFAULT_HARNESS_SCRIPT_PATH;
164
+ const runCommand = options.runHarnessRenderTraceCommand ?? runHarnessRenderTraceCommand;
165
+ const result = await runCommand({
166
+ invocationDirectory: options.invocationDirectory,
167
+ harnessScriptPath,
168
+ sessionName: options.sessionName,
169
+ conversationId: options.conversationId,
170
+ action,
171
+ });
172
+ return {
173
+ action,
174
+ message: summarizeRenderTraceSuccess(action, result.stdout),
175
+ stdout: result.stdout,
176
+ };
177
+ }