pi-harness-delegate 0.2.2 → 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.
@@ -9,14 +9,19 @@ import type {
9
9
  StreamedResult,
10
10
  } from './types.ts';
11
11
 
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).
16
+
12
17
  const execFileAsync = promisify(execFile);
13
18
  function isRecord(v: unknown): v is Record<string, unknown> {
14
19
  return typeof v === 'object' && v !== null && !Array.isArray(v);
15
20
  }
16
- const PERMISSION_MAP: Record<NormalizedPermission, string> = {
17
- readonly: 'read-only',
18
- edit: 'allow-edit',
19
- danger: 'danger',
21
+ const AGENT_MAP: Record<NormalizedPermission, string> = {
22
+ readonly: 'plan',
23
+ edit: 'build',
24
+ danger: 'build',
20
25
  };
21
26
  function extractOpencodeText(o: Record<string, unknown>): string | undefined {
22
27
  if (typeof o.text === 'string') return o.text;
@@ -28,6 +33,23 @@ function extractOpencodeText(o: Record<string, unknown>): string | undefined {
28
33
  if (isRecord(o.message) && typeof o.message.text === 'string') return o.message.text;
29
34
  return undefined;
30
35
  }
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
+
31
53
  export function parseOpencodeLine(line: string, state: ParseState): ParseOutcome {
32
54
  let o: unknown;
33
55
  try {
@@ -40,31 +62,33 @@ export function parseOpencodeLine(line: string, state: ParseState): ParseOutcome
40
62
  const activities: ParseOutcome['activities'] = [];
41
63
  let streamedText: string | undefined;
42
64
  const typeStr = typeof o.type === 'string' ? o.type : '';
43
- // latch sessionID from step_start
44
- if (typeStr === 'step_start' && typeof o.sessionID === 'string') {
45
- (state as unknown as Record<string, unknown>)._harness = {
46
- ...(((state as unknown as Record<string, unknown>)._harness as Record<string, unknown>) ?? {}),
47
- sessionId: o.sessionID,
48
- };
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;
49
70
  }
50
- if (
51
- isRecord(o.part) &&
52
- typeof (o.part as Record<string, unknown>).sessionID === 'string' &&
53
- !((state as unknown as Record<string, unknown>)._harness as Record<string, unknown> | undefined)?.sessionId
54
- ) {
55
- (state as unknown as Record<string, unknown>)._harness = {
56
- ...(((state as unknown as Record<string, unknown>)._harness as Record<string, unknown>) ?? {}),
57
- sessionId: (o.part as Record<string, unknown>).sessionID as string,
58
- };
59
- }
60
- if (typeStr.includes('tool') || o.type === 'tool_use' || o.type === 'tool_result') {
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
61
85
  const name =
62
86
  typeof o.name === 'string'
63
87
  ? o.name
64
88
  : typeof (o as Record<string, unknown>).tool === 'string'
65
89
  ? ((o as Record<string, unknown>).tool as string)
66
90
  : 'tool';
67
- if (typeStr.includes('start') || o.type === 'tool_use') {
91
+ if (typeStr.includes('start')) {
68
92
  activities.push({ kind: 'tool_start', name });
69
93
  if (isRecord(o.input)) activities.push({ kind: 'tool_input', name, input: o.input as Record<string, unknown> });
70
94
  } else if (typeStr.includes('result') || typeStr.includes('completed') || o.type === 'tool_result')
@@ -72,54 +96,34 @@ export function parseOpencodeLine(line: string, state: ParseState): ParseOutcome
72
96
  }
73
97
  if (typeStr.includes('thinking') || o.type === 'reasoning') activities.push({ kind: 'thinking', chars: 10 });
74
98
  const directText = extractOpencodeText(o);
75
- if (directText && !typeStr.includes('tool') && !typeStr.includes('thinking')) streamedText = directText;
99
+ if (directText && typeStr !== 'tool_use' && !typeStr.includes('tool') && !typeStr.includes('thinking'))
100
+ streamedText = directText;
76
101
  if (o.type === 'text' && isRecord(o.part) && typeof o.part.text === 'string') streamedText = o.part.text;
77
102
  if (o.type === 'result' || o.type === 'completed' || o.type === 'done' || o.type === 'step_finish') {
78
103
  const part = isRecord(o.part) ? (o.part as Record<string, unknown>) : null;
79
- const usageRaw = isRecord(o.usage)
80
- ? o.usage
81
- : part && isRecord((part as Record<string, unknown>).tokens)
82
- ? ((part as Record<string, unknown>).tokens as Record<string, unknown>)
83
- : isRecord(o.part) && isRecord((o.part as Record<string, unknown>).tokens)
84
- ? ((o.part as Record<string, unknown>).tokens as Record<string, unknown>)
85
- : null;
86
- const usage = isRecord(usageRaw) ? usageRaw : null;
87
- const tokensInput = isRecord(usage)
88
- ? typeof usage.input === 'number'
89
- ? usage.input
90
- : typeof usage.input_tokens === 'number'
91
- ? usage.input_tokens
92
- : 0
93
- : 0;
94
- const tokensOutput = isRecord(usage)
95
- ? typeof usage.output === 'number'
96
- ? usage.output
97
- : typeof usage.output_tokens === 'number'
98
- ? usage.output_tokens
99
- : 0
100
- : 0;
101
- const cost =
102
- typeof o.total_cost_usd === 'number'
103
- ? o.total_cost_usd
104
- : part && typeof (part as Record<string, unknown>).cost === 'number'
105
- ? ((part as Record<string, unknown>).cost as number)
106
- : typeof o.cost === 'number'
107
- ? (o.cost as number)
108
- : 0;
109
- const latched = ((state as unknown as Record<string, unknown>)._harness as Record<string, unknown> | undefined)
110
- ?.sessionId as string | undefined;
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;
111
117
  const sessionId =
112
118
  typeof o.session_id === 'string'
113
119
  ? o.session_id
114
120
  : typeof o.sessionID === 'string'
115
121
  ? o.sessionID
116
- : typeof (o as Record<string, unknown>).sessionID === 'string'
117
- ? ((o as Record<string, unknown>).sessionID as string)
118
- : part && typeof (part as Record<string, unknown>).sessionID === 'string'
119
- ? ((part as Record<string, unknown>).sessionID as string)
120
- : typeof o.id === 'string'
121
- ? o.id
122
- : (latched ?? null);
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);
123
127
  const result: StreamedResult = {
124
128
  result:
125
129
  typeof o.result === 'string'
@@ -128,8 +132,12 @@ export function parseOpencodeLine(line: string, state: ParseState): ParseOutcome
128
132
  ? o.output
129
133
  : state.streamedText + (streamedText ?? ''),
130
134
  isError: o.is_error === true,
131
- numTurns: typeof o.num_turns === 'number' ? o.num_turns : 0,
132
- totalCostUsd: cost,
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,
133
141
  sessionId,
134
142
  stopReason:
135
143
  typeof o.stop_reason === 'string'
@@ -144,8 +152,13 @@ export function parseOpencodeLine(line: string, state: ParseState): ParseOutcome
144
152
  model: typeof o.model === 'string' ? o.model : null,
145
153
  contextWindow: null,
146
154
  maxOutputTokens: null,
147
- usage: usage
148
- ? { inputTokens: tokensInput, outputTokens: tokensOutput, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }
155
+ usage: measured
156
+ ? {
157
+ inputTokens: hs.inputAccum ?? 0,
158
+ outputTokens: hs.outputAccum ?? 0,
159
+ cacheCreationInputTokens: hs.cacheWriteAccum ?? 0,
160
+ cacheReadInputTokens: hs.cacheReadAccum ?? 0,
161
+ }
149
162
  : null,
150
163
  };
151
164
  return { activities, streamedText, result };
@@ -165,11 +178,11 @@ export const opencodeHarness: Harness = {
165
178
  }
166
179
  },
167
180
  buildArgs(opts: BuildArgsOpts): string[] {
168
- const perm = opts.nativePermission ?? PERMISSION_MAP[opts.permission] ?? 'allow-edit';
169
- const args = ['run', '--format', 'json', opts.prompt, '--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');
170
184
  if (opts.model) args.push('--model', opts.model);
171
185
  if (opts.resumeSessionId) args.push('--session', opts.resumeSessionId);
172
- for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
173
186
  return args;
174
187
  },
175
188
  parseLine(line: string, state: ParseState): ParseOutcome {
@@ -178,13 +191,12 @@ export const opencodeHarness: Harness = {
178
191
  extractResult(state: ParseState): StreamedResult | null {
179
192
  if (state.result) return state.result;
180
193
  if (state.streamedText.trim().length > 0) {
181
- const latched = ((state as unknown as Record<string, unknown>)._harness as Record<string, unknown> | undefined)
182
- ?.sessionId as string | undefined;
194
+ const latched = (state._harness as OpencodeHarnessState | undefined)?.sessionId;
183
195
  return {
184
196
  result: state.streamedText,
185
197
  isError: false,
186
- numTurns: 1,
187
- totalCostUsd: 0,
198
+ numTurns: null,
199
+ totalCostUsd: null,
188
200
  sessionId: latched ?? null,
189
201
  stopReason: null,
190
202
  permissionDenials: [],
@@ -199,5 +211,5 @@ export const opencodeHarness: Harness = {
199
211
  }
200
212
  return null;
201
213
  },
202
- permissionMap: { readonly: ['read-only'], edit: ['allow-edit'], danger: ['danger'] },
214
+ permissionMap: { readonly: ['plan'], edit: ['build'], danger: ['build', '--auto'] },
203
215
  };
@@ -12,8 +12,10 @@ export interface StreamedUsage {
12
12
  export interface StreamedResult {
13
13
  result: string;
14
14
  isError: boolean;
15
- numTurns: number;
16
- totalCostUsd: number;
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 {