pi-harness-delegate 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -12
- package/extensions/activity.ts +212 -16
- package/extensions/command.ts +83 -6
- package/extensions/config.ts +12 -8
- package/extensions/harnesses/amp.ts +160 -42
- package/extensions/harnesses/claude.ts +8 -3
- package/extensions/harnesses/codex.ts +139 -77
- package/extensions/harnesses/opencode.ts +105 -45
- package/extensions/harnesses/types.ts +6 -4
- package/extensions/index.ts +622 -210
- package/extensions/notify.ts +50 -0
- package/extensions/progress.ts +1 -1
- package/extensions/run-claude.ts +1 -1
- package/extensions/run-registry.ts +90 -0
- package/extensions/templates.ts +3 -0
- package/extensions/usage.ts +8 -4
- package/package.json +1 -1
|
@@ -9,64 +9,121 @@ import type {
|
|
|
9
9
|
StreamedResult,
|
|
10
10
|
} from './types.ts';
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// Schema verified against opencode 1.18.16 — see tests/fixtures/opencode.jsonl.
|
|
13
|
+
// `opencode run` in this version has no `--permission` or `--add-dir` flag at all (confirmed via
|
|
14
|
+
// `opencode run --help`) — permission tiers map onto built-in agents instead (`opencode agent
|
|
15
|
+
// list`: "plan" is the read-only-oriented primary agent, "build" is the full-access one).
|
|
13
16
|
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
14
18
|
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
15
19
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
16
20
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
danger: 'danger',
|
|
21
|
+
const AGENT_MAP: Record<NormalizedPermission, string> = {
|
|
22
|
+
readonly: 'plan',
|
|
23
|
+
edit: 'build',
|
|
24
|
+
danger: 'build',
|
|
22
25
|
};
|
|
23
|
-
|
|
24
26
|
function extractOpencodeText(o: Record<string, unknown>): string | undefined {
|
|
25
27
|
if (typeof o.text === 'string') return o.text;
|
|
26
28
|
if (typeof o.output === 'string') return o.output;
|
|
27
29
|
if (typeof o.delta === 'string') return o.delta;
|
|
28
30
|
if (typeof o.content === 'string') return o.content;
|
|
31
|
+
if (isRecord(o.part) && typeof o.part.text === 'string') return o.part.text;
|
|
29
32
|
if (isRecord(o.event) && typeof o.event.text === 'string') return o.event.text;
|
|
30
33
|
if (isRecord(o.message) && typeof o.message.text === 'string') return o.message.text;
|
|
31
34
|
return undefined;
|
|
32
35
|
}
|
|
33
36
|
|
|
37
|
+
interface OpencodeHarnessState {
|
|
38
|
+
sessionId?: string;
|
|
39
|
+
costAccum?: number;
|
|
40
|
+
inputAccum?: number;
|
|
41
|
+
outputAccum?: number;
|
|
42
|
+
cacheReadAccum?: number;
|
|
43
|
+
cacheWriteAccum?: number;
|
|
44
|
+
stepCount?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function harnessState(state: ParseState): OpencodeHarnessState {
|
|
48
|
+
const s = (state._harness ?? {}) as OpencodeHarnessState;
|
|
49
|
+
state._harness = s as unknown as Record<string, unknown>;
|
|
50
|
+
return s;
|
|
51
|
+
}
|
|
52
|
+
|
|
34
53
|
export function parseOpencodeLine(line: string, state: ParseState): ParseOutcome {
|
|
35
54
|
let o: unknown;
|
|
36
55
|
try {
|
|
37
56
|
o = JSON.parse(line);
|
|
38
57
|
} catch {
|
|
39
|
-
if (line.trim().length > 0) return { streamedText: line
|
|
58
|
+
if (line.trim().length > 0) return { streamedText: `${line}\n`, activities: [] };
|
|
40
59
|
return { activities: [] };
|
|
41
60
|
}
|
|
42
61
|
if (!isRecord(o)) return { activities: [] };
|
|
43
62
|
const activities: ParseOutcome['activities'] = [];
|
|
44
63
|
let streamedText: string | undefined;
|
|
45
64
|
const typeStr = typeof o.type === 'string' ? o.type : '';
|
|
46
|
-
|
|
47
|
-
|
|
65
|
+
const hs = harnessState(state);
|
|
66
|
+
// latch sessionID — real events carry it top-level (not just nested under part)
|
|
67
|
+
if (typeof o.sessionID === 'string' && !hs.sessionId) hs.sessionId = o.sessionID;
|
|
68
|
+
if (isRecord(o.part) && typeof (o.part as Record<string, unknown>).sessionID === 'string' && !hs.sessionId) {
|
|
69
|
+
hs.sessionId = (o.part as Record<string, unknown>).sessionID as string;
|
|
70
|
+
}
|
|
71
|
+
// real tool schema: one combined `tool_use` event per call (already resolved by the time it's
|
|
72
|
+
// emitted — no separate start/pending line observed), correlated by part.callID.
|
|
73
|
+
if (typeStr === 'tool_use' && isRecord(o.part)) {
|
|
74
|
+
const part = o.part as Record<string, unknown>;
|
|
75
|
+
const name = typeof part.tool === 'string' ? part.tool : 'tool';
|
|
76
|
+
const id = typeof part.callID === 'string' ? part.callID : undefined;
|
|
77
|
+
const toolState = isRecord(part.state) ? (part.state as Record<string, unknown>) : null;
|
|
78
|
+
const input = toolState && isRecord(toolState.input) ? (toolState.input as Record<string, unknown>) : {};
|
|
79
|
+
const isError = Boolean(toolState?.error) || toolState?.status === 'error';
|
|
80
|
+
activities.push({ kind: 'tool_start', name });
|
|
81
|
+
activities.push({ kind: 'tool_input', name, input, id });
|
|
82
|
+
activities.push({ kind: 'tool_result', isError, id });
|
|
83
|
+
} else if (typeStr.includes('tool') || o.type === 'tool_result') {
|
|
84
|
+
// fallback for older/other event shapes that don't match the combined tool_use schema above
|
|
48
85
|
const name =
|
|
49
86
|
typeof o.name === 'string'
|
|
50
87
|
? o.name
|
|
51
88
|
: typeof (o as Record<string, unknown>).tool === 'string'
|
|
52
89
|
? ((o as Record<string, unknown>).tool as string)
|
|
53
90
|
: 'tool';
|
|
54
|
-
if (typeStr.includes('start')
|
|
91
|
+
if (typeStr.includes('start')) {
|
|
55
92
|
activities.push({ kind: 'tool_start', name });
|
|
56
93
|
if (isRecord(o.input)) activities.push({ kind: 'tool_input', name, input: o.input as Record<string, unknown> });
|
|
57
|
-
} else if (typeStr.includes('result') || typeStr.includes('completed') || o.type === 'tool_result')
|
|
94
|
+
} else if (typeStr.includes('result') || typeStr.includes('completed') || o.type === 'tool_result')
|
|
58
95
|
activities.push({ kind: 'tool_result', isError: o.is_error === true || o.error === true });
|
|
59
|
-
}
|
|
60
96
|
}
|
|
61
|
-
if (typeStr.includes('thinking') || o.type === 'reasoning') {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
97
|
+
if (typeStr.includes('thinking') || o.type === 'reasoning') activities.push({ kind: 'thinking', chars: 10 });
|
|
98
|
+
const directText = extractOpencodeText(o);
|
|
99
|
+
if (directText && typeStr !== 'tool_use' && !typeStr.includes('tool') && !typeStr.includes('thinking'))
|
|
100
|
+
streamedText = directText;
|
|
101
|
+
if (o.type === 'text' && isRecord(o.part) && typeof o.part.text === 'string') streamedText = o.part.text;
|
|
102
|
+
if (o.type === 'result' || o.type === 'completed' || o.type === 'done' || o.type === 'step_finish') {
|
|
103
|
+
const part = isRecord(o.part) ? (o.part as Record<string, unknown>) : null;
|
|
104
|
+
// step_finish reports the cost/tokens of that one step, not a running total — accumulate
|
|
105
|
+
// across every step_finish seen so far so the final result reports a genuine session total.
|
|
106
|
+
if (typeStr === 'step_finish' && part) {
|
|
107
|
+
const tokens = isRecord(part.tokens) ? (part.tokens as Record<string, unknown>) : null;
|
|
108
|
+
const cache = tokens && isRecord(tokens.cache) ? (tokens.cache as Record<string, unknown>) : null;
|
|
109
|
+
hs.costAccum = (hs.costAccum ?? 0) + (typeof part.cost === 'number' ? part.cost : 0);
|
|
110
|
+
hs.inputAccum = (hs.inputAccum ?? 0) + (tokens && typeof tokens.input === 'number' ? tokens.input : 0);
|
|
111
|
+
hs.outputAccum = (hs.outputAccum ?? 0) + (tokens && typeof tokens.output === 'number' ? tokens.output : 0);
|
|
112
|
+
hs.cacheReadAccum = (hs.cacheReadAccum ?? 0) + (cache && typeof cache.read === 'number' ? cache.read : 0);
|
|
113
|
+
hs.cacheWriteAccum = (hs.cacheWriteAccum ?? 0) + (cache && typeof cache.write === 'number' ? cache.write : 0);
|
|
114
|
+
hs.stepCount = (hs.stepCount ?? 0) + 1;
|
|
115
|
+
}
|
|
116
|
+
const measured = (hs.stepCount ?? 0) > 0;
|
|
117
|
+
const sessionId =
|
|
118
|
+
typeof o.session_id === 'string'
|
|
119
|
+
? o.session_id
|
|
120
|
+
: typeof o.sessionID === 'string'
|
|
121
|
+
? o.sessionID
|
|
122
|
+
: part && typeof (part as Record<string, unknown>).sessionID === 'string'
|
|
123
|
+
? ((part as Record<string, unknown>).sessionID as string)
|
|
124
|
+
: typeof o.id === 'string'
|
|
125
|
+
? o.id
|
|
126
|
+
: (hs.sessionId ?? null);
|
|
70
127
|
const result: StreamedResult = {
|
|
71
128
|
result:
|
|
72
129
|
typeof o.result === 'string'
|
|
@@ -75,10 +132,19 @@ export function parseOpencodeLine(line: string, state: ParseState): ParseOutcome
|
|
|
75
132
|
? o.output
|
|
76
133
|
: state.streamedText + (streamedText ?? ''),
|
|
77
134
|
isError: o.is_error === true,
|
|
78
|
-
numTurns: typeof o.num_turns === 'number' ? o.num_turns :
|
|
79
|
-
totalCostUsd:
|
|
80
|
-
|
|
81
|
-
|
|
135
|
+
numTurns: typeof o.num_turns === 'number' ? o.num_turns : measured ? (hs.stepCount as number) : null,
|
|
136
|
+
totalCostUsd: measured
|
|
137
|
+
? (hs.costAccum as number)
|
|
138
|
+
: typeof o.total_cost_usd === 'number'
|
|
139
|
+
? o.total_cost_usd
|
|
140
|
+
: null,
|
|
141
|
+
sessionId,
|
|
142
|
+
stopReason:
|
|
143
|
+
typeof o.stop_reason === 'string'
|
|
144
|
+
? o.stop_reason
|
|
145
|
+
: part && typeof (part as Record<string, unknown>).reason === 'string'
|
|
146
|
+
? ((part as Record<string, unknown>).reason as string)
|
|
147
|
+
: null,
|
|
82
148
|
permissionDenials: [],
|
|
83
149
|
durationMs: typeof o.duration_ms === 'number' ? o.duration_ms : null,
|
|
84
150
|
durationApiMs: null,
|
|
@@ -86,12 +152,12 @@ export function parseOpencodeLine(line: string, state: ParseState): ParseOutcome
|
|
|
86
152
|
model: typeof o.model === 'string' ? o.model : null,
|
|
87
153
|
contextWindow: null,
|
|
88
154
|
maxOutputTokens: null,
|
|
89
|
-
usage:
|
|
155
|
+
usage: measured
|
|
90
156
|
? {
|
|
91
|
-
inputTokens:
|
|
92
|
-
outputTokens:
|
|
93
|
-
cacheCreationInputTokens: 0,
|
|
94
|
-
cacheReadInputTokens: 0,
|
|
157
|
+
inputTokens: hs.inputAccum ?? 0,
|
|
158
|
+
outputTokens: hs.outputAccum ?? 0,
|
|
159
|
+
cacheCreationInputTokens: hs.cacheWriteAccum ?? 0,
|
|
160
|
+
cacheReadInputTokens: hs.cacheReadAccum ?? 0,
|
|
95
161
|
}
|
|
96
162
|
: null,
|
|
97
163
|
};
|
|
@@ -99,7 +165,6 @@ export function parseOpencodeLine(line: string, state: ParseState): ParseOutcome
|
|
|
99
165
|
}
|
|
100
166
|
return { activities, streamedText };
|
|
101
167
|
}
|
|
102
|
-
|
|
103
168
|
export const opencodeHarness: Harness = {
|
|
104
169
|
name: 'opencode',
|
|
105
170
|
displayName: 'OpenCode',
|
|
@@ -113,13 +178,11 @@ export const opencodeHarness: Harness = {
|
|
|
113
178
|
}
|
|
114
179
|
},
|
|
115
180
|
buildArgs(opts: BuildArgsOpts): string[] {
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
args.push('--permission', perm);
|
|
181
|
+
const agent = opts.nativePermission ?? AGENT_MAP[opts.permission] ?? 'build';
|
|
182
|
+
const args = ['run', opts.prompt, '--format', 'json', '--agent', agent];
|
|
183
|
+
if (opts.permission === 'danger' && !opts.nativePermission) args.push('--auto');
|
|
120
184
|
if (opts.model) args.push('--model', opts.model);
|
|
121
185
|
if (opts.resumeSessionId) args.push('--session', opts.resumeSessionId);
|
|
122
|
-
for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
|
|
123
186
|
return args;
|
|
124
187
|
},
|
|
125
188
|
parseLine(line: string, state: ParseState): ParseOutcome {
|
|
@@ -128,12 +191,13 @@ export const opencodeHarness: Harness = {
|
|
|
128
191
|
extractResult(state: ParseState): StreamedResult | null {
|
|
129
192
|
if (state.result) return state.result;
|
|
130
193
|
if (state.streamedText.trim().length > 0) {
|
|
194
|
+
const latched = (state._harness as OpencodeHarnessState | undefined)?.sessionId;
|
|
131
195
|
return {
|
|
132
196
|
result: state.streamedText,
|
|
133
197
|
isError: false,
|
|
134
|
-
numTurns:
|
|
135
|
-
totalCostUsd:
|
|
136
|
-
sessionId: null,
|
|
198
|
+
numTurns: null,
|
|
199
|
+
totalCostUsd: null,
|
|
200
|
+
sessionId: latched ?? null,
|
|
137
201
|
stopReason: null,
|
|
138
202
|
permissionDenials: [],
|
|
139
203
|
durationMs: null,
|
|
@@ -147,9 +211,5 @@ export const opencodeHarness: Harness = {
|
|
|
147
211
|
}
|
|
148
212
|
return null;
|
|
149
213
|
},
|
|
150
|
-
permissionMap: {
|
|
151
|
-
readonly: ['read-only'],
|
|
152
|
-
edit: ['allow-edit'],
|
|
153
|
-
danger: ['danger'],
|
|
154
|
-
},
|
|
214
|
+
permissionMap: { readonly: ['plan'], edit: ['build'], danger: ['build', '--auto'] },
|
|
155
215
|
};
|
|
@@ -12,8 +12,10 @@ export interface StreamedUsage {
|
|
|
12
12
|
export interface StreamedResult {
|
|
13
13
|
result: string;
|
|
14
14
|
isError: boolean;
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
/** null when the harness's payload doesn't report a turn count — distinct from a measured 0. */
|
|
16
|
+
numTurns: number | null;
|
|
17
|
+
/** null when the harness's payload doesn't report cost — distinct from a measured $0. */
|
|
18
|
+
totalCostUsd: number | null;
|
|
17
19
|
sessionId: string | null;
|
|
18
20
|
stopReason: string | null;
|
|
19
21
|
permissionDenials: unknown[];
|
|
@@ -28,8 +30,8 @@ export interface StreamedResult {
|
|
|
28
30
|
|
|
29
31
|
export type ActivityEvent =
|
|
30
32
|
| { kind: 'tool_start'; name: string }
|
|
31
|
-
| { kind: 'tool_input'; name: string; input: Record<string, unknown
|
|
32
|
-
| { kind: 'tool_result'; isError: boolean }
|
|
33
|
+
| { kind: 'tool_input'; name: string; input: Record<string, unknown>; id?: string }
|
|
34
|
+
| { kind: 'tool_result'; isError: boolean; id?: string }
|
|
33
35
|
| { kind: 'thinking'; chars: number };
|
|
34
36
|
|
|
35
37
|
export interface ParseState {
|