@jmoyers/harness 0.1.8 → 0.1.10

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 (41) hide show
  1. package/README.md +33 -155
  2. package/package.json +5 -1
  3. package/packages/harness-ai/src/anthropic-client.ts +99 -0
  4. package/packages/harness-ai/src/anthropic-protocol.ts +581 -0
  5. package/packages/harness-ai/src/anthropic-provider.ts +82 -0
  6. package/packages/harness-ai/src/async-iterable-stream.ts +65 -0
  7. package/packages/harness-ai/src/index.ts +36 -0
  8. package/packages/harness-ai/src/json-parse.ts +66 -0
  9. package/packages/harness-ai/src/sse.ts +80 -0
  10. package/packages/harness-ai/src/stream-object.ts +96 -0
  11. package/packages/harness-ai/src/stream-text.ts +1340 -0
  12. package/packages/harness-ai/src/types.ts +330 -0
  13. package/packages/harness-ai/src/ui-stream.ts +217 -0
  14. package/scripts/codex-live-mux-runtime.ts +123 -7
  15. package/scripts/control-plane-daemon.ts +20 -3
  16. package/scripts/harness.ts +566 -133
  17. package/src/cli/gateway-record.ts +16 -1
  18. package/src/control-plane/agent-realtime-api.ts +4 -0
  19. package/src/control-plane/prompt/agent-prompt-extractor.ts +191 -0
  20. package/src/control-plane/prompt/extractors/claude-prompt-extractor.ts +53 -0
  21. package/src/control-plane/prompt/extractors/codex-prompt-extractor.ts +50 -0
  22. package/src/control-plane/prompt/extractors/cursor-prompt-extractor.ts +56 -0
  23. package/src/control-plane/prompt/session-prompt-engine.ts +69 -0
  24. package/src/control-plane/prompt/thread-title-namer.ts +290 -0
  25. package/src/control-plane/stream-command-parser.ts +12 -0
  26. package/src/control-plane/stream-protocol.ts +109 -0
  27. package/src/control-plane/stream-server-command.ts +14 -0
  28. package/src/control-plane/stream-server-session-runtime.ts +12 -0
  29. package/src/control-plane/stream-server.ts +485 -19
  30. package/src/mux/input-shortcuts.ts +9 -0
  31. package/src/mux/live-mux/critique-review.ts +5 -1
  32. package/src/mux/live-mux/git-parsing.ts +24 -0
  33. package/src/mux/live-mux/global-shortcut-handlers.ts +8 -0
  34. package/src/mux/render-frame.ts +1 -1
  35. package/src/pty/pty_host.ts +46 -1
  36. package/src/services/control-plane.ts +22 -0
  37. package/src/services/runtime-control-actions.ts +69 -0
  38. package/src/services/runtime-navigation-input.ts +4 -0
  39. package/src/services/runtime-rail-input.ts +4 -0
  40. package/src/services/runtime-workspace-actions.ts +5 -0
  41. package/src/ui/global-shortcut-input.ts +2 -0
