@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 +1 -0
- package/package.json +3 -1
- package/scripts/codex-live-mux-runtime.ts +20 -4
- package/src/control-plane/agent-realtime-api.ts +4 -0
- package/src/control-plane/prompt/agent-prompt-extractor.ts +191 -0
- package/src/control-plane/prompt/extractors/claude-prompt-extractor.ts +53 -0
- package/src/control-plane/prompt/extractors/codex-prompt-extractor.ts +50 -0
- package/src/control-plane/prompt/extractors/cursor-prompt-extractor.ts +56 -0
- package/src/control-plane/prompt/session-prompt-engine.ts +69 -0
- package/src/control-plane/stream-protocol.ts +103 -0
- package/src/control-plane/stream-server-session-runtime.ts +12 -0
- package/src/control-plane/stream-server.ts +103 -0
- package/src/mux/live-mux/critique-review.ts +5 -1
- package/src/pty/pty_host.ts +46 -1
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.
|
|
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
|
|
2626
|
+
if (mode !== 'base-branch') {
|
|
2627
2627
|
const commandText = buildCritiqueReviewCommand({
|
|
2628
|
-
mode
|
|
2628
|
+
mode,
|
|
2629
2629
|
agent,
|
|
2630
2630
|
});
|
|
2631
2631
|
await runCommandInNewTerminalThread(directoryId, commandText);
|
|
2632
|
-
|
|
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
|
}
|
package/src/pty/pty_host.ts
CHANGED
|
@@ -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) =>
|
|
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];
|