@jmoyers/harness 0.1.8 → 0.1.9

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.
package/README.md CHANGED
@@ -92,6 +92,7 @@ harness --session my-session
92
92
  - Runs with `--watch` by default.
93
93
  - Install actions are availability-aware and config-driven (`*.install.command`), opening a terminal thread to run the configured install command when a tool is missing.
94
94
  - Global command palette (`ctrl+p` / `cmd+p`) includes:
95
+ - `Critique AI Review: Unstaged Changes`
95
96
  - `Critique AI Review: Staged Changes`
96
97
  - `Critique AI Review: Current Branch vs Base`
97
98
  - These start a terminal thread and run `critique review ...`, preferring `claude` when available and otherwise using `opencode` when installed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jmoyers/harness",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -77,6 +77,8 @@
77
77
  "test": "bun run build:ptyd && bun test",
78
78
  "test:integration:codex-status": "bun scripts/integration-codex-status-sequence.ts",
79
79
  "test:integration:codex-status:long": "bun scripts/integration-codex-status-sequence.ts --timeout-ms 180000 --prompt \"Write three short poems titled Dawn, Voltage, and Orbit. Before each poem, perform one repository inspection action and include one factual line from that action. Use at least three total tool actions and do not edit any files.\"",
80
+ "test:integration:agent-prompts": "bun scripts/integration-agent-prompt-parity.ts",
81
+ "test:integration:agent-prompts:long": "bun scripts/integration-agent-prompt-parity.ts --timeout-ms 180000",
80
82
  "smoke:harness-ai": "bun scripts/harness-ai-smoke.ts",
81
83
  "smoke:harness-ai:parity": "bun scripts/harness-ai-parity-smoke.mts",
82
84
  "test:coverage": "bun run build:ptyd && bun test --coverage --coverage-reporter=lcov --coverage-dir .harness/coverage-bun && bun run coverage:check",
