@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,706 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type {
3
+ HarnessLifecycleEventType,
4
+ HarnessLifecycleHooksConfig,
5
+ HarnessLifecycleWebhookConfig,
6
+ } from '../config/config-core.ts';
7
+ import { recordPerfEvent } from '../perf/perf-core.ts';
8
+ import type {
9
+ StreamObservedEvent,
10
+ StreamSessionRuntimeStatus,
11
+ StreamTelemetrySource,
12
+ } from './stream-protocol.ts';
13
+
14
+ type HarnessLifecycleProvider = 'codex' | 'claude' | 'cursor' | 'control-plane' | 'unknown';
15
+
16
+ interface LifecycleObservedScope {
17
+ tenantId: string;
18
+ userId: string;
19
+ workspaceId: string;
20
+ directoryId: string | null;
21
+ conversationId: string | null;
22
+ }
23
+
24
+ interface HarnessLifecycleEventEnvelope {
25
+ readonly schemaVersion: '1';
26
+ readonly eventId: string;
27
+ readonly eventType: HarnessLifecycleEventType;
28
+ readonly provider: HarnessLifecycleProvider;
29
+ readonly observedType: StreamObservedEvent['type'];
30
+ readonly ts: string;
31
+ readonly cursor: number;
32
+ readonly summary: string;
33
+ readonly context: {
34
+ tenantId: string;
35
+ userId: string;
36
+ workspaceId: string;
37
+ directoryId: string | null;
38
+ conversationId: string | null;
39
+ sessionId: string | null;
40
+ };
41
+ readonly attributes: Readonly<Record<string, unknown>>;
42
+ }
43
+
44
+ interface LifecycleConnector {
45
+ readonly id: string;
46
+ dispatch(event: HarnessLifecycleEventEnvelope): Promise<void>;
47
+ close?(): Promise<void>;
48
+ }
49
+
50
+ interface SessionStatusSnapshot {
51
+ status: StreamSessionRuntimeStatus;
52
+ live: boolean;
53
+ }
54
+
55
+ const SESSION_DEDUP_WINDOW_MS = 250;
56
+ const MAX_PENDING_EVENTS = 2048;
57
+ const OTLP_SOURCES = new Set<StreamTelemetrySource>([
58
+ 'history',
59
+ 'otlp-log',
60
+ 'otlp-metric',
61
+ 'otlp-trace',
62
+ ]);
63
+
64
+ function asRecord(value: unknown): Record<string, unknown> | null {
65
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
66
+ return null;
67
+ }
68
+ return value as Record<string, unknown>;
69
+ }
70
+
71
+ function readTrimmedString(value: unknown): string | null {
72
+ if (typeof value !== 'string') {
73
+ return null;
74
+ }
75
+ const trimmed = value.trim();
76
+ return trimmed.length > 0 ? trimmed : null;
77
+ }
78
+
79
+ function normalizeTimestamp(candidate: string | null): string {
80
+ if (candidate === null) {
81
+ return new Date().toISOString();
82
+ }
83
+ const parsed = Date.parse(candidate);
84
+ if (!Number.isFinite(parsed)) {
85
+ return new Date().toISOString();
86
+ }
87
+ return new Date(parsed).toISOString();
88
+ }
89
+
90
+ function normalizeBaseUrl(baseUrl: string): string {
91
+ const trimmed = baseUrl.trim();
92
+ if (trimmed.endsWith('/')) {
93
+ return trimmed.slice(0, -1);
94
+ }
95
+ return trimmed;
96
+ }
97
+
98
+ async function fetchWithTimeout(
99
+ url: string,
100
+ init: RequestInit,
101
+ timeoutMs: number,
102
+ ): Promise<Response> {
103
+ if (timeoutMs <= 0) {
104
+ return await fetch(url, init);
105
+ }
106
+ const controller = new AbortController();
107
+ let timeout: ReturnType<typeof setTimeout> | null = null;
108
+ const timeoutPromise = new Promise<never>((_, reject) => {
109
+ timeout = setTimeout(() => {
110
+ controller.abort();
111
+ reject(new Error(`request timed out after ${String(timeoutMs)}ms`));
112
+ }, timeoutMs);
113
+ timeout.unref();
114
+ });
115
+ try {
116
+ return await Promise.race([
117
+ fetch(url, {
118
+ ...init,
119
+ signal: controller.signal,
120
+ }),
121
+ timeoutPromise,
122
+ ]);
123
+ } finally {
124
+ if (timeout !== null) {
125
+ clearTimeout(timeout);
126
+ }
127
+ }
128
+ }
129
+
130
+ class PeonPingLifecycleConnector implements LifecycleConnector {
131
+ readonly id = 'peon-ping';
132
+ private readonly baseUrl: string;
133
+ private readonly timeoutMs: number;
134
+ private readonly categoryByEvent: Readonly<Partial<Record<HarnessLifecycleEventType, string>>>;
135
+
136
+ constructor(config: HarnessLifecycleHooksConfig['peonPing']) {
137
+ this.baseUrl = normalizeBaseUrl(config.baseUrl);
138
+ this.timeoutMs = config.timeoutMs;
139
+ this.categoryByEvent = config.eventCategoryMap;
140
+ }
141
+
142
+ async dispatch(event: HarnessLifecycleEventEnvelope): Promise<void> {
143
+ const category = this.categoryByEvent[event.eventType];
144
+ if (category === undefined) {
145
+ return;
146
+ }
147
+ const endpoint = new URL('/play', this.baseUrl);
148
+ endpoint.searchParams.set('category', category);
149
+ const response = await fetchWithTimeout(endpoint.toString(), { method: 'GET' }, this.timeoutMs);
150
+ if (!response.ok) {
151
+ throw new Error(`peon-ping connector failed status=${String(response.status)}`);
152
+ }
153
+ }
154
+ }
155
+
156
+ class WebhookLifecycleConnector implements LifecycleConnector {
157
+ readonly id: string;
158
+ private readonly url: string;
159
+ private readonly method: string;
160
+ private readonly timeoutMs: number;
161
+ private readonly headers: Readonly<Record<string, string>>;
162
+ private readonly eventTypes: ReadonlySet<HarnessLifecycleEventType>;
163
+ private readonly matchAllEventTypes: boolean;
164
+
165
+ constructor(config: HarnessLifecycleWebhookConfig) {
166
+ this.id = `webhook:${config.name}`;
167
+ this.url = config.url;
168
+ this.method = config.method;
169
+ this.timeoutMs = config.timeoutMs;
170
+ this.headers = config.headers;
171
+ this.eventTypes = new Set(config.eventTypes);
172
+ this.matchAllEventTypes = this.eventTypes.size === 0;
173
+ }
174
+
175
+ async dispatch(event: HarnessLifecycleEventEnvelope): Promise<void> {
176
+ if (!this.matchAllEventTypes && !this.eventTypes.has(event.eventType)) {
177
+ return;
178
+ }
179
+ const hasBody = this.method !== 'GET' && this.method !== 'HEAD';
180
+ const headers: Record<string, string> = {
181
+ ...this.headers,
182
+ };
183
+ const init: RequestInit = {
184
+ method: this.method,
185
+ headers,
186
+ };
187
+ if (hasBody) {
188
+ if (headers['content-type'] === undefined) {
189
+ headers['content-type'] = 'application/json';
190
+ }
191
+ init.body = JSON.stringify(event);
192
+ }
193
+ const response = await fetchWithTimeout(this.url, init, this.timeoutMs);
194
+ if (!response.ok) {
195
+ throw new Error(`${this.id} failed status=${String(response.status)}`);
196
+ }
197
+ }
198
+ }
199
+
200
+ function providerFromSessionKeyEvent(
201
+ event: Extract<StreamObservedEvent, { type: 'session-key-event' }>,
202
+ ): HarnessLifecycleProvider {
203
+ const eventName = event.keyEvent.eventName?.toLowerCase() ?? '';
204
+ if (
205
+ eventName.startsWith('claude.') ||
206
+ eventName.includes('pretooluse') ||
207
+ eventName.includes('posttooluse') ||
208
+ eventName.includes('userpromptsubmit')
209
+ ) {
210
+ return 'claude';
211
+ }
212
+ if (eventName.startsWith('cursor.')) {
213
+ return 'cursor';
214
+ }
215
+ if (eventName.startsWith('codex.')) {
216
+ return 'codex';
217
+ }
218
+ const source = event.keyEvent.source;
219
+ if (OTLP_SOURCES.has(source)) {
220
+ return 'codex';
221
+ }
222
+ return 'unknown';
223
+ }
224
+
225
+ function providerFromObservedEvent(event: StreamObservedEvent): HarnessLifecycleProvider {
226
+ if (event.type === 'session-key-event') {
227
+ return providerFromSessionKeyEvent(event);
228
+ }
229
+ return 'control-plane';
230
+ }
231
+
232
+ function conversationIdFromConversationRecord(event: {
233
+ conversation: Record<string, unknown>;
234
+ }): string | null {
235
+ const direct = readTrimmedString(event.conversation['conversationId']);
236
+ if (direct !== null) {
237
+ return direct;
238
+ }
239
+ return null;
240
+ }
241
+
242
+ function maybeToolFailure(summary: string | null, severity: string | null): boolean {
243
+ const normalizedSummary = summary?.toLowerCase() ?? '';
244
+ if (
245
+ normalizedSummary.includes('error') ||
246
+ normalizedSummary.includes('failed') ||
247
+ normalizedSummary.includes('denied') ||
248
+ normalizedSummary.includes('abort')
249
+ ) {
250
+ return true;
251
+ }
252
+ const normalizedSeverity = severity?.toLowerCase() ?? '';
253
+ return normalizedSeverity === 'error' || normalizedSeverity === 'fatal';
254
+ }
255
+
256
+ export class LifecycleHooksRuntime {
257
+ private readonly enabled: boolean;
258
+ private readonly providers: HarnessLifecycleHooksConfig['providers'];
259
+ private readonly connectors: readonly LifecycleConnector[];
260
+ private readonly sessionStatusById = new Map<string, SessionStatusSnapshot>();
261
+ private readonly lastEmitMsBySessionAndType = new Map<string, number>();
262
+ private readonly pendingEvents: HarnessLifecycleEventEnvelope[] = [];
263
+ private drainPromise: Promise<void> | null = null;
264
+ private closing = false;
265
+
266
+ constructor(config: HarnessLifecycleHooksConfig) {
267
+ this.enabled = config.enabled;
268
+ this.providers = config.providers;
269
+ this.connectors = this.buildConnectors(config);
270
+ }
271
+
272
+ publish(scope: LifecycleObservedScope, event: StreamObservedEvent, cursor: number): void {
273
+ if (!this.enabled || this.closing || this.connectors.length === 0) {
274
+ return;
275
+ }
276
+ const normalizedEvents = this.normalizeObservedEvent(scope, event, cursor);
277
+ if (normalizedEvents.length === 0) {
278
+ return;
279
+ }
280
+ for (const normalizedEvent of normalizedEvents) {
281
+ if (this.pendingEvents.length >= MAX_PENDING_EVENTS) {
282
+ this.pendingEvents.shift();
283
+ }
284
+ this.pendingEvents.push(normalizedEvent);
285
+ }
286
+ this.startDrainIfNeeded();
287
+ }
288
+
289
+ async close(): Promise<void> {
290
+ this.closing = true;
291
+ if (this.drainPromise !== null) {
292
+ await this.drainPromise;
293
+ }
294
+ for (const connector of this.connectors) {
295
+ if (connector.close === undefined) {
296
+ continue;
297
+ }
298
+ await connector.close();
299
+ }
300
+ }
301
+
302
+ private buildConnectors(config: HarnessLifecycleHooksConfig): readonly LifecycleConnector[] {
303
+ const connectors: LifecycleConnector[] = [];
304
+ if (config.peonPing.enabled) {
305
+ connectors.push(new PeonPingLifecycleConnector(config.peonPing));
306
+ }
307
+ for (const webhook of config.webhooks) {
308
+ if (!webhook.enabled) {
309
+ continue;
310
+ }
311
+ connectors.push(new WebhookLifecycleConnector(webhook));
312
+ }
313
+ return connectors;
314
+ }
315
+
316
+ private startDrainIfNeeded(): void {
317
+ if (this.drainPromise !== null) {
318
+ return;
319
+ }
320
+ this.drainPromise = this.drainPendingEvents().finally(() => {
321
+ this.drainPromise = null;
322
+ if (this.pendingEvents.length > 0 && !this.closing) {
323
+ this.startDrainIfNeeded();
324
+ }
325
+ });
326
+ }
327
+
328
+ private async drainPendingEvents(): Promise<void> {
329
+ while (this.pendingEvents.length > 0) {
330
+ const event = this.pendingEvents.shift();
331
+ if (event === undefined) {
332
+ continue;
333
+ }
334
+ for (const connector of this.connectors) {
335
+ try {
336
+ await connector.dispatch(event);
337
+ recordPerfEvent('control-plane.lifecycle-hooks.dispatch.completed', {
338
+ connector: connector.id,
339
+ eventType: event.eventType,
340
+ provider: event.provider,
341
+ sessionId: event.context.sessionId ?? '',
342
+ });
343
+ } catch (error: unknown) {
344
+ recordPerfEvent('control-plane.lifecycle-hooks.dispatch.failed', {
345
+ connector: connector.id,
346
+ eventType: event.eventType,
347
+ provider: event.provider,
348
+ sessionId: event.context.sessionId ?? '',
349
+ error: String(error),
350
+ });
351
+ }
352
+ }
353
+ }
354
+ }
355
+
356
+ private normalizeObservedEvent(
357
+ scope: LifecycleObservedScope,
358
+ event: StreamObservedEvent,
359
+ cursor: number,
360
+ ): readonly HarnessLifecycleEventEnvelope[] {
361
+ const provider = providerFromObservedEvent(event);
362
+ if (!this.isProviderEnabled(provider)) {
363
+ return [];
364
+ }
365
+
366
+ if (event.type === 'conversation-created') {
367
+ return [
368
+ this.buildLifecycleEvent(scope, event, cursor, provider, 'thread.created', {
369
+ sessionId: conversationIdFromConversationRecord(event),
370
+ summary: 'thread created',
371
+ attributes: {
372
+ conversationId: conversationIdFromConversationRecord(event),
373
+ },
374
+ }),
375
+ ];
376
+ }
377
+ if (event.type === 'conversation-updated') {
378
+ return [
379
+ this.buildLifecycleEvent(scope, event, cursor, provider, 'thread.updated', {
380
+ sessionId: conversationIdFromConversationRecord(event),
381
+ summary: 'thread updated',
382
+ attributes: {
383
+ conversationId: conversationIdFromConversationRecord(event),
384
+ },
385
+ }),
386
+ ];
387
+ }
388
+ if (event.type === 'conversation-archived') {
389
+ return [
390
+ this.buildLifecycleEvent(scope, event, cursor, provider, 'thread.archived', {
391
+ sessionId: event.conversationId,
392
+ summary: 'thread archived',
393
+ attributes: {
394
+ conversationId: event.conversationId,
395
+ },
396
+ }),
397
+ ];
398
+ }
399
+ if (event.type === 'conversation-deleted') {
400
+ return [
401
+ this.buildLifecycleEvent(scope, event, cursor, provider, 'thread.deleted', {
402
+ sessionId: event.conversationId,
403
+ summary: 'thread deleted',
404
+ attributes: {
405
+ conversationId: event.conversationId,
406
+ },
407
+ }),
408
+ ];
409
+ }
410
+ if (event.type === 'session-status') {
411
+ return this.normalizeSessionStatusEvent(scope, event, cursor, provider);
412
+ }
413
+ if (event.type === 'session-event' && event.event.type === 'session-exit') {
414
+ const events: HarnessLifecycleEventEnvelope[] = [
415
+ this.buildLifecycleEvent(scope, event, cursor, provider, 'session.exited', {
416
+ sessionId: event.sessionId,
417
+ summary: 'session exited',
418
+ attributes: {
419
+ exitCode: event.event.exit.code,
420
+ exitSignal: event.event.exit.signal,
421
+ },
422
+ }),
423
+ ];
424
+ if (event.event.exit.code !== 0 || event.event.exit.signal !== null) {
425
+ events.push(
426
+ this.buildLifecycleEvent(scope, event, cursor, provider, 'turn.failed', {
427
+ sessionId: event.sessionId,
428
+ summary: 'turn failed',
429
+ attributes: {
430
+ exitCode: event.event.exit.code,
431
+ exitSignal: event.event.exit.signal,
432
+ },
433
+ }),
434
+ );
435
+ }
436
+ return this.dedupeSessionEvents(events);
437
+ }
438
+ if (event.type === 'session-event' && event.event.type === 'notify') {
439
+ return [];
440
+ }
441
+ if (event.type === 'session-key-event') {
442
+ return this.normalizeSessionKeyEvent(scope, event, cursor, provider);
443
+ }
444
+ return [];
445
+ }
446
+
447
+ private normalizeSessionStatusEvent(
448
+ scope: LifecycleObservedScope,
449
+ event: Extract<StreamObservedEvent, { type: 'session-status' }>,
450
+ cursor: number,
451
+ provider: HarnessLifecycleProvider,
452
+ ): readonly HarnessLifecycleEventEnvelope[] {
453
+ const previous = this.sessionStatusById.get(event.sessionId);
454
+ this.sessionStatusById.set(event.sessionId, {
455
+ status: event.status,
456
+ live: event.live,
457
+ });
458
+ const lifecycleEvents: HarnessLifecycleEventEnvelope[] = [];
459
+ if (event.live && (previous === undefined || !previous.live || previous.status === 'exited')) {
460
+ lifecycleEvents.push(
461
+ this.buildLifecycleEvent(scope, event, cursor, provider, 'session.started', {
462
+ sessionId: event.sessionId,
463
+ summary: 'session started',
464
+ attributes: {
465
+ status: event.status,
466
+ },
467
+ }),
468
+ );
469
+ }
470
+ if (previous?.status !== event.status) {
471
+ if (event.status === 'running') {
472
+ lifecycleEvents.push(
473
+ this.buildLifecycleEvent(scope, event, cursor, provider, 'turn.started', {
474
+ sessionId: event.sessionId,
475
+ summary: 'turn started',
476
+ attributes: {
477
+ status: event.status,
478
+ },
479
+ }),
480
+ );
481
+ } else if (event.status === 'completed') {
482
+ lifecycleEvents.push(
483
+ this.buildLifecycleEvent(scope, event, cursor, provider, 'turn.completed', {
484
+ sessionId: event.sessionId,
485
+ summary: 'turn completed',
486
+ attributes: {
487
+ status: event.status,
488
+ },
489
+ }),
490
+ );
491
+ } else if (event.status === 'needs-input') {
492
+ lifecycleEvents.push(
493
+ this.buildLifecycleEvent(scope, event, cursor, provider, 'input.required', {
494
+ sessionId: event.sessionId,
495
+ summary: 'input required',
496
+ attributes: {
497
+ attentionReason: event.attentionReason,
498
+ },
499
+ }),
500
+ );
501
+ } else if (event.status === 'exited') {
502
+ lifecycleEvents.push(
503
+ this.buildLifecycleEvent(scope, event, cursor, provider, 'session.exited', {
504
+ sessionId: event.sessionId,
505
+ summary: 'session exited',
506
+ attributes: {
507
+ status: event.status,
508
+ },
509
+ }),
510
+ );
511
+ }
512
+ }
513
+ return this.dedupeSessionEvents(lifecycleEvents);
514
+ }
515
+
516
+ private normalizeSessionKeyEvent(
517
+ scope: LifecycleObservedScope,
518
+ event: Extract<StreamObservedEvent, { type: 'session-key-event' }>,
519
+ cursor: number,
520
+ provider: HarnessLifecycleProvider,
521
+ ): readonly HarnessLifecycleEventEnvelope[] {
522
+ const lifecycleEvents: HarnessLifecycleEventEnvelope[] = [];
523
+ const eventName = event.keyEvent.eventName?.toLowerCase() ?? '';
524
+ const summary = event.keyEvent.summary?.toLowerCase() ?? '';
525
+
526
+ if (
527
+ eventName.includes('tool_call') ||
528
+ eventName.includes('pretooluse') ||
529
+ eventName === 'cursor.beforeshellexecution' ||
530
+ eventName === 'cursor.beforemcptool'
531
+ ) {
532
+ lifecycleEvents.push(
533
+ this.buildLifecycleEvent(scope, event, cursor, provider, 'tool.started', {
534
+ sessionId: event.sessionId,
535
+ summary: event.keyEvent.summary ?? 'tool started',
536
+ attributes: {
537
+ eventName: event.keyEvent.eventName,
538
+ source: event.keyEvent.source,
539
+ },
540
+ }),
541
+ );
542
+ }
543
+ if (
544
+ eventName.includes('tool_result') ||
545
+ eventName.includes('posttooluse') ||
546
+ eventName === 'cursor.aftershellexecution' ||
547
+ eventName === 'cursor.aftermcptool'
548
+ ) {
549
+ const toolFailed = maybeToolFailure(event.keyEvent.summary, event.keyEvent.severity);
550
+ lifecycleEvents.push(
551
+ this.buildLifecycleEvent(
552
+ scope,
553
+ event,
554
+ cursor,
555
+ provider,
556
+ toolFailed ? 'tool.failed' : 'tool.completed',
557
+ {
558
+ sessionId: event.sessionId,
559
+ summary: event.keyEvent.summary ?? (toolFailed ? 'tool failed' : 'tool completed'),
560
+ attributes: {
561
+ eventName: event.keyEvent.eventName,
562
+ source: event.keyEvent.source,
563
+ severity: event.keyEvent.severity,
564
+ },
565
+ },
566
+ ),
567
+ );
568
+ }
569
+ if (
570
+ eventName.includes('user_prompt') ||
571
+ eventName.includes('userpromptsubmit') ||
572
+ eventName === 'cursor.beforesubmitprompt'
573
+ ) {
574
+ lifecycleEvents.push(
575
+ this.buildLifecycleEvent(scope, event, cursor, provider, 'turn.started', {
576
+ sessionId: event.sessionId,
577
+ summary: event.keyEvent.summary ?? 'turn started',
578
+ attributes: {
579
+ eventName: event.keyEvent.eventName,
580
+ source: event.keyEvent.source,
581
+ },
582
+ }),
583
+ );
584
+ }
585
+ if (eventName === 'codex.turn.e2e_duration_ms' || summary.includes('turn complete')) {
586
+ lifecycleEvents.push(
587
+ this.buildLifecycleEvent(scope, event, cursor, provider, 'turn.completed', {
588
+ sessionId: event.sessionId,
589
+ summary: event.keyEvent.summary ?? 'turn completed',
590
+ attributes: {
591
+ eventName: event.keyEvent.eventName,
592
+ source: event.keyEvent.source,
593
+ },
594
+ }),
595
+ );
596
+ }
597
+ if (
598
+ event.keyEvent.statusHint === 'needs-input' ||
599
+ eventName.includes('attention-required') ||
600
+ eventName.includes('notification')
601
+ ) {
602
+ lifecycleEvents.push(
603
+ this.buildLifecycleEvent(scope, event, cursor, provider, 'input.required', {
604
+ sessionId: event.sessionId,
605
+ summary: event.keyEvent.summary ?? 'input required',
606
+ attributes: {
607
+ eventName: event.keyEvent.eventName,
608
+ source: event.keyEvent.source,
609
+ },
610
+ }),
611
+ );
612
+ }
613
+ if (
614
+ maybeToolFailure(event.keyEvent.summary, event.keyEvent.severity) &&
615
+ eventName.includes('api_request')
616
+ ) {
617
+ lifecycleEvents.push(
618
+ this.buildLifecycleEvent(scope, event, cursor, provider, 'turn.failed', {
619
+ sessionId: event.sessionId,
620
+ summary: event.keyEvent.summary ?? 'turn failed',
621
+ attributes: {
622
+ eventName: event.keyEvent.eventName,
623
+ source: event.keyEvent.source,
624
+ severity: event.keyEvent.severity,
625
+ },
626
+ }),
627
+ );
628
+ }
629
+
630
+ return this.dedupeSessionEvents(lifecycleEvents);
631
+ }
632
+
633
+ private dedupeSessionEvents(
634
+ events: readonly HarnessLifecycleEventEnvelope[],
635
+ ): readonly HarnessLifecycleEventEnvelope[] {
636
+ const deduped: HarnessLifecycleEventEnvelope[] = [];
637
+ for (const event of events) {
638
+ const sessionId = event.context.sessionId;
639
+ if (sessionId === null) {
640
+ deduped.push(event);
641
+ continue;
642
+ }
643
+ const dedupeKey = `${sessionId}:${event.eventType}`;
644
+ const currentEventMs = Date.parse(event.ts);
645
+ const eventMs = Number.isFinite(currentEventMs) ? currentEventMs : Date.now();
646
+ const lastMs = this.lastEmitMsBySessionAndType.get(dedupeKey);
647
+ if (lastMs !== undefined && eventMs - lastMs <= SESSION_DEDUP_WINDOW_MS) {
648
+ continue;
649
+ }
650
+ this.lastEmitMsBySessionAndType.set(dedupeKey, eventMs);
651
+ deduped.push(event);
652
+ }
653
+ return deduped;
654
+ }
655
+
656
+ private buildLifecycleEvent(
657
+ scope: LifecycleObservedScope,
658
+ observed: StreamObservedEvent,
659
+ cursor: number,
660
+ provider: HarnessLifecycleProvider,
661
+ eventType: HarnessLifecycleEventType,
662
+ details: {
663
+ sessionId: string | null;
664
+ summary: string;
665
+ attributes: Readonly<Record<string, unknown>>;
666
+ },
667
+ ): HarnessLifecycleEventEnvelope {
668
+ const observedRecord = asRecord(observed);
669
+ const observedTs = readTrimmedString(observedRecord?.['ts']);
670
+ return {
671
+ schemaVersion: '1',
672
+ eventId: randomUUID(),
673
+ eventType,
674
+ provider,
675
+ observedType: observed.type,
676
+ ts: normalizeTimestamp(observedTs),
677
+ cursor,
678
+ summary: details.summary,
679
+ context: {
680
+ tenantId: scope.tenantId,
681
+ userId: scope.userId,
682
+ workspaceId: scope.workspaceId,
683
+ directoryId: scope.directoryId,
684
+ conversationId: scope.conversationId,
685
+ sessionId: details.sessionId,
686
+ },
687
+ attributes: details.attributes,
688
+ };
689
+ }
690
+
691
+ private isProviderEnabled(provider: HarnessLifecycleProvider): boolean {
692
+ if (provider === 'codex') {
693
+ return this.providers.codex;
694
+ }
695
+ if (provider === 'claude') {
696
+ return this.providers.claude;
697
+ }
698
+ if (provider === 'cursor') {
699
+ return this.providers.cursor;
700
+ }
701
+ if (provider === 'control-plane') {
702
+ return this.providers.controlPlane;
703
+ }
704
+ return this.providers.codex || this.providers.claude || this.providers.cursor;
705
+ }
706
+ }