@@ -0,0 +1,290 @@
1
+ import { createAnthropic, generateText } from '../../../packages/harness-ai/src/index.ts';
2
+ import type { StreamSessionPromptRecord } from '../stream-protocol.ts';
3
+
4
+ const THREAD_TITLE_ADAPTER_STATE_KEY = 'harnessThreadTitle';
5
+ const MAX_SANITIZED_PROMPT_CHARS = 1200;
6
+ const IMAGE_DATA_URL_PATTERN = /data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=\s]+/giu;
7
+ const MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*\]\([^)]*\)/gu;
8
+ const HTML_IMAGE_PATTERN = /<img\b[^>]*>/giu;
9
+ const LONG_BASE64_LINE_PATTERN = /^[A-Za-z0-9+/=\s]{160,}$/u;
10
+ const TITLE_WORD_PATTERN = /[A-Za-z0-9]+(?:'[A-Za-z0-9]+)*/g;
11
+ const TARGET_TITLE_WORD_COUNT = 2;
12
+ const FALLBACK_FILL_WORDS = ['current', 'thread'] as const;
13
+ const FALLBACK_STOP_WORDS = new Set([
14
+ 'a',
15
+ 'an',
16
+ 'and',
17
+ 'are',
18
+ 'as',
19
+ 'at',
20
+ 'be',
21
+ 'build',
22
+ 'for',
23
+ 'from',
24
+ 'in',
25
+ 'into',
26
+ 'is',
27
+ 'it',
28
+ 'of',
29
+ 'on',
30
+ 'or',
31
+ 'that',
32
+ 'the',
33
+ 'this',
34
+ 'to',
35
+ 'up',
36
+ 'with',
37
+ ]);
38
+ const DEFAULT_HAIKU_MODEL_ID = 'claude-3-5-haiku-latest';
39
+
40
+ interface ThreadTitlePromptHistoryEntry {
41
+ readonly text: string;
42
+ readonly observedAt: string;
43
+ readonly hash: string;
44
+ }
45
+
46
+ interface ThreadTitleNamerInput {
47
+ readonly conversationId: string;
48
+ readonly agentType: string;
49
+ readonly currentTitle: string;
50
+ readonly promptHistory: readonly ThreadTitlePromptHistoryEntry[];
51
+ }
52
+
53
+ export interface ThreadTitleNamer {
54
+ suggest(input: ThreadTitleNamerInput): Promise<string | null>;
55
+ }
56
+
57
+ interface AnthropicThreadTitleNamerOptions {
58
+ readonly apiKey: string;
59
+ readonly modelId?: string;
60
+ readonly baseUrl?: string;
61
+ readonly fetch?: typeof fetch;
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 trimmedString(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 historyEntriesFromAdapterState(
80
+ adapterState: Record<string, unknown>,
81
+ ): readonly ThreadTitlePromptHistoryEntry[] {
82
+ const stored = asRecord(adapterState[THREAD_TITLE_ADAPTER_STATE_KEY]);
83
+ if (stored === null) {
84
+ return [];
85
+ }
86
+ const promptsRaw = stored['prompts'];
87
+ if (!Array.isArray(promptsRaw)) {
88
+ return [];
89
+ }
90
+ const parsed: ThreadTitlePromptHistoryEntry[] = [];
91
+ for (const item of promptsRaw) {
92
+ const record = asRecord(item);
93
+ if (record === null) {
94
+ continue;
95
+ }
96
+ const text = trimmedString(record['text']);
97
+ const observedAt = trimmedString(record['observedAt']);
98
+ const hash = trimmedString(record['hash']);
99
+ if (text === null || observedAt === null || hash === null) {
100
+ continue;
101
+ }
102
+ parsed.push({
103
+ text,
104
+ observedAt,
105
+ hash,
106
+ });
107
+ }
108
+ return parsed;
109
+ }
110
+
111
+ function serializePromptHistory(
112
+ entries: readonly ThreadTitlePromptHistoryEntry[],
113
+ ): Readonly<Record<string, unknown>> {
114
+ return {
115
+ prompts: entries.map((entry) => ({
116
+ text: entry.text,
117
+ observedAt: entry.observedAt,
118
+ hash: entry.hash,
119
+ })),
120
+ };
121
+ }
122
+
123
+ function sanitizePromptLine(line: string): string | null {
124
+ const withoutDataUrl = line
125
+ .replace(IMAGE_DATA_URL_PATTERN, ' ')
126
+ .replace(MARKDOWN_IMAGE_PATTERN, ' ')
127
+ .replace(HTML_IMAGE_PATTERN, ' ')
128
+ .trim();
129
+ if (withoutDataUrl.length === 0) {
130
+ return null;
131
+ }
132
+ if (LONG_BASE64_LINE_PATTERN.test(withoutDataUrl)) {
133
+ return null;
134
+ }
135
+ return withoutDataUrl;
136
+ }
137
+
138
+ export function sanitizePromptForThreadTitle(text: string): string | null {
139
+ const normalized = text
140
+ .replace(/\r\n/gu, '\n')
141
+ .replace(/\r/gu, '\n')
142
+ .split('\n')
143
+ .map((line) => sanitizePromptLine(line))
144
+ .filter((line): line is string => line !== null)
145
+ .join('\n')
146
+ .replace(/[ \t]{2,}/gu, ' ')
147
+ .trim();
148
+ if (normalized.length === 0) {
149
+ return null;
150
+ }
151
+ if (normalized.length <= MAX_SANITIZED_PROMPT_CHARS) {
152
+ return normalized;
153
+ }
154
+ return `${normalized.slice(0, MAX_SANITIZED_PROMPT_CHARS).trimEnd()}...`;
155
+ }
156
+
157
+ export function normalizeThreadTitleCandidate(value: string): string | null {
158
+ const compact = value.trim();
159
+ if (compact.length === 0) {
160
+ return null;
161
+ }
162
+ const words = compact.match(TITLE_WORD_PATTERN)?.map((word) => word.trim()) ?? [];
163
+ if (words.length < TARGET_TITLE_WORD_COUNT) {
164
+ return null;
165
+ }
166
+ const filtered = words
167
+ .map((word) => word.toLowerCase())
168
+ .filter((word) => word !== 'title' && word.length > 0);
169
+ if (filtered.length < TARGET_TITLE_WORD_COUNT) {
170
+ return null;
171
+ }
172
+ return filtered.slice(0, TARGET_TITLE_WORD_COUNT).join(' ');
173
+ }
174
+
175
+ export function fallbackThreadTitleFromPromptHistory(
176
+ promptHistory: readonly ThreadTitlePromptHistoryEntry[],
177
+ ): string {
178
+ const selected: string[] = [];
179
+ for (let index = promptHistory.length - 1; index >= 0; index -= 1) {
180
+ const entry = promptHistory[index];
181
+ if (entry === undefined) {
182
+ continue;
183
+ }
184
+ const words = entry.text.match(TITLE_WORD_PATTERN) ?? [];
185
+ for (const rawWord of words) {
186
+ const normalized = rawWord.toLowerCase();
187
+ if (normalized.length < 3 || FALLBACK_STOP_WORDS.has(normalized)) {
188
+ continue;
189
+ }
190
+ if (selected.includes(normalized)) {
191
+ continue;
192
+ }
193
+ selected.push(normalized);
194
+ if (selected.length >= TARGET_TITLE_WORD_COUNT) {
195
+ return selected.join(' ');
196
+ }
197
+ }
198
+ }
199
+ for (const fallback of FALLBACK_FILL_WORDS) {
200
+ selected.push(fallback);
201
+ if (selected.length >= TARGET_TITLE_WORD_COUNT) {
202
+ break;
203
+ }
204
+ }
205
+ return selected.slice(0, TARGET_TITLE_WORD_COUNT).join(' ');
206
+ }
207
+
208
+ export function readThreadTitlePromptHistory(
209
+ adapterState: Record<string, unknown>,
210
+ ): readonly ThreadTitlePromptHistoryEntry[] {
211
+ return historyEntriesFromAdapterState(adapterState);
212
+ }
213
+
214
+ export function appendThreadTitlePromptHistory(
215
+ adapterState: Record<string, unknown>,
216
+ prompt: StreamSessionPromptRecord,
217
+ ): {
218
+ readonly nextAdapterState: Record<string, unknown>;
219
+ readonly promptHistory: readonly ThreadTitlePromptHistoryEntry[];
220
+ readonly added: boolean;
221
+ } {
222
+ const text = prompt.text === null ? null : sanitizePromptForThreadTitle(prompt.text);
223
+ const existing = historyEntriesFromAdapterState(adapterState);
224
+ if (text === null) {
225
+ return {
226
+ nextAdapterState: adapterState,
227
+ promptHistory: existing,
228
+ added: false,
229
+ };
230
+ }
231
+ const nextHistory: ThreadTitlePromptHistoryEntry[] = [
232
+ ...existing,
233
+ {
234
+ text,
235
+ observedAt: prompt.observedAt,
236
+ hash: prompt.hash,
237
+ },
238
+ ];
239
+ const nextAdapterState: Record<string, unknown> = {
240
+ ...adapterState,
241
+ [THREAD_TITLE_ADAPTER_STATE_KEY]: serializePromptHistory(nextHistory),
242
+ };
243
+ return {
244
+ nextAdapterState,
245
+ promptHistory: nextHistory,
246
+ added: true,
247
+ };
248
+ }
249
+
250
+ export function createAnthropicThreadTitleNamer(
251
+ options: AnthropicThreadTitleNamerOptions,
252
+ ): ThreadTitleNamer {
253
+ const anthropic = createAnthropic({
254
+ apiKey: options.apiKey,
255
+ ...(options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }),
256
+ ...(options.fetch === undefined ? {} : { fetch: options.fetch }),
257
+ });
258
+ const model = anthropic(options.modelId ?? DEFAULT_HAIKU_MODEL_ID);
259
+ return {
260
+ async suggest(input: ThreadTitleNamerInput): Promise<string | null> {
261
+ if (input.promptHistory.length === 0) {
262
+ return null;
263
+ }
264
+ const promptLines = input.promptHistory.map(
265
+ (entry, index) => `${String(index + 1)}. ${entry.text}`,
266
+ );
267
+ const response = await generateText({
268
+ model,
269
+ system: [
270
+ 'You name active coding-agent threads.',
271
+ 'Use the full user prompt history to keep titles relevant and fresh.',
272
+ 'Stay high-level and avoid low-level implementation details.',
273
+ 'Return exactly 2 words in lowercase with no punctuation and no extra text.',
274
+ ].join(' '),
275
+ prompt: [
276
+ `Agent: ${input.agentType}`,
277
+ `Current title: ${input.currentTitle}`,
278
+ `Conversation id: ${input.conversationId}`,
279
+ 'Prompt history (oldest to newest):',
280
+ ...promptLines,
281
+ 'Return a new title now.',
282
+ ].join('\n'),
283
+ maxOutputTokens: 16,
284
+ temperature: 0,
285
+ });
286
+ const normalized = normalizeThreadTitleCandidate(response.text);
287
+ return normalized ?? fallbackThreadTitleFromPromptHistory(input.promptHistory);
288
+ },
289
+ };
290
+ }
@@ -421,6 +421,17 @@ function parseConversationUpdate(record: CommandRecord): StreamCommand | null {
421
421
  };
422
422
  }
423
423
 
424
+ function parseConversationTitleRefresh(record: CommandRecord): StreamCommand | null {
425
+ const conversationId = readString(record['conversationId']);
426
+ if (conversationId === null) {
427
+ return null;
428
+ }
429
+ return {
430
+ type: 'conversation.title.refresh',
431
+ conversationId,
432
+ };
433
+ }
434
+
424
435
  function parseConversationDelete(record: CommandRecord): StreamCommand | null {
425
436
  const conversationId = readString(record['conversationId']);
426
437
  if (conversationId === null) {
@@ -1628,6 +1639,7 @@ export const DEFAULT_STREAM_COMMAND_PARSERS: StreamCommandParserRegistry = {
1628
1639
  'conversation.list': parseConversationList,
1629
1640
  'conversation.archive': parseConversationArchive,
1630
1641
  'conversation.update': parseConversationUpdate,
1642
+ 'conversation.title.refresh': parseConversationTitleRefresh,
1631
1643
  'conversation.delete': parseConversationDelete,
1632
1644
  'repository.upsert': parseRepositoryUpsert,
1633
1645
  'repository.get': parseRepositoryGet,
@@ -6,6 +6,8 @@ export type StreamSessionRuntimeStatus = 'running' | 'needs-input' | 'completed'
6
6
  export type StreamSessionListSort = 'attention-first' | 'started-desc' | 'started-asc';
7
7
  export type StreamTelemetrySource = 'otlp-log' | 'otlp-metric' | 'otlp-trace' | 'history';
8
8
  export type StreamTelemetryStatusHint = 'running' | 'completed' | 'needs-input';
9
+ export type StreamPromptCaptureSource = 'otlp-log' | 'hook-notify' | 'history';
10
+ export type StreamPromptConfidence = 'high' | 'medium' | 'low';
9
11
  export type StreamSessionControllerType = 'human' | 'agent' | 'automation';
10
12
  export type StreamSessionDisplayPhase = 'needs-action' | 'starting' | 'working' | 'idle' | 'exited';
11
13
 
@@ -46,6 +48,16 @@ export interface StreamSessionKeyEventRecord {
46
48
  statusHint: StreamTelemetryStatusHint | null;
47
49
  }
48
50
 
51
+ export interface StreamSessionPromptRecord {
52
+ text: string | null;
53
+ hash: string;
54
+ confidence: StreamPromptConfidence;
55
+ captureSource: StreamPromptCaptureSource;
56
+ providerEventName: string | null;
57
+ providerPayloadKeys: string[];
58
+ observedAt: string;
59
+ }
60
+
49
61
  interface DirectoryUpsertCommand {
50
62
  type: 'directory.upsert';
51
63
  directoryId?: string;
@@ -107,6 +119,11 @@ interface ConversationUpdateCommand {
107
119
  title: string;
108
120
  }
109
121
 
122
+ interface ConversationTitleRefreshCommand {
123
+ type: 'conversation.title.refresh';
124
+ conversationId: string;
125
+ }
126
+
110
127
  interface ConversationDeleteCommand {
111
128
  type: 'conversation.delete';
112
129
  conversationId: string;
@@ -482,6 +499,7 @@ export type StreamCommand =
482
499
  | ConversationListCommand
483
500
  | ConversationArchiveCommand
484
501
  | ConversationUpdateCommand
502
+ | ConversationTitleRefreshCommand
485
503
  | ConversationDeleteCommand
486
504
  | RepositoryUpsertCommand
487
505
  | RepositoryGetCommand
@@ -708,6 +726,14 @@ export type StreamObservedEvent =
708
726
  directoryId: string | null;
709
727
  conversationId: string | null;
710
728
  }
729
+ | {
730
+ type: 'session-prompt-event';
731
+ sessionId: string;
732
+ prompt: StreamSessionPromptRecord;
733
+ ts: string;
734
+ directoryId: string | null;
735
+ conversationId: string | null;
736
+ }
711
737
  | {
712
738
  type: 'session-control';
713
739
  sessionId: string;
@@ -1057,6 +1083,20 @@ function parseTelemetryStatusHint(value: unknown): StreamTelemetryStatusHint | n
1057
1083
  return undefined;
1058
1084
  }
1059
1085
 
1086
+ function parsePromptCaptureSource(value: unknown): StreamPromptCaptureSource | null {
1087
+ if (value === 'otlp-log' || value === 'hook-notify' || value === 'history') {
1088
+ return value;
1089
+ }
1090
+ return null;
1091
+ }
1092
+
1093
+ function parsePromptConfidence(value: unknown): StreamPromptConfidence | null {
1094
+ if (value === 'high' || value === 'medium' || value === 'low') {
1095
+ return value;
1096
+ }
1097
+ return null;
1098
+ }
1099
+
1060
1100
  function parseTelemetrySummary(value: unknown): StreamTelemetrySummary | null | undefined {
1061
1101
  if (value === undefined) {
1062
1102
  return undefined;
@@ -1189,6 +1229,50 @@ function parseSessionKeyEventRecord(value: unknown): StreamSessionKeyEventRecord
1189
1229
  };
1190
1230
  }
1191
1231
 
1232
+ function parseSessionPromptRecord(value: unknown): StreamSessionPromptRecord | null {
1233
+ const record = asRecord(value);
1234
+ if (record === null) {
1235
+ return null;
1236
+ }
1237
+ const text = record['text'] === null ? null : readString(record['text']);
1238
+ const hash = readString(record['hash']);
1239
+ const confidence = parsePromptConfidence(record['confidence']);
1240
+ const captureSource = parsePromptCaptureSource(record['captureSource']);
1241
+ const providerEventName =
1242
+ record['providerEventName'] === null ? null : readString(record['providerEventName']);
1243
+ const providerPayloadKeysValue = record['providerPayloadKeys'];
1244
+ const observedAt = readString(record['observedAt']);
1245
+ if (
1246
+ (text === null && record['text'] !== null) ||
1247
+ hash === null ||
1248
+ hash.trim().length === 0 ||
1249
+ confidence === null ||
1250
+ captureSource === null ||
1251
+ (providerEventName === null && record['providerEventName'] !== null) ||
1252
+ !Array.isArray(providerPayloadKeysValue) ||
1253
+ observedAt === null
1254
+ ) {
1255
+ return null;
1256
+ }
1257
+ const providerPayloadKeys: string[] = [];
1258
+ for (const entry of providerPayloadKeysValue) {
1259
+ const key = readString(entry);
1260
+ if (key === null) {
1261
+ return null;
1262
+ }
1263
+ providerPayloadKeys.push(key);
1264
+ }
1265
+ return {
1266
+ text: record['text'] === null ? null : text,
1267
+ hash,
1268
+ confidence,
1269
+ captureSource,
1270
+ providerEventName: record['providerEventName'] === null ? null : providerEventName,
1271
+ providerPayloadKeys,
1272
+ observedAt,
1273
+ };
1274
+ }
1275
+
1192
1276
  function parseStreamObservedEvent(value: unknown): StreamObservedEvent | null {
1193
1277
  const record = asRecord(value);
1194
1278
  if (record === null) {
@@ -1609,6 +1693,31 @@ function parseStreamObservedEvent(value: unknown): StreamObservedEvent | null {
1609
1693
  };
1610
1694
  }
1611
1695
 
1696
+ if (type === 'session-prompt-event') {
1697
+ const sessionId = readString(record['sessionId']);
1698
+ const prompt = parseSessionPromptRecord(record['prompt']);
1699
+ const ts = readString(record['ts']);
1700
+ const directoryId = readString(record['directoryId']);
1701
+ const conversationId = readString(record['conversationId']);
1702
+ if (
1703
+ sessionId === null ||
1704
+ prompt === null ||
1705
+ ts === null ||
1706
+ (record['directoryId'] !== null && directoryId === null) ||
1707
+ (record['conversationId'] !== null && conversationId === null)
1708
+ ) {
1709
+ return null;
1710
+ }
1711
+ return {
1712
+ type,
1713
+ sessionId,
1714
+ prompt,
1715
+ ts,
1716
+ directoryId: record['directoryId'] === null ? null : directoryId,
1717
+ conversationId: record['conversationId'] === null ? null : conversationId,
1718
+ };
1719
+ }
1720
+
1612
1721
  if (type === 'session-control') {
1613
1722
  const sessionId = readString(record['sessionId']);
1614
1723
  const action = readString(record['action']);
@@ -419,6 +419,11 @@ interface ExecuteCommandContext {
419
419
  }>;
420
420
  };
421
421
  readonly streamCursor: number;
422
+ refreshConversationTitle(conversationId: string): Promise<{
423
+ conversation: ControlPlaneConversationRecord;
424
+ status: 'updated' | 'unchanged' | 'skipped';
425
+ reason: string | null;
426
+ }>;
422
427
  refreshGitStatusForDirectory(
423
428
  directory: ControlPlaneDirectoryRecord,
424
429
  options?: {
@@ -1403,6 +1408,15 @@ export async function executeStreamServerCommand(
1403
1408
  };
1404
1409
  }
1405
1410
 
1411
+ if (command.type === 'conversation.title.refresh') {
1412
+ const refreshed = await ctx.refreshConversationTitle(command.conversationId);
1413
+ return {
1414
+ conversation: ctx.conversationRecord(refreshed.conversation),
1415
+ status: refreshed.status,
1416
+ reason: refreshed.reason,
1417
+ };
1418
+ }
1419
+
1406
1420
  if (command.type === 'conversation.delete') {
1407
1421
  const existing = ctx.stateStore.getConversation(command.conversationId);
1408
1422
  if (existing === null) {
@@ -5,10 +5,12 @@ import type {
5
5
  StreamSessionController,
6
6
  StreamSessionEvent,
7
7
  StreamSessionKeyEventRecord,
8
+ StreamSessionPromptRecord,
8
9
  StreamSessionRuntimeStatus,
9
10
  StreamSessionStatusModel,
10
11
  StreamSignal,
11
12
  } from './stream-protocol.ts';
13
+ import { SessionPromptEngine } from './prompt/session-prompt-engine.ts';
12
14
 
13
15
  const CLAUDE_NEEDS_INPUT_NOTIFICATION_TYPES = new Set([
14
16
  'permissionrequest',
@@ -22,6 +24,7 @@ const CLAUDE_RUNNING_NOTIFICATION_TYPES = new Set([
22
24
  'approvalapproved',
23
25
  'approvalgranted',
24
26
  ]);
27
+ const sessionPromptEngine = new SessionPromptEngine();
25
28
 
26
29
  interface RuntimeSession {
27
30
  id: string;
@@ -83,6 +86,7 @@ interface StreamRuntimeContext {
83
86
  state: RuntimeSession,
84
87
  keyEvent: StreamSessionKeyEventRecord,
85
88
  ): void;
89
+ publishSessionPromptObservedEvent(state: RuntimeSession, prompt: StreamSessionPromptRecord): void;
86
90
  refreshSessionStatusModel(state: RuntimeSession, observedAt: string): void;
87
91
  toPublicSessionController(
88
92
  controller: StreamSessionController | null,
@@ -557,6 +561,14 @@ export function handleSessionEvent(
557
561
  ctx.stateStore.updateConversationAdapterState(sessionState.id, mergedAdapterState);
558
562
  }
559
563
  if (mapped.type === 'notify') {
564
+ const promptEvent = sessionPromptEngine.extractFromNotify({
565
+ agentType: sessionState.agentType,
566
+ payload: mapped.record.payload,
567
+ observedAt,
568
+ });
569
+ if (promptEvent !== null) {
570
+ ctx.publishSessionPromptObservedEvent(sessionState, promptEvent);
571
+ }
560
572
  const keyEvent =
561
573
  notifyKeyEventFromPayload(sessionState.agentType, mapped.record.payload, observedAt) ??
562
574
  unmappedNotifyKeyEventFromPayload(