@@ -2619,17 +2619,23 @@ async function main(): Promise<number> {
2619
2619
 
2620
2620
  const runCritiqueReviewFromCommandMenu = (
2621
2621
  directoryId: string,
2622
- mode: 'staged' | 'base-branch',
2622
+ mode: 'unstaged' | 'staged' | 'base-branch',
2623
2623
  ): void => {
2624
2624
  queueControlPlaneOp(async () => {
2625
2625
  const agent = resolveCritiqueReviewAgentFromEnvironment();
2626
- if (mode === 'staged') {
2626
+ if (mode !== 'base-branch') {
2627
2627
  const commandText = buildCritiqueReviewCommand({
2628
- mode: 'staged',
2628
+ mode,
2629
2629
  agent,
2630
2630
  });
2631
2631
  await runCommandInNewTerminalThread(directoryId, commandText);
2632
- setCommandNotice(`running critique staged review (${agent ?? 'default'})`);
2632
+ const reviewLabelByMode: Readonly<Record<'unstaged' | 'staged', string>> = {
2633
+ unstaged: 'unstaged',
2634
+ staged: 'staged',
2635
+ };
2636
+ setCommandNotice(
2637
+ `running critique ${reviewLabelByMode[mode]} review (${agent ?? 'default'})`,
2638
+ );
2633
2639
  return;
2634
2640
  }
2635
2641
  const baseBranch = await resolveCritiqueReviewBaseBranchForDirectory(directoryId);
@@ -2653,6 +2659,16 @@ async function main(): Promise<number> {
2653
2659
  return [];
2654
2660
  }
2655
2661
  return [
2662
+ {
2663
+ id: 'critique.review.unstaged',
2664
+ title: 'Critique AI Review: Unstaged Changes',
2665
+ aliases: ['critique unstaged review', 'review unstaged diff', 'ai review unstaged'],
2666
+ keywords: ['critique', 'review', 'unstaged', 'diff', 'ai'],
2667
+ detail: 'runs critique review',
2668
+ run: () => {
2669
+ runCritiqueReviewFromCommandMenu(directoryId, 'unstaged');
2670
+ },
2671
+ },
2656
2672
  {
2657
2673
  id: 'critique.review.staged',
2658
2674
  title: 'Critique AI Review: Staged Changes',
@@ -54,6 +54,7 @@ interface AgentEventTypeMap {
54
54
  'session.status': Extract<StreamObservedEvent, { type: 'session-status' }>;
55
55
  'session.event': Extract<StreamObservedEvent, { type: 'session-event' }>;
56
56
  'session.telemetry': Extract<StreamObservedEvent, { type: 'session-key-event' }>;
57
+ 'session.prompt': Extract<StreamObservedEvent, { type: 'session-prompt-event' }>;
57
58
  'session.control': Extract<StreamObservedEvent, { type: 'session-control' }>;
58
59
  'session.output': Extract<StreamObservedEvent, { type: 'session-output' }>;
59
60
  }
@@ -1155,6 +1156,9 @@ function mapObservedEventType(observed: StreamObservedEvent): AgentRealtimeEvent
1155
1156
  if (observed.type === 'session-key-event') {
1156
1157
  return 'session.telemetry';
1157
1158
  }
1159
+ if (observed.type === 'session-prompt-event') {
1160
+ return 'session.prompt';
1161
+ }
1158
1162
  if (observed.type === 'session-control') {
1159
1163
  return 'session.control';
1160
1164
  }
@@ -0,0 +1,191 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type {
3
+ StreamPromptCaptureSource,
4
+ StreamPromptConfidence,
5
+ StreamSessionPromptRecord,
6
+ StreamTelemetrySource,
7
+ } from '../stream-protocol.ts';
8
+
9
+ export interface PromptFromNotifyInput {
10
+ readonly payload: Record<string, unknown>;
11
+ readonly observedAt: string;
12
+ }
13
+
14
+ export interface PromptFromTelemetryInput {
15
+ readonly source: StreamTelemetrySource;
16
+ readonly eventName: string | null;
17
+ readonly summary: string | null;
18
+ readonly payload: Record<string, unknown>;
19
+ readonly observedAt: string;
20
+ }
21
+
22
+ export interface AgentPromptExtractor {
23
+ readonly agentType: string;
24
+ fromNotify(input: PromptFromNotifyInput): StreamSessionPromptRecord | null;
25
+ fromTelemetry(input: PromptFromTelemetryInput): StreamSessionPromptRecord | null;
26
+ }
27
+
28
+ export function readTrimmedString(value: unknown): string | null {
29
+ if (typeof value !== 'string') {
30
+ return null;
31
+ }
32
+ const trimmed = value.trim();
33
+ return trimmed.length > 0 ? trimmed : null;
34
+ }
35
+
36
+ export function normalizeEventToken(value: string): string {
37
+ return value
38
+ .trim()
39
+ .toLowerCase()
40
+ .replace(/[^a-z0-9]+/g, '');
41
+ }
42
+
43
+ function asRecord(value: unknown): Record<string, unknown> | null {
44
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
45
+ return null;
46
+ }
47
+ return value as Record<string, unknown>;
48
+ }
49
+
50
+ function stableSerialize(value: unknown, depth = 0): string {
51
+ if (depth > 4) {
52
+ return '[depth-limit]';
53
+ }
54
+ if (value === null) {
55
+ return 'null';
56
+ }
57
+ if (typeof value === 'string') {
58
+ return JSON.stringify(value);
59
+ }
60
+ if (typeof value === 'number' || typeof value === 'boolean') {
61
+ return String(value);
62
+ }
63
+ if (Array.isArray(value)) {
64
+ return `[${value
65
+ .slice(0, 24)
66
+ .map((entry) => stableSerialize(entry, depth + 1))
67
+ .join(',')}]`;
68
+ }
69
+ const record = asRecord(value);
70
+ if (record === null) {
71
+ return typeof value;
72
+ }
73
+ const keys = Object.keys(record).sort().slice(0, 24);
74
+ return `{${keys
75
+ .map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key], depth + 1)}`)
76
+ .join(',')}}`;
77
+ }
78
+
79
+ function hashPromptPayload(input: {
80
+ readonly text: string | null;
81
+ readonly providerEventName: string | null;
82
+ readonly payload: Record<string, unknown>;
83
+ }): string {
84
+ const hash = createHash('sha256');
85
+ hash.update(input.providerEventName ?? '');
86
+ hash.update('\n');
87
+ hash.update(input.text ?? '');
88
+ hash.update('\n');
89
+ hash.update(stableSerialize(input.payload));
90
+ return hash.digest('hex');
91
+ }
92
+
93
+ function lookupRecordValue(
94
+ record: Record<string, unknown>,
95
+ candidateKeys: readonly string[],
96
+ ): string | null {
97
+ const normalizedKeys = new Set(candidateKeys.map((key) => key.toLowerCase()));
98
+ for (const [key, value] of Object.entries(record)) {
99
+ if (!normalizedKeys.has(key.toLowerCase())) {
100
+ continue;
101
+ }
102
+ const text = readTrimmedString(value);
103
+ if (text !== null) {
104
+ return text;
105
+ }
106
+ }
107
+ return null;
108
+ }
109
+
110
+ export function findPromptText(
111
+ value: unknown,
112
+ options: {
113
+ readonly keys: readonly string[];
114
+ readonly maxDepth?: number;
115
+ },
116
+ ): string | null {
117
+ const maxDepth = options.maxDepth ?? 3;
118
+ const visited = new Set<unknown>();
119
+ const queue: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }];
120
+ while (queue.length > 0) {
121
+ const next = queue.shift();
122
+ if (next === undefined) {
123
+ continue;
124
+ }
125
+ if (next.depth > maxDepth) {
126
+ continue;
127
+ }
128
+ if (next.value !== null && typeof next.value === 'object') {
129
+ if (visited.has(next.value)) {
130
+ continue;
131
+ }
132
+ visited.add(next.value);
133
+ }
134
+ if (typeof next.value === 'string') {
135
+ if (next.depth === 0) {
136
+ const text = readTrimmedString(next.value);
137
+ if (text !== null) {
138
+ return text;
139
+ }
140
+ }
141
+ continue;
142
+ }
143
+ const record = asRecord(next.value);
144
+ if (record !== null) {
145
+ const match = lookupRecordValue(record, options.keys);
146
+ if (match !== null) {
147
+ return match;
148
+ }
149
+ if (next.depth >= maxDepth) {
150
+ continue;
151
+ }
152
+ for (const nested of Object.values(record)) {
153
+ queue.push({ value: nested, depth: next.depth + 1 });
154
+ }
155
+ continue;
156
+ }
157
+ if (Array.isArray(next.value) && next.depth < maxDepth) {
158
+ for (const nested of next.value.slice(0, 24)) {
159
+ queue.push({ value: nested, depth: next.depth + 1 });
160
+ }
161
+ }
162
+ }
163
+ return null;
164
+ }
165
+
166
+ function providerPayloadKeys(payload: Record<string, unknown>): string[] {
167
+ return Object.keys(payload).sort().slice(0, 12);
168
+ }
169
+
170
+ export function createPromptRecord(input: {
171
+ readonly text: string | null;
172
+ readonly confidence: StreamPromptConfidence;
173
+ readonly captureSource: StreamPromptCaptureSource;
174
+ readonly providerEventName: string | null;
175
+ readonly payload: Record<string, unknown>;
176
+ readonly observedAt: string;
177
+ }): StreamSessionPromptRecord {
178
+ return {
179
+ text: input.text,
180
+ hash: hashPromptPayload({
181
+ text: input.text,
182
+ providerEventName: input.providerEventName,
183
+ payload: input.payload,
184
+ }),
185
+ confidence: input.confidence,
186
+ captureSource: input.captureSource,
187
+ providerEventName: input.providerEventName,
188
+ providerPayloadKeys: providerPayloadKeys(input.payload),
189
+ observedAt: input.observedAt,
190
+ };
191
+ }
@@ -0,0 +1,53 @@
1
+ import type { StreamSessionPromptRecord } from '../../stream-protocol.ts';
2
+ import type {
3
+ AgentPromptExtractor,
4
+ PromptFromNotifyInput,
5
+ PromptFromTelemetryInput,
6
+ } from '../agent-prompt-extractor.ts';
7
+ import {
8
+ createPromptRecord,
9
+ findPromptText,
10
+ normalizeEventToken,
11
+ readTrimmedString,
12
+ } from '../agent-prompt-extractor.ts';
13
+
14
+ function fromNotify(input: PromptFromNotifyInput): StreamSessionPromptRecord | null {
15
+ const hookEventName =
16
+ readTrimmedString(input.payload['hook_event_name']) ??
17
+ readTrimmedString(input.payload['hookEventName']);
18
+ if (hookEventName === null) {
19
+ return null;
20
+ }
21
+ const hookToken = normalizeEventToken(hookEventName);
22
+ if (hookToken !== 'userpromptsubmit') {
23
+ return null;
24
+ }
25
+ const directPrompt =
26
+ readTrimmedString(input.payload['prompt']) ??
27
+ readTrimmedString(input.payload['user_prompt']) ??
28
+ readTrimmedString(input.payload['userPrompt']);
29
+ const fallbackPrompt =
30
+ directPrompt ??
31
+ findPromptText(input.payload, {
32
+ keys: ['prompt', 'user_prompt', 'userPrompt', 'text', 'input', 'query', 'message'],
33
+ maxDepth: 3,
34
+ });
35
+ return createPromptRecord({
36
+ text: fallbackPrompt,
37
+ confidence: directPrompt !== null ? 'high' : fallbackPrompt !== null ? 'medium' : 'low',
38
+ captureSource: 'hook-notify',
39
+ providerEventName: `claude.${hookToken}`,
40
+ payload: input.payload,
41
+ observedAt: input.observedAt,
42
+ });
43
+ }
44
+
45
+ function fromTelemetry(_input: PromptFromTelemetryInput): StreamSessionPromptRecord | null {
46
+ return null;
47
+ }
48
+
49
+ export const claudePromptExtractor: AgentPromptExtractor = {
50
+ agentType: 'claude',
51
+ fromNotify,
52
+ fromTelemetry,
53
+ };
@@ -0,0 +1,50 @@
1
+ import type { StreamSessionPromptRecord } from '../../stream-protocol.ts';
2
+ import type {
3
+ AgentPromptExtractor,
4
+ PromptFromNotifyInput,
5
+ PromptFromTelemetryInput,
6
+ } from '../agent-prompt-extractor.ts';
7
+ import {
8
+ createPromptRecord,
9
+ findPromptText,
10
+ readTrimmedString,
11
+ } from '../agent-prompt-extractor.ts';
12
+
13
+ function fromNotify(_input: PromptFromNotifyInput): StreamSessionPromptRecord | null {
14
+ return null;
15
+ }
16
+
17
+ function fromTelemetry(input: PromptFromTelemetryInput): StreamSessionPromptRecord | null {
18
+ const normalizedEventName = (input.eventName ?? '').trim().toLowerCase();
19
+ if (normalizedEventName !== 'codex.user_prompt' && normalizedEventName !== 'user_prompt') {
20
+ return null;
21
+ }
22
+ if (input.source !== 'otlp-log' && input.source !== 'history') {
23
+ return null;
24
+ }
25
+ const textFromPayload = findPromptText(input.payload, {
26
+ keys: ['prompt', 'user_prompt', 'userPrompt', 'message', 'text', 'content', 'input', 'body'],
27
+ maxDepth: 4,
28
+ });
29
+ const normalizedSummary = readTrimmedString(input.summary);
30
+ const textFromSummary =
31
+ normalizedSummary !== null && normalizedSummary.toLowerCase().startsWith('prompt:')
32
+ ? readTrimmedString(normalizedSummary.slice('prompt:'.length))
33
+ : null;
34
+ const promptText = textFromPayload ?? textFromSummary;
35
+ const confidence = promptText === null ? 'low' : textFromPayload !== null ? 'high' : 'medium';
36
+ return createPromptRecord({
37
+ text: promptText,
38
+ confidence,
39
+ captureSource: input.source === 'history' ? 'history' : 'otlp-log',
40
+ providerEventName: input.eventName,
41
+ payload: input.payload,
42
+ observedAt: input.observedAt,
43
+ });
44
+ }
45
+
46
+ export const codexPromptExtractor: AgentPromptExtractor = {
47
+ agentType: 'codex',
48
+ fromNotify,
49
+ fromTelemetry,
50
+ };
@@ -0,0 +1,56 @@
1
+ import type { StreamSessionPromptRecord } from '../../stream-protocol.ts';
2
+ import type {
3
+ AgentPromptExtractor,
4
+ PromptFromNotifyInput,
5
+ PromptFromTelemetryInput,
6
+ } from '../agent-prompt-extractor.ts';
7
+ import {
8
+ createPromptRecord,
9
+ findPromptText,
10
+ normalizeEventToken,
11
+ readTrimmedString,
12
+ } from '../agent-prompt-extractor.ts';
13
+
14
+ function fromNotify(input: PromptFromNotifyInput): StreamSessionPromptRecord | null {
15
+ const hookEventName =
16
+ readTrimmedString(input.payload['hook_event_name']) ??
17
+ readTrimmedString(input.payload['hookEventName']) ??
18
+ readTrimmedString(input.payload['event_name']) ??
19
+ readTrimmedString(input.payload['eventName']) ??
20
+ readTrimmedString(input.payload['event']);
21
+ if (hookEventName === null) {
22
+ return null;
23
+ }
24
+ const hookToken = normalizeEventToken(hookEventName);
25
+ if (hookToken !== 'beforesubmitprompt') {
26
+ return null;
27
+ }
28
+ const directPrompt =
29
+ readTrimmedString(input.payload['prompt']) ??
30
+ readTrimmedString(input.payload['user_prompt']) ??
31
+ readTrimmedString(input.payload['userPrompt']);
32
+ const promptText =
33
+ directPrompt ??
34
+ findPromptText(input.payload, {
35
+ keys: ['prompt', 'user_prompt', 'userPrompt', 'text', 'input', 'query', 'message'],
36
+ maxDepth: 3,
37
+ });
38
+ return createPromptRecord({
39
+ text: promptText,
40
+ confidence: directPrompt !== null ? 'high' : promptText !== null ? 'medium' : 'low',
41
+ captureSource: 'hook-notify',
42
+ providerEventName: `cursor.${hookToken}`,
43
+ payload: input.payload,
44
+ observedAt: input.observedAt,
45
+ });
46
+ }
47
+
48
+ function fromTelemetry(_input: PromptFromTelemetryInput): StreamSessionPromptRecord | null {
49
+ return null;
50
+ }
51
+
52
+ export const cursorPromptExtractor: AgentPromptExtractor = {
53
+ agentType: 'cursor',
54
+ fromNotify,
55
+ fromTelemetry,
56
+ };
@@ -0,0 +1,69 @@
1
+ import type { StreamSessionPromptRecord, StreamTelemetrySource } from '../stream-protocol.ts';
2
+ import type { AgentPromptExtractor } from './agent-prompt-extractor.ts';
3
+ import { claudePromptExtractor } from './extractors/claude-prompt-extractor.ts';
4
+ import { codexPromptExtractor } from './extractors/codex-prompt-extractor.ts';
5
+ import { cursorPromptExtractor } from './extractors/cursor-prompt-extractor.ts';
6
+
7
+ type SupportedAgentType = 'codex' | 'claude' | 'cursor';
8
+
9
+ const DEFAULT_EXTRACTORS: Record<SupportedAgentType, AgentPromptExtractor> = {
10
+ codex: codexPromptExtractor,
11
+ claude: claudePromptExtractor,
12
+ cursor: cursorPromptExtractor,
13
+ };
14
+
15
+ function normalizeAgentType(value: string): SupportedAgentType | null {
16
+ const normalized = value.trim().toLowerCase();
17
+ if (normalized === 'codex' || normalized === 'claude' || normalized === 'cursor') {
18
+ return normalized;
19
+ }
20
+ return null;
21
+ }
22
+
23
+ interface PromptFromNotifyInput {
24
+ readonly agentType: string;
25
+ readonly payload: Record<string, unknown>;
26
+ readonly observedAt: string;
27
+ }
28
+
29
+ interface PromptFromTelemetryInput {
30
+ readonly agentType: string;
31
+ readonly source: StreamTelemetrySource;
32
+ readonly eventName: string | null;
33
+ readonly summary: string | null;
34
+ readonly payload: Record<string, unknown>;
35
+ readonly observedAt: string;
36
+ }
37
+
38
+ export class SessionPromptEngine {
39
+ private readonly extractors: Record<SupportedAgentType, AgentPromptExtractor>;
40
+
41
+ constructor(extractors: Record<SupportedAgentType, AgentPromptExtractor> = DEFAULT_EXTRACTORS) {
42
+ this.extractors = extractors;
43
+ }
44
+
45
+ extractFromNotify(input: PromptFromNotifyInput): StreamSessionPromptRecord | null {
46
+ const agentType = normalizeAgentType(input.agentType);
47
+ if (agentType === null) {
48
+ return null;
49
+ }
50
+ return this.extractors[agentType].fromNotify({
51
+ payload: input.payload,
52
+ observedAt: input.observedAt,
53
+ });
54
+ }
55
+
56
+ extractFromTelemetry(input: PromptFromTelemetryInput): StreamSessionPromptRecord | null {
57
+ const agentType = normalizeAgentType(input.agentType);
58
+ if (agentType === null) {
59
+ return null;
60
+ }
61
+ return this.extractors[agentType].fromTelemetry({
62
+ source: input.source,
63
+ eventName: input.eventName,
64
+ summary: input.summary,
65
+ payload: input.payload,
66
+ observedAt: input.observedAt,
67
+ });
68
+ }
69
+ }
@@ -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;
@@ -708,6 +720,14 @@ export type StreamObservedEvent =
708
720
  directoryId: string | null;
709
721
  conversationId: string | null;
710
722
  }
723
+ | {
724
+ type: 'session-prompt-event';
725
+ sessionId: string;
726
+ prompt: StreamSessionPromptRecord;
727
+ ts: string;
728
+ directoryId: string | null;
729
+ conversationId: string | null;
730
+ }
711
731
  | {
712
732
  type: 'session-control';
713
733
  sessionId: string;
@@ -1057,6 +1077,20 @@ function parseTelemetryStatusHint(value: unknown): StreamTelemetryStatusHint | n
1057
1077
  return undefined;
1058
1078
  }
1059
1079
 
1080
+ function parsePromptCaptureSource(value: unknown): StreamPromptCaptureSource | null {
1081
+ if (value === 'otlp-log' || value === 'hook-notify' || value === 'history') {
1082
+ return value;
1083
+ }
1084
+ return null;
1085
+ }
1086
+
1087
+ function parsePromptConfidence(value: unknown): StreamPromptConfidence | null {
1088
+ if (value === 'high' || value === 'medium' || value === 'low') {
1089
+ return value;
1090
+ }
1091
+ return null;
1092
+ }
1093
+
1060
1094
  function parseTelemetrySummary(value: unknown): StreamTelemetrySummary | null | undefined {
1061
1095
  if (value === undefined) {
1062
1096
  return undefined;
@@ -1189,6 +1223,50 @@ function parseSessionKeyEventRecord(value: unknown): StreamSessionKeyEventRecord
1189
1223
  };
1190
1224
  }
1191
1225
 
1226
+ function parseSessionPromptRecord(value: unknown): StreamSessionPromptRecord | null {
1227
+ const record = asRecord(value);
1228
+ if (record === null) {
1229
+ return null;
1230
+ }
1231
+ const text = record['text'] === null ? null : readString(record['text']);
1232
+ const hash = readString(record['hash']);
1233
+ const confidence = parsePromptConfidence(record['confidence']);
1234
+ const captureSource = parsePromptCaptureSource(record['captureSource']);
1235
+ const providerEventName =
1236
+ record['providerEventName'] === null ? null : readString(record['providerEventName']);
1237
+ const providerPayloadKeysValue = record['providerPayloadKeys'];
1238
+ const observedAt = readString(record['observedAt']);
1239
+ if (
1240
+ (text === null && record['text'] !== null) ||
1241
+ hash === null ||
1242
+ hash.trim().length === 0 ||
1243
+ confidence === null ||
1244
+ captureSource === null ||
1245
+ (providerEventName === null && record['providerEventName'] !== null) ||
1246
+ !Array.isArray(providerPayloadKeysValue) ||
1247
+ observedAt === null
1248
+ ) {
1249
+ return null;
1250
+ }
1251
+ const providerPayloadKeys: string[] = [];
1252
+ for (const entry of providerPayloadKeysValue) {
1253
+ const key = readString(entry);
1254
+ if (key === null) {
1255
+ return null;
1256
+ }
1257
+ providerPayloadKeys.push(key);
1258
+ }
1259
+ return {
1260
+ text: record['text'] === null ? null : text,
1261
+ hash,
1262
+ confidence,
1263
+ captureSource,
1264
+ providerEventName: record['providerEventName'] === null ? null : providerEventName,
1265
+ providerPayloadKeys,
1266
+ observedAt,
1267
+ };
1268
+ }
1269
+
1192
1270
  function parseStreamObservedEvent(value: unknown): StreamObservedEvent | null {
1193
1271
  const record = asRecord(value);
1194
1272
  if (record === null) {
@@ -1609,6 +1687,31 @@ function parseStreamObservedEvent(value: unknown): StreamObservedEvent | null {
1609
1687
  };
1610
1688
  }
1611
1689
 
1690
+ if (type === 'session-prompt-event') {
1691
+ const sessionId = readString(record['sessionId']);
1692
+ const prompt = parseSessionPromptRecord(record['prompt']);
1693
+ const ts = readString(record['ts']);
1694
+ const directoryId = readString(record['directoryId']);
1695
+ const conversationId = readString(record['conversationId']);
1696
+ if (
1697
+ sessionId === null ||
1698
+ prompt === null ||
1699
+ ts === null ||
1700
+ (record['directoryId'] !== null && directoryId === null) ||
1701
+ (record['conversationId'] !== null && conversationId === null)
1702
+ ) {
1703
+ return null;
1704
+ }
1705
+ return {
1706
+ type,
1707
+ sessionId,
1708
+ prompt,
1709
+ ts,
1710
+ directoryId: record['directoryId'] === null ? null : directoryId,
1711
+ conversationId: record['conversationId'] === null ? null : conversationId,
1712
+ };
1713
+ }
1714
+
1612
1715
  if (type === 'session-control') {
1613
1716
  const sessionId = readString(record['sessionId']);
1614
1717
  const action = readString(record['action']);
@@ -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(
@@ -18,6 +18,7 @@ import {
18
18
  encodeStreamEnvelope,
19
19
  type StreamObservedEvent,
20
20
  type StreamSessionKeyEventRecord,
21
+ type StreamSessionPromptRecord,
21
22
  type StreamSessionController,
22
23
  type StreamSessionListSort,
23
24
  type StreamSessionRuntimeStatus,
@@ -83,6 +84,7 @@ import {
83
84
  } from './stream-server-session-runtime.ts';
84
85
  import { closeOwnedStateStore as closeOwnedStreamServerStateStore } from './stream-server-state-store.ts';
85
86
  import { SessionStatusEngine } from './status/session-status-engine.ts';
87
+ import { SessionPromptEngine } from './prompt/session-prompt-engine.ts';
86
88
  import {
87
89
  eventIncludesRepositoryId as filterEventIncludesRepositoryId,
88
90
  eventIncludesTaskId as filterEventIncludesTaskId,
@@ -413,6 +415,8 @@ const DEFAULT_GITHUB_POLL_MS = 15_000;
413
415
  const HISTORY_POLL_JITTER_RATIO = 0.35;
414
416
  const SESSION_DIAGNOSTICS_BUCKET_MS = 10_000;
415
417
  const SESSION_DIAGNOSTICS_BUCKET_COUNT = 6;
418
+ const PROMPT_EVENT_DEDUPE_TTL_MS = 5 * 60 * 1000;
419
+ const MAX_PROMPT_EVENT_DEDUPE_ENTRIES = 4096;
416
420
  const DEFAULT_BOOTSTRAP_SESSION_COLS = 80;
417
421
  const DEFAULT_BOOTSTRAP_SESSION_ROWS = 24;
418
422
  const DEFAULT_TENANT_ID = 'tenant-local';
@@ -1054,6 +1058,8 @@ export class ControlPlaneStreamServer {
1054
1058
  };
1055
1059
  private readonly readGitDirectorySnapshot: GitDirectorySnapshotReader;
1056
1060
  private readonly statusEngine = new SessionStatusEngine();
1061
+ private readonly promptEngine = new SessionPromptEngine();
1062
+ private readonly promptEventDedupeByKey = new Map<string, number>();
1057
1063
  private readonly server: Server;
1058
1064
  private readonly telemetryServer: HttpServer | null;
1059
1065
  private telemetryAddress: AddressInfo | null = null;
@@ -1943,6 +1949,31 @@ export class ControlPlaneStreamServer {
1943
1949
  event.observedAt,
1944
1950
  );
1945
1951
  }
1952
+ if (inserted && resolvedSessionId !== null) {
1953
+ const promptEvent = this.promptEngine.extractFromTelemetry({
1954
+ agentType: 'codex',
1955
+ source: event.source,
1956
+ eventName: event.eventName,
1957
+ summary: event.summary,
1958
+ payload: event.payload,
1959
+ observedAt: event.observedAt,
1960
+ });
1961
+ if (promptEvent !== null) {
1962
+ const liveState = this.sessions.get(resolvedSessionId);
1963
+ if (liveState !== undefined) {
1964
+ this.publishSessionPromptObservedEvent(liveState, promptEvent);
1965
+ } else {
1966
+ const observedScope = this.observedScopeForSessionId(resolvedSessionId);
1967
+ if (observedScope !== null) {
1968
+ this.publishSessionPromptObservedEventForScope(
1969
+ resolvedSessionId,
1970
+ observedScope,
1971
+ promptEvent,
1972
+ );
1973
+ }
1974
+ }
1975
+ }
1976
+ }
1946
1977
  if (!inserted || resolvedSessionId === null) {
1947
1978
  return;
1948
1979
  }
@@ -2674,6 +2705,75 @@ export class ControlPlaneStreamServer {
2674
2705
  });
2675
2706
  }
2676
2707
 
2708
+ private promptEventSecondBucket(observedAt: string): string {
2709
+ const normalized = observedAt.trim();
2710
+ if (normalized.length >= 19) {
2711
+ return normalized.slice(0, 19);
2712
+ }
2713
+ return normalized;
2714
+ }
2715
+
2716
+ private shouldPublishPromptEvent(sessionId: string, prompt: StreamSessionPromptRecord): boolean {
2717
+ const bucket = this.promptEventSecondBucket(prompt.observedAt);
2718
+ const dedupeKey = `${sessionId}:${prompt.hash}:${prompt.providerEventName ?? ''}:${bucket}`;
2719
+ if (this.promptEventDedupeByKey.has(dedupeKey)) {
2720
+ return false;
2721
+ }
2722
+ const nowMs = Date.now();
2723
+ this.promptEventDedupeByKey.set(dedupeKey, nowMs);
2724
+ if (this.promptEventDedupeByKey.size > MAX_PROMPT_EVENT_DEDUPE_ENTRIES) {
2725
+ for (const [key, observedMs] of this.promptEventDedupeByKey) {
2726
+ if (nowMs - observedMs > PROMPT_EVENT_DEDUPE_TTL_MS) {
2727
+ this.promptEventDedupeByKey.delete(key);
2728
+ }
2729
+ }
2730
+ if (this.promptEventDedupeByKey.size > MAX_PROMPT_EVENT_DEDUPE_ENTRIES) {
2731
+ let dropCount = this.promptEventDedupeByKey.size - MAX_PROMPT_EVENT_DEDUPE_ENTRIES;
2732
+ for (const key of this.promptEventDedupeByKey.keys()) {
2733
+ this.promptEventDedupeByKey.delete(key);
2734
+ dropCount -= 1;
2735
+ if (dropCount <= 0) {
2736
+ break;
2737
+ }
2738
+ }
2739
+ }
2740
+ }
2741
+ return true;
2742
+ }
2743
+
2744
+ private publishSessionPromptObservedEventForScope(
2745
+ sessionId: string,
2746
+ scope: StreamObservedScope,
2747
+ prompt: StreamSessionPromptRecord,
2748
+ ): void {
2749
+ if (!this.shouldPublishPromptEvent(sessionId, prompt)) {
2750
+ return;
2751
+ }
2752
+ this.publishObservedEvent(scope, {
2753
+ type: 'session-prompt-event',
2754
+ sessionId,
2755
+ prompt: {
2756
+ text: prompt.text,
2757
+ hash: prompt.hash,
2758
+ confidence: prompt.confidence,
2759
+ captureSource: prompt.captureSource,
2760
+ providerEventName: prompt.providerEventName,
2761
+ providerPayloadKeys: [...prompt.providerPayloadKeys],
2762
+ observedAt: prompt.observedAt,
2763
+ },
2764
+ ts: new Date().toISOString(),
2765
+ directoryId: scope.directoryId,
2766
+ conversationId: scope.conversationId,
2767
+ });
2768
+ }
2769
+
2770
+ private publishSessionPromptObservedEvent(
2771
+ state: SessionState,
2772
+ prompt: StreamSessionPromptRecord,
2773
+ ): void {
2774
+ this.publishSessionPromptObservedEventForScope(state.id, this.sessionScope(state), prompt);
2775
+ }
2776
+
2677
2777
  private setSessionStatus(
2678
2778
  state: SessionState,
2679
2779
  status: StreamSessionRuntimeStatus,
@@ -3212,6 +3312,9 @@ export class ControlPlaneStreamServer {
3212
3312
  if (event.type === 'session-key-event') {
3213
3313
  return event.sessionId;
3214
3314
  }
3315
+ if (event.type === 'session-prompt-event') {
3316
+ return event.sessionId;
3317
+ }
3215
3318
  if (event.type === 'session-control') {
3216
3319
  return event.sessionId;
3217
3320
  }
@@ -8,6 +8,10 @@ interface CritiqueReviewAgentAvailability {
8
8
  }
9
9
 
10
10
  type CritiqueReviewCommandInput =
11
+ | {
12
+ readonly mode: 'unstaged';
13
+ readonly agent: CritiqueReviewAgent | null;
14
+ }
11
15
  | {
12
16
  readonly mode: 'staged';
13
17
  readonly agent: CritiqueReviewAgent | null;
@@ -54,7 +58,7 @@ export function buildCritiqueReviewCommand(input: CritiqueReviewCommandInput): s
54
58
  const tokens = ['critique', 'review'];
55
59
  if (input.mode === 'staged') {
56
60
  tokens.push('--staged');
57
- } else {
61
+ } else if (input.mode === 'base-branch') {
58
62
  const normalizedBaseBranch = normalizeBranchName(input.baseBranch) ?? 'main';
59
63
  tokens.push(normalizedBaseBranch, 'HEAD');
60
64
  }
@@ -68,6 +68,8 @@ class PtySession extends EventEmitter {
68
68
  private nextProbeId = 1;
69
69
  private outputWindow = Buffer.alloc(0);
70
70
  private static readonly MAX_OUTPUT_WINDOW_BYTES = 8192;
71
+ private static readonly MAX_PENDING_ROUNDTRIP_PROBES = 64;
72
+ private static readonly ROUNDTRIP_PROBE_MAX_AGE_NS = 5_000_000_000n;
71
73
 
72
74
  constructor(child: ChildProcessWithoutNullStreams) {
73
75
  super();
@@ -101,6 +103,12 @@ class PtySession extends EventEmitter {
101
103
  matchPayloads: PtySession.buildMatchPayloads(payload),
102
104
  startedAtNs: perfNowNs(),
103
105
  });
106
+ if (this.pendingRoundtripProbes.length > PtySession.MAX_PENDING_ROUNDTRIP_PROBES) {
107
+ this.pendingRoundtripProbes.splice(
108
+ 0,
109
+ this.pendingRoundtripProbes.length - PtySession.MAX_PENDING_ROUNDTRIP_PROBES,
110
+ );
111
+ }
104
112
  this.nextProbeId += 1;
105
113
  }
106
114
 
@@ -148,12 +156,22 @@ class PtySession extends EventEmitter {
148
156
  );
149
157
  }
150
158
 
159
+ const maxMatchPayloadLength = this.compactPendingRoundtripProbes(perfNowNs());
160
+ if (this.pendingRoundtripProbes.length === 0) {
161
+ return;
162
+ }
163
+ const searchWindowLength = Math.max(
164
+ 1,
165
+ Math.min(this.outputWindow.length, chunk.length + Math.max(1, maxMatchPayloadLength) - 1),
166
+ );
167
+ const searchWindow = this.outputWindow.subarray(this.outputWindow.length - searchWindowLength);
168
+
151
169
  let idx = 0;
152
170
  while (idx < this.pendingRoundtripProbes.length) {
153
171
  const probe = this.pendingRoundtripProbes[idx];
154
172
  if (
155
173
  probe !== undefined &&
156
- probe.matchPayloads.some((matchPayload) => this.outputWindow.includes(matchPayload))
174
+ probe.matchPayloads.some((matchPayload) => searchWindow.includes(matchPayload))
157
175
  ) {
158
176
  recordPerfDuration('pty.keystroke.roundtrip', probe.startedAtNs, {
159
177
  'probe-id': probe.probeId,
@@ -166,6 +184,33 @@ class PtySession extends EventEmitter {
166
184
  }
167
185
  }
168
186
 
187
+ private compactPendingRoundtripProbes(nowNs: bigint): number {
188
+ if (this.pendingRoundtripProbes.length > PtySession.MAX_PENDING_ROUNDTRIP_PROBES) {
189
+ this.pendingRoundtripProbes.splice(
190
+ 0,
191
+ this.pendingRoundtripProbes.length - PtySession.MAX_PENDING_ROUNDTRIP_PROBES,
192
+ );
193
+ }
194
+ let maxMatchPayloadLength = 1;
195
+ let idx = 0;
196
+ while (idx < this.pendingRoundtripProbes.length) {
197
+ const probe = this.pendingRoundtripProbes[idx];
198
+ if (probe === undefined) {
199
+ idx += 1;
200
+ continue;
201
+ }
202
+ if (nowNs - probe.startedAtNs > PtySession.ROUNDTRIP_PROBE_MAX_AGE_NS) {
203
+ this.pendingRoundtripProbes.splice(idx, 1);
204
+ continue;
205
+ }
206
+ for (const matchPayload of probe.matchPayloads) {
207
+ maxMatchPayloadLength = Math.max(maxMatchPayloadLength, matchPayload.length);
208
+ }
209
+ idx += 1;
210
+ }
211
+ return maxMatchPayloadLength;
212
+ }
213
+
169
214
  private static buildMatchPayloads(payload: Buffer): Buffer[] {
170
215
  if (!payload.includes(0x0a)) {
171
216
  return [payload];