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.
@@ -9,64 +9,121 @@ import type {
9
9
  StreamedResult,
10
10
  } from './types.ts';
11
11
 
12
- const execFileAsync = promisify(execFile);
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
- const PERMISSION_MAP: Record<NormalizedPermission, string> = {
19
- readonly: 'read-only',
20
- edit: 'allow-edit',
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 + '\n', activities: [] };
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
- if (typeStr.includes('tool') || o.type === 'tool_use' || o.type === 'tool_result') {
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') || o.type === 'tool_use') {
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
- activities.push({ kind: 'thinking', chars: 10 });
63
- }
64
-
65
- const text = extractOpencodeText(o);
66
- if (text && !typeStr.includes('tool') && !typeStr.includes('thinking')) streamedText = text;
67
-
68
- if (o.type === 'result' || o.type === 'completed' || o.type === 'done') {
69
- const usage = isRecord(o.usage) ? o.usage : null;
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 : 0,
79
- totalCostUsd: typeof o.total_cost_usd === 'number' ? o.total_cost_usd : 0,
80
- sessionId: typeof o.session_id === 'string' ? o.session_id : typeof o.id === 'string' ? o.id : null,
81
- stopReason: typeof o.stop_reason === 'string' ? o.stop_reason : null,
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: usage
155
+ usage: measured
90
156
  ? {
91
- inputTokens: typeof usage.input_tokens === 'number' ? usage.input_tokens : 0,
92
- outputTokens: typeof usage.output_tokens === 'number' ? usage.output_tokens : 0,
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 args = ['run', '--format', 'json', opts.prompt];
117
- // permission not fully standardized; pass as --permission if supported
118
- const perm = opts.nativePermission ?? PERMISSION_MAP[opts.permission] ?? 'allow-edit';
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: 1,
135
- totalCostUsd: 0,
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
- 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 {