pi-harness-delegate 0.1.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/LICENSE +21 -0
- package/README.md +179 -0
- package/extensions/activity.ts +254 -0
- package/extensions/command.ts +91 -0
- package/extensions/config.ts +158 -0
- package/extensions/harnesses/amp.ts +137 -0
- package/extensions/harnesses/claude.ts +129 -0
- package/extensions/harnesses/codex.ts +172 -0
- package/extensions/harnesses/opencode.ts +138 -0
- package/extensions/harnesses/registry.ts +51 -0
- package/extensions/harnesses/types.ts +93 -0
- package/extensions/hint.ts +57 -0
- package/extensions/index.ts +816 -0
- package/extensions/progress.ts +142 -0
- package/extensions/run-claude.ts +52 -0
- package/extensions/runner.ts +120 -0
- package/extensions/stream-parse.ts +29 -0
- package/extensions/templates.ts +166 -0
- package/extensions/usage.ts +40 -0
- package/package.json +58 -0
- package/templates/amp/docs.md +11 -0
- package/templates/amp/general.md +9 -0
- package/templates/amp/implement.md +13 -0
- package/templates/amp/plan.md +18 -0
- package/templates/amp/review.md +19 -0
- package/templates/amp/security-audit.md +18 -0
- package/templates/claude/docs.md +11 -0
- package/templates/claude/general.md +8 -0
- package/templates/claude/implement.md +13 -0
- package/templates/claude/plan.md +18 -0
- package/templates/claude/review.md +19 -0
- package/templates/claude/security-audit.md +18 -0
- package/templates/codex/docs.md +11 -0
- package/templates/codex/general.md +9 -0
- package/templates/codex/implement.md +13 -0
- package/templates/codex/plan.md +18 -0
- package/templates/codex/review.md +19 -0
- package/templates/codex/security-audit.md +18 -0
- package/templates/docs.md +11 -0
- package/templates/general.md +8 -0
- package/templates/implement.md +13 -0
- package/templates/opencode/docs.md +11 -0
- package/templates/opencode/general.md +9 -0
- package/templates/opencode/implement.md +13 -0
- package/templates/opencode/plan.md +18 -0
- package/templates/opencode/review.md +19 -0
- package/templates/opencode/security-audit.md +18 -0
- package/templates/plan.md +18 -0
- package/templates/review.md +19 -0
- package/templates/security-audit.md +18 -0
- package/templates/shared/docs.md +11 -0
- package/templates/shared/general.md +8 -0
- package/templates/shared/implement.md +13 -0
- package/templates/shared/plan.md +18 -0
- package/templates/shared/review.md +19 -0
- package/templates/shared/security-audit.md +18 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { DEFAULT_TIMEOUT_MS } from './harnesses/types.ts';
|
|
5
|
+
|
|
6
|
+
export interface HarnessConfig {
|
|
7
|
+
model?: string;
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
allowDangerous?: boolean;
|
|
10
|
+
maxBudgetUsd?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface DelegateConfig {
|
|
14
|
+
model?: string;
|
|
15
|
+
timeoutMs: number;
|
|
16
|
+
defaultMode: string;
|
|
17
|
+
defaultHarness: string;
|
|
18
|
+
allowDangerous: boolean;
|
|
19
|
+
inspectThinking: boolean;
|
|
20
|
+
maxBudgetUsd?: number;
|
|
21
|
+
autoDelegateHints: boolean;
|
|
22
|
+
modelAliases: Record<string, string>;
|
|
23
|
+
maxConcurrent: number | { global?: number; perHarness?: Record<string, number> };
|
|
24
|
+
maxTranscripts: number;
|
|
25
|
+
harnesses: Record<string, HarnessConfig>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function agentDir(): string {
|
|
29
|
+
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function outputsDir(harness?: string): string {
|
|
33
|
+
const base = join(agentDir(), 'delegate', 'outputs');
|
|
34
|
+
return harness ? join(base, harness) : base;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function legacyOutputsDir(): string {
|
|
38
|
+
return join(agentDir(), 'claude-delegate', 'outputs');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function loadConfig(): DelegateConfig {
|
|
42
|
+
const cfg: DelegateConfig = {
|
|
43
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
44
|
+
defaultMode: 'general',
|
|
45
|
+
defaultHarness: 'claude',
|
|
46
|
+
allowDangerous: false,
|
|
47
|
+
inspectThinking: false,
|
|
48
|
+
autoDelegateHints: false,
|
|
49
|
+
modelAliases: { economy: 'haiku', balanced: 'sonnet', max: 'opus' },
|
|
50
|
+
maxConcurrent: 1,
|
|
51
|
+
maxTranscripts: 100,
|
|
52
|
+
harnesses: {},
|
|
53
|
+
};
|
|
54
|
+
try {
|
|
55
|
+
const file = join(agentDir(), 'settings.json');
|
|
56
|
+
if (!existsSync(file)) return cfg;
|
|
57
|
+
const settings = JSON.parse(readFileSync(file, 'utf8')) as {
|
|
58
|
+
delegate?: Partial<DelegateConfig & { harnesses: Record<string, HarnessConfig> }>;
|
|
59
|
+
claudeDelegate?: Partial<DelegateConfig & { model?: string }>;
|
|
60
|
+
};
|
|
61
|
+
// Legacy claudeDelegate -> delegate.harnesses.claude migration
|
|
62
|
+
if (settings.claudeDelegate && !settings.delegate) {
|
|
63
|
+
const c = settings.claudeDelegate as Partial<DelegateConfig>;
|
|
64
|
+
if (typeof c.model === 'string') cfg.harnesses['claude'] = { ...(cfg.harnesses['claude'] ?? {}), model: c.model };
|
|
65
|
+
if (typeof c.timeoutMs === 'number' && c.timeoutMs > 0) cfg.timeoutMs = c.timeoutMs;
|
|
66
|
+
if (typeof c.defaultMode === 'string') cfg.defaultMode = c.defaultMode;
|
|
67
|
+
if (typeof c.allowDangerous === 'boolean') cfg.allowDangerous = c.allowDangerous;
|
|
68
|
+
if (typeof c.inspectThinking === 'boolean') cfg.inspectThinking = c.inspectThinking;
|
|
69
|
+
if (typeof (c as DelegateConfig).maxBudgetUsd === 'number' && (c as DelegateConfig).maxBudgetUsd! > 0) cfg.maxBudgetUsd = (c as DelegateConfig).maxBudgetUsd;
|
|
70
|
+
if (typeof c.autoDelegateHints === 'boolean') cfg.autoDelegateHints = c.autoDelegateHints;
|
|
71
|
+
if ((c as DelegateConfig).modelAliases && typeof (c as DelegateConfig).modelAliases === 'object') {
|
|
72
|
+
for (const [k, v] of Object.entries((c as DelegateConfig).modelAliases!)) {
|
|
73
|
+
if (typeof v === 'string' && v) cfg.modelAliases[k] = v;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (typeof c.maxConcurrent === 'number' && c.maxConcurrent >= 0) cfg.maxConcurrent = c.maxConcurrent;
|
|
77
|
+
else if (c.maxConcurrent && typeof c.maxConcurrent === 'object') {
|
|
78
|
+
const obj = c.maxConcurrent as { global?: unknown; perHarness?: unknown };
|
|
79
|
+
const out: { global?: number; perHarness?: Record<string, number> } = {};
|
|
80
|
+
if (typeof obj.global === 'number' && obj.global >= 0) out.global = obj.global;
|
|
81
|
+
if (obj.perHarness && typeof obj.perHarness === 'object') {
|
|
82
|
+
const ph: Record<string, number> = {};
|
|
83
|
+
for (const [k, v] of Object.entries(obj.perHarness as Record<string, unknown>)) {
|
|
84
|
+
if (typeof v === 'number' && v >= 0) ph[k] = v;
|
|
85
|
+
}
|
|
86
|
+
if (Object.keys(ph).length > 0) out.perHarness = ph;
|
|
87
|
+
}
|
|
88
|
+
if (out.global !== undefined || out.perHarness) cfg.maxConcurrent = out;
|
|
89
|
+
}
|
|
90
|
+
if (typeof c.maxTranscripts === 'number' && c.maxTranscripts >= 0) cfg.maxTranscripts = c.maxTranscripts;
|
|
91
|
+
if (c.harnesses && typeof c.harnesses === 'object') {
|
|
92
|
+
for (const [k, v] of Object.entries(c.harnesses)) {
|
|
93
|
+
if (v && typeof v === 'object') cfg.harnesses[k] = { ...(cfg.harnesses[k] ?? {}), ...(v as HarnessConfig) };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// also map harnesses.claude if any
|
|
97
|
+
return cfg;
|
|
98
|
+
}
|
|
99
|
+
const d = settings.delegate ?? {};
|
|
100
|
+
if (typeof d.defaultHarness === 'string' && d.defaultHarness) cfg.defaultHarness = d.defaultHarness;
|
|
101
|
+
if (typeof d.defaultMode === 'string') cfg.defaultMode = d.defaultMode;
|
|
102
|
+
if (typeof d.model === 'string') cfg.model = d.model;
|
|
103
|
+
if (typeof d.timeoutMs === 'number' && d.timeoutMs > 0) cfg.timeoutMs = d.timeoutMs;
|
|
104
|
+
if (typeof d.allowDangerous === 'boolean') cfg.allowDangerous = d.allowDangerous;
|
|
105
|
+
if (typeof d.inspectThinking === 'boolean') cfg.inspectThinking = d.inspectThinking;
|
|
106
|
+
if (typeof d.maxBudgetUsd === 'number' && d.maxBudgetUsd > 0) cfg.maxBudgetUsd = d.maxBudgetUsd;
|
|
107
|
+
if (typeof d.autoDelegateHints === 'boolean') cfg.autoDelegateHints = d.autoDelegateHints;
|
|
108
|
+
if (d.modelAliases && typeof d.modelAliases === 'object') {
|
|
109
|
+
for (const [k, v] of Object.entries(d.modelAliases)) {
|
|
110
|
+
if (typeof v === 'string' && v) cfg.modelAliases[k] = v;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (typeof d.maxConcurrent === 'number' && d.maxConcurrent >= 0) cfg.maxConcurrent = d.maxConcurrent;
|
|
114
|
+
else if (d.maxConcurrent && typeof d.maxConcurrent === 'object') {
|
|
115
|
+
const obj = d.maxConcurrent as { global?: unknown; perHarness?: unknown };
|
|
116
|
+
const out: { global?: number; perHarness?: Record<string, number> } = {};
|
|
117
|
+
if (typeof obj.global === 'number' && obj.global >= 0) out.global = obj.global;
|
|
118
|
+
if (obj.perHarness && typeof obj.perHarness === 'object') {
|
|
119
|
+
const ph: Record<string, number> = {};
|
|
120
|
+
for (const [k, v] of Object.entries(obj.perHarness as Record<string, unknown>)) {
|
|
121
|
+
if (typeof v === 'number' && v >= 0) ph[k] = v;
|
|
122
|
+
}
|
|
123
|
+
if (Object.keys(ph).length > 0) out.perHarness = ph;
|
|
124
|
+
}
|
|
125
|
+
if (out.global !== undefined || out.perHarness) cfg.maxConcurrent = out;
|
|
126
|
+
}
|
|
127
|
+
if (typeof d.maxTranscripts === 'number' && d.maxTranscripts >= 0) cfg.maxTranscripts = d.maxTranscripts;
|
|
128
|
+
if (d.harnesses && typeof d.harnesses === 'object') {
|
|
129
|
+
for (const [k, v] of Object.entries(d.harnesses)) {
|
|
130
|
+
if (v && typeof v === 'object') cfg.harnesses[k] = { ...(v as HarnessConfig) };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// also support legacy claudeDelegate merged when delegate also present (delegate wins)
|
|
134
|
+
if (settings.claudeDelegate) {
|
|
135
|
+
const c = settings.claudeDelegate as Partial<DelegateConfig>;
|
|
136
|
+
if (typeof c.model === 'string' && !cfg.harnesses['claude']?.model) {
|
|
137
|
+
cfg.harnesses['claude'] = { ...(cfg.harnesses['claude'] ?? {}), model: c.model };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
// invalid settings — fall back to defaults
|
|
142
|
+
}
|
|
143
|
+
return cfg;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function resolveModelForHarness(cfg: DelegateConfig, harness: string, model?: string, templateModel?: string): string | undefined {
|
|
147
|
+
const resolve = (m?: string) => (m ? (cfg.modelAliases[m] ?? m) : undefined);
|
|
148
|
+
return resolve(model) ?? resolve(templateModel) ?? resolve(cfg.harnesses[harness]?.model) ?? resolve(cfg.model);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function getMaxConcurrent(cfg: DelegateConfig, harness?: string): number {
|
|
152
|
+
if (typeof cfg.maxConcurrent === 'number') return cfg.maxConcurrent;
|
|
153
|
+
// if object shape {global, perHarness}
|
|
154
|
+
const mc = cfg.maxConcurrent as unknown as { global?: number; perHarness?: Record<string, number> };
|
|
155
|
+
if (harness && mc.perHarness && typeof mc.perHarness[harness] === 'number') return mc.perHarness[harness]!;
|
|
156
|
+
if (typeof mc.global === 'number') return mc.global;
|
|
157
|
+
return 1;
|
|
158
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import type { Harness, BuildArgsOpts, NormalizedPermission, ParseOutcome, ParseState, StreamedResult } from './types.ts';
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
8
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const PERMISSION_MAP: Record<NormalizedPermission, string> = {
|
|
12
|
+
readonly: 'read-only',
|
|
13
|
+
edit: 'workspace',
|
|
14
|
+
danger: 'danger',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function extractAmpText(o: Record<string, unknown>): string | undefined {
|
|
18
|
+
if (typeof o.text === 'string') return o.text;
|
|
19
|
+
if (typeof o.output === 'string') return o.output;
|
|
20
|
+
if (typeof o.delta === 'string') return o.delta;
|
|
21
|
+
if (typeof o.content === 'string') return o.content;
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parseAmpLine(line: string, state: ParseState): ParseOutcome {
|
|
26
|
+
let o: unknown;
|
|
27
|
+
try {
|
|
28
|
+
o = JSON.parse(line);
|
|
29
|
+
} catch {
|
|
30
|
+
if (line.trim().length > 0) return { streamedText: line + '\n', activities: [] };
|
|
31
|
+
return { activities: [] };
|
|
32
|
+
}
|
|
33
|
+
if (!isRecord(o)) return { activities: [] };
|
|
34
|
+
const activities: ParseOutcome['activities'] = [];
|
|
35
|
+
let streamedText: string | undefined;
|
|
36
|
+
const typeStr = typeof o.type === 'string' ? o.type : '';
|
|
37
|
+
|
|
38
|
+
if (typeStr.includes('tool') || o.type === 'tool_use') {
|
|
39
|
+
const name = typeof o.name === 'string' ? o.name : 'tool';
|
|
40
|
+
if (typeStr.includes('start') || o.type === 'tool_use') {
|
|
41
|
+
activities.push({ kind: 'tool_start', name });
|
|
42
|
+
if (isRecord(o.input)) activities.push({ kind: 'tool_input', name, input: o.input as Record<string, unknown> });
|
|
43
|
+
} else {
|
|
44
|
+
activities.push({ kind: 'tool_result', isError: o.is_error === true });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (typeStr.includes('thinking')) activities.push({ kind: 'thinking', chars: 10 });
|
|
48
|
+
|
|
49
|
+
const text = extractAmpText(o);
|
|
50
|
+
if (text && !typeStr.includes('tool')) streamedText = text;
|
|
51
|
+
|
|
52
|
+
if (o.type === 'result' || o.type === 'done' || o.type === 'completed') {
|
|
53
|
+
const usage = isRecord(o.usage) ? o.usage : null;
|
|
54
|
+
const result: StreamedResult = {
|
|
55
|
+
result: typeof o.result === 'string' ? o.result : state.streamedText + (streamedText ?? ''),
|
|
56
|
+
isError: o.is_error === true,
|
|
57
|
+
numTurns: typeof o.num_turns === 'number' ? o.num_turns : 0,
|
|
58
|
+
totalCostUsd: typeof o.total_cost_usd === 'number' ? o.total_cost_usd : 0,
|
|
59
|
+
sessionId: typeof o.session_id === 'string' ? o.session_id : null,
|
|
60
|
+
stopReason: typeof o.stop_reason === 'string' ? o.stop_reason : null,
|
|
61
|
+
permissionDenials: [],
|
|
62
|
+
durationMs: typeof o.duration_ms === 'number' ? o.duration_ms : null,
|
|
63
|
+
durationApiMs: null,
|
|
64
|
+
ttftMs: null,
|
|
65
|
+
model: typeof o.model === 'string' ? o.model : null,
|
|
66
|
+
contextWindow: null,
|
|
67
|
+
maxOutputTokens: null,
|
|
68
|
+
usage: usage
|
|
69
|
+
? {
|
|
70
|
+
inputTokens: typeof usage.input_tokens === 'number' ? usage.input_tokens : 0,
|
|
71
|
+
outputTokens: typeof usage.output_tokens === 'number' ? usage.output_tokens : 0,
|
|
72
|
+
cacheCreationInputTokens: 0,
|
|
73
|
+
cacheReadInputTokens: 0,
|
|
74
|
+
}
|
|
75
|
+
: null,
|
|
76
|
+
};
|
|
77
|
+
return { activities, streamedText, result };
|
|
78
|
+
}
|
|
79
|
+
return { activities, streamedText };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const ampHarness: Harness = {
|
|
83
|
+
name: 'amp',
|
|
84
|
+
displayName: 'Amp',
|
|
85
|
+
binary: 'amp',
|
|
86
|
+
async detect() {
|
|
87
|
+
try {
|
|
88
|
+
const { stdout } = await execFileAsync('amp', ['--version'], { timeout: 5000 });
|
|
89
|
+
return { ok: true, version: stdout.trim() };
|
|
90
|
+
} catch {
|
|
91
|
+
try {
|
|
92
|
+
const { stdout } = await execFileAsync('omp', ['--version'], { timeout: 5000 });
|
|
93
|
+
return { ok: true, version: stdout.trim() };
|
|
94
|
+
} catch {
|
|
95
|
+
return { ok: false, hint: 'Install Amp: https://ampcode.com' };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
buildArgs(opts: BuildArgsOpts): string[] {
|
|
100
|
+
const perm = opts.nativePermission ?? PERMISSION_MAP[opts.permission] ?? 'workspace';
|
|
101
|
+
const args = ['--output', 'jsonl', opts.prompt, '--permission', perm];
|
|
102
|
+
if (opts.model) args.push('--model', opts.model);
|
|
103
|
+
if (opts.resumeSessionId) args.push('--resume', opts.resumeSessionId);
|
|
104
|
+
for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
|
|
105
|
+
return args;
|
|
106
|
+
},
|
|
107
|
+
parseLine(line: string, state: ParseState): ParseOutcome {
|
|
108
|
+
return parseAmpLine(line, state);
|
|
109
|
+
},
|
|
110
|
+
extractResult(state: ParseState): StreamedResult | null {
|
|
111
|
+
if (state.result) return state.result;
|
|
112
|
+
if (state.streamedText.trim().length > 0) {
|
|
113
|
+
return {
|
|
114
|
+
result: state.streamedText,
|
|
115
|
+
isError: false,
|
|
116
|
+
numTurns: 1,
|
|
117
|
+
totalCostUsd: 0,
|
|
118
|
+
sessionId: null,
|
|
119
|
+
stopReason: null,
|
|
120
|
+
permissionDenials: [],
|
|
121
|
+
durationMs: null,
|
|
122
|
+
durationApiMs: null,
|
|
123
|
+
ttftMs: null,
|
|
124
|
+
model: null,
|
|
125
|
+
contextWindow: null,
|
|
126
|
+
maxOutputTokens: null,
|
|
127
|
+
usage: null,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
},
|
|
132
|
+
permissionMap: {
|
|
133
|
+
readonly: ['read-only'],
|
|
134
|
+
edit: ['workspace'],
|
|
135
|
+
danger: ['danger'],
|
|
136
|
+
},
|
|
137
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import type { Harness, BuildArgsOpts, NormalizedPermission, ParseOutcome, ParseState, StreamedResult } from './types.ts';
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
8
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const PERMISSION_MAP: Record<NormalizedPermission, string> = {
|
|
12
|
+
readonly: 'plan',
|
|
13
|
+
edit: 'acceptEdits',
|
|
14
|
+
danger: 'bypassPermissions',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function parseClaudeLine(line: string, state: ParseState): ParseOutcome {
|
|
18
|
+
let o: unknown;
|
|
19
|
+
try {
|
|
20
|
+
o = JSON.parse(line);
|
|
21
|
+
} catch {
|
|
22
|
+
return { activities: [] };
|
|
23
|
+
}
|
|
24
|
+
if (!isRecord(o)) return { activities: [] };
|
|
25
|
+
const activities: ParseOutcome['activities'] = [];
|
|
26
|
+
let streamedText: string | undefined;
|
|
27
|
+
|
|
28
|
+
if (o.type === 'stream_event' && isRecord(o.event)) {
|
|
29
|
+
const ev = o.event;
|
|
30
|
+
const delta = isRecord(ev.delta) ? ev.delta : undefined;
|
|
31
|
+
if (ev.type === 'content_block_delta' && delta?.type === 'text_delta' && typeof delta.text === 'string') {
|
|
32
|
+
streamedText = delta.text;
|
|
33
|
+
} else if (ev.type === 'content_block_delta' && delta?.type === 'thinking_delta' && typeof delta.thinking === 'string') {
|
|
34
|
+
activities.push({ kind: 'thinking', chars: delta.thinking.length });
|
|
35
|
+
} else if (ev.type === 'content_block_start' && isRecord(ev.content_block)) {
|
|
36
|
+
const cb = ev.content_block;
|
|
37
|
+
if (cb.type === 'tool_use' && typeof cb.name === 'string') {
|
|
38
|
+
activities.push({ kind: 'tool_start', name: cb.name });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} else if (o.type === 'assistant' && isRecord(o.message)) {
|
|
42
|
+
for (const block of Array.isArray(o.message.content) ? o.message.content : []) {
|
|
43
|
+
if (isRecord(block) && block.type === 'tool_use' && typeof block.name === 'string') {
|
|
44
|
+
activities.push({ kind: 'tool_input', name: block.name, input: isRecord(block.input) ? block.input : {} });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} else if (o.type === 'user' && isRecord(o.message)) {
|
|
48
|
+
for (const block of Array.isArray(o.message.content) ? o.message.content : []) {
|
|
49
|
+
if (isRecord(block) && block.type === 'tool_result') {
|
|
50
|
+
activities.push({ kind: 'tool_result', isError: block.is_error === true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} else if (o.type === 'result') {
|
|
54
|
+
const u = isRecord(o.usage) ? o.usage : null;
|
|
55
|
+
let model: string | null = null;
|
|
56
|
+
let contextWindow: number | null = null;
|
|
57
|
+
let maxOutputTokens: number | null = null;
|
|
58
|
+
if (isRecord(o.modelUsage)) {
|
|
59
|
+
const first = Object.entries(o.modelUsage)[0]?.[1];
|
|
60
|
+
const firstKey = Object.keys(o.modelUsage)[0];
|
|
61
|
+
if (firstKey) model = firstKey;
|
|
62
|
+
if (isRecord(first)) {
|
|
63
|
+
if (typeof first.contextWindow === 'number') contextWindow = first.contextWindow;
|
|
64
|
+
if (typeof first.maxOutputTokens === 'number') maxOutputTokens = first.maxOutputTokens;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const result: StreamedResult = {
|
|
68
|
+
result: typeof o.result === 'string' ? o.result : state.streamedText,
|
|
69
|
+
isError: o.is_error === true,
|
|
70
|
+
numTurns: typeof o.num_turns === 'number' ? o.num_turns : 0,
|
|
71
|
+
totalCostUsd: typeof o.total_cost_usd === 'number' ? o.total_cost_usd : 0,
|
|
72
|
+
sessionId: typeof o.session_id === 'string' ? o.session_id : null,
|
|
73
|
+
stopReason: typeof o.stop_reason === 'string' ? o.stop_reason : null,
|
|
74
|
+
permissionDenials: Array.isArray(o.permission_denials) ? o.permission_denials : [],
|
|
75
|
+
durationMs: typeof o.duration_ms === 'number' ? o.duration_ms : null,
|
|
76
|
+
durationApiMs: typeof o.duration_api_ms === 'number' ? o.duration_api_ms : null,
|
|
77
|
+
ttftMs: typeof o.ttft_ms === 'number' ? o.ttft_ms : null,
|
|
78
|
+
model,
|
|
79
|
+
contextWindow,
|
|
80
|
+
maxOutputTokens,
|
|
81
|
+
usage: u
|
|
82
|
+
? {
|
|
83
|
+
inputTokens: typeof u.input_tokens === 'number' ? u.input_tokens : 0,
|
|
84
|
+
outputTokens: typeof u.output_tokens === 'number' ? u.output_tokens : 0,
|
|
85
|
+
cacheCreationInputTokens: typeof u.cache_creation_input_tokens === 'number' ? u.cache_creation_input_tokens : 0,
|
|
86
|
+
cacheReadInputTokens: typeof u.cache_read_input_tokens === 'number' ? u.cache_read_input_tokens : 0,
|
|
87
|
+
}
|
|
88
|
+
: null,
|
|
89
|
+
};
|
|
90
|
+
return { activities, streamedText, result };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { activities, streamedText };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const claudeHarness: Harness = {
|
|
97
|
+
name: 'claude',
|
|
98
|
+
displayName: 'Claude Code',
|
|
99
|
+
binary: 'claude',
|
|
100
|
+
async detect() {
|
|
101
|
+
try {
|
|
102
|
+
const { stdout } = await execFileAsync('claude', ['--version'], { timeout: 5000 });
|
|
103
|
+
return { ok: true, version: stdout.trim() };
|
|
104
|
+
} catch {
|
|
105
|
+
return { ok: false, hint: 'Install Claude Code: https://docs.anthropic.com/en/docs/claude-code' };
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
buildArgs(opts: BuildArgsOpts): string[] {
|
|
109
|
+
const mode = opts.nativePermission ?? PERMISSION_MAP[opts.permission] ?? 'acceptEdits';
|
|
110
|
+
const args = ['-p', opts.prompt, '--output-format', 'stream-json', '--verbose', '--include-partial-messages', '--permission-mode', mode];
|
|
111
|
+
if (opts.resumeSessionId) args.push('--resume', opts.resumeSessionId);
|
|
112
|
+
else args.push('--no-session-persistence');
|
|
113
|
+
if (opts.model) args.push('--model', opts.model);
|
|
114
|
+
if (opts.maxBudgetUsd !== undefined) args.push('--max-budget-usd', String(opts.maxBudgetUsd));
|
|
115
|
+
for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
|
|
116
|
+
return args;
|
|
117
|
+
},
|
|
118
|
+
parseLine(line: string, state: ParseState): ParseOutcome {
|
|
119
|
+
return parseClaudeLine(line, state);
|
|
120
|
+
},
|
|
121
|
+
extractResult(state: ParseState): StreamedResult | null {
|
|
122
|
+
return state.result;
|
|
123
|
+
},
|
|
124
|
+
permissionMap: {
|
|
125
|
+
readonly: ['plan'],
|
|
126
|
+
edit: ['acceptEdits'],
|
|
127
|
+
danger: ['bypassPermissions'],
|
|
128
|
+
},
|
|
129
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import type { Harness, BuildArgsOpts, NormalizedPermission, ParseOutcome, ParseState, StreamedResult } from './types.ts';
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
8
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const SANDBOX_MAP: Record<NormalizedPermission, string> = {
|
|
12
|
+
readonly: 'read-only',
|
|
13
|
+
edit: 'workspace-write',
|
|
14
|
+
danger: 'danger-full-access',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function extractTextFromCodexEvent(o: Record<string, unknown>, state: ParseState): string | undefined {
|
|
18
|
+
// Codex JSON variants: try common shapes
|
|
19
|
+
// 1. {type:"item.completed", item:{type:"agent_message", text:"..."}}
|
|
20
|
+
// 2. {type:"thread.item.completed", item:{type:"agent_message", ...}}
|
|
21
|
+
// 3. {type:"event", event:{type:"response.output_text.delta", delta:"..."}}
|
|
22
|
+
// 4. Plain {"text":"..."} or {"output":"..."}
|
|
23
|
+
if (typeof o.text === 'string' && o.type !== 'tool_use') return o.text;
|
|
24
|
+
if (typeof o.output === 'string') return o.output;
|
|
25
|
+
if (typeof o.delta === 'string') return o.delta;
|
|
26
|
+
if (isRecord(o.event) && typeof o.event.text === 'string') return o.event.text;
|
|
27
|
+
if (isRecord(o.event) && typeof (o.event as Record<string, unknown>).delta === 'string') return (o.event as Record<string, unknown>).delta as string;
|
|
28
|
+
if (isRecord(o.item) && typeof o.item.text === 'string') return o.item.text;
|
|
29
|
+
if (isRecord(o.item) && typeof o.item.output === 'string') return o.item.output;
|
|
30
|
+
// agent_message
|
|
31
|
+
if (o.type === 'agent_message' && typeof o.text === 'string') return o.text;
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseCodexLine(line: string, state: ParseState): ParseOutcome {
|
|
36
|
+
let o: unknown;
|
|
37
|
+
try {
|
|
38
|
+
o = JSON.parse(line);
|
|
39
|
+
} catch {
|
|
40
|
+
// Non-JSON line: treat as streamed text (codex may emit plain text)
|
|
41
|
+
if (line.trim().length > 0) return { streamedText: line + '\n', activities: [] };
|
|
42
|
+
return { activities: [] };
|
|
43
|
+
}
|
|
44
|
+
if (!isRecord(o)) return { activities: [] };
|
|
45
|
+
|
|
46
|
+
const activities: ParseOutcome['activities'] = [];
|
|
47
|
+
let streamedText: string | undefined;
|
|
48
|
+
|
|
49
|
+
// Tool activity heuristics
|
|
50
|
+
// Codex may emit: {type:"item.started", item:{type:"tool_use", name:"...", input:{}}}
|
|
51
|
+
// or {type:"tool_use", name:"..."}
|
|
52
|
+
const item = isRecord(o.item) ? o.item : null;
|
|
53
|
+
const typeStr = typeof o.type === 'string' ? o.type : '';
|
|
54
|
+
|
|
55
|
+
if (typeStr.includes('tool') || typeStr.includes('item.started') || typeStr.includes('function_call')) {
|
|
56
|
+
const toolName = (item && typeof item.name === 'string' ? item.name : typeof o.name === 'string' ? o.name : null) as string | null;
|
|
57
|
+
if (toolName) {
|
|
58
|
+
if (typeStr.includes('started') || o.type === 'tool_use') {
|
|
59
|
+
activities.push({ kind: 'tool_start', name: toolName });
|
|
60
|
+
if (item && isRecord(item.input)) activities.push({ kind: 'tool_input', name: toolName, input: item.input as Record<string, unknown> });
|
|
61
|
+
else if (isRecord(o.input)) activities.push({ kind: 'tool_input', name: toolName, input: o.input as Record<string, unknown> });
|
|
62
|
+
} else if (typeStr.includes('completed') || typeStr.includes('result')) {
|
|
63
|
+
activities.push({ kind: 'tool_result', isError: o.is_error === true || o.error === true });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (o.type === 'tool_result' || typeStr === 'item.completed' && item?.type === 'tool_result') {
|
|
68
|
+
activities.push({ kind: 'tool_result', isError: (o as Record<string, unknown>).is_error === true });
|
|
69
|
+
}
|
|
70
|
+
if (typeStr.includes('thinking') || o.type === 'reasoning' || item?.type === 'reasoning') {
|
|
71
|
+
const thinkingText = extractTextFromCodexEvent(o, state);
|
|
72
|
+
if (thinkingText) activities.push({ kind: 'thinking', chars: thinkingText.length });
|
|
73
|
+
else activities.push({ kind: 'thinking', chars: 10 });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Text extraction
|
|
77
|
+
const text = extractTextFromCodexEvent(o, state);
|
|
78
|
+
if (text && !typeStr.includes('tool') && !typeStr.includes('thinking')) {
|
|
79
|
+
// Avoid double-counting tool inputs as text
|
|
80
|
+
streamedText = text;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Result detection: final result often {type:"result", result:"...", total_cost_usd, usage, ...}
|
|
84
|
+
// or {type:"thread.completed", ...}
|
|
85
|
+
if (o.type === 'result' || o.type === 'thread.completed' || o.type === 'task.completed') {
|
|
86
|
+
const usage = isRecord(o.usage) ? o.usage : null;
|
|
87
|
+
const result: StreamedResult = {
|
|
88
|
+
result: typeof o.result === 'string' ? o.result : typeof o.output === 'string' ? o.output : state.streamedText + (streamedText ?? ''),
|
|
89
|
+
isError: o.is_error === true || o.error === true,
|
|
90
|
+
numTurns: typeof o.num_turns === 'number' ? o.num_turns : typeof o.turns === 'number' ? o.turns : 0,
|
|
91
|
+
totalCostUsd: typeof o.total_cost_usd === 'number' ? o.total_cost_usd : typeof o.cost === 'number' ? o.cost : 0,
|
|
92
|
+
sessionId: typeof o.session_id === 'string' ? o.session_id : typeof o.thread_id === 'string' ? o.thread_id : typeof o.id === 'string' ? o.id : null,
|
|
93
|
+
stopReason: typeof o.stop_reason === 'string' ? o.stop_reason : null,
|
|
94
|
+
permissionDenials: Array.isArray(o.permission_denials) ? o.permission_denials : [],
|
|
95
|
+
durationMs: typeof o.duration_ms === 'number' ? o.duration_ms : null,
|
|
96
|
+
durationApiMs: typeof o.duration_api_ms === 'number' ? o.duration_api_ms : null,
|
|
97
|
+
ttftMs: typeof o.ttft_ms === 'number' ? o.ttft_ms : null,
|
|
98
|
+
model: typeof o.model === 'string' ? o.model : null,
|
|
99
|
+
contextWindow: typeof o.context_window === 'number' ? o.context_window : null,
|
|
100
|
+
maxOutputTokens: null,
|
|
101
|
+
usage: usage
|
|
102
|
+
? {
|
|
103
|
+
inputTokens: typeof usage.input_tokens === 'number' ? usage.input_tokens : typeof (usage as Record<string, unknown>).inputTokens === 'number' ? (usage as Record<string, unknown>).inputTokens as number : 0,
|
|
104
|
+
outputTokens: typeof usage.output_tokens === 'number' ? usage.output_tokens : typeof (usage as Record<string, unknown>).outputTokens === 'number' ? (usage as Record<string, unknown>).outputTokens as number : 0,
|
|
105
|
+
cacheCreationInputTokens: typeof (usage as Record<string, unknown>).cache_creation_input_tokens === 'number' ? (usage as Record<string, unknown>).cache_creation_input_tokens as number : 0,
|
|
106
|
+
cacheReadInputTokens: typeof (usage as Record<string, unknown>).cache_read_input_tokens === 'number' ? (usage as Record<string, unknown>).cache_read_input_tokens as number : 0,
|
|
107
|
+
}
|
|
108
|
+
: null,
|
|
109
|
+
};
|
|
110
|
+
return { activities, streamedText, result };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { activities, streamedText };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const codexHarness: Harness = {
|
|
117
|
+
name: 'codex',
|
|
118
|
+
displayName: 'Muse',
|
|
119
|
+
binary: 'codex',
|
|
120
|
+
async detect() {
|
|
121
|
+
try {
|
|
122
|
+
const { stdout } = await execFileAsync('codex', ['--version'], { timeout: 5000 });
|
|
123
|
+
return { ok: true, version: stdout.trim() };
|
|
124
|
+
} catch {
|
|
125
|
+
return { ok: false, hint: 'Install Muse: https://github.com/openai/codex' };
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
buildArgs(opts: BuildArgsOpts): string[] {
|
|
129
|
+
// codex exec --json <prompt> --sandbox <level> --ask-for-approval <level>
|
|
130
|
+
const sandbox = opts.nativePermission ?? SANDBOX_MAP[opts.permission] ?? 'workspace-write';
|
|
131
|
+
const args = ['exec', '--json', opts.prompt, '--sandbox', sandbox];
|
|
132
|
+
if (opts.permission === 'danger' || sandbox === 'danger-full-access') args.push('--ask-for-approval', 'never');
|
|
133
|
+
else if (opts.permission === 'readonly') args.push('--ask-for-approval', 'never');
|
|
134
|
+
else args.push('--ask-for-approval', 'on-request');
|
|
135
|
+
if (opts.model) args.push('--model', opts.model);
|
|
136
|
+
if (opts.resumeSessionId) args.push('--thread-id', opts.resumeSessionId);
|
|
137
|
+
// maxBudgetUsd not natively supported; pass as env hint via --config if needed
|
|
138
|
+
for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
|
|
139
|
+
return args;
|
|
140
|
+
},
|
|
141
|
+
parseLine(line: string, state: ParseState): ParseOutcome {
|
|
142
|
+
return parseCodexLine(line, state);
|
|
143
|
+
},
|
|
144
|
+
extractResult(state: ParseState): StreamedResult | null {
|
|
145
|
+
if (state.result) return state.result;
|
|
146
|
+
// Fallback: if no explicit result, synthesize from streamed text if any
|
|
147
|
+
if (state.streamedText.trim().length > 0) {
|
|
148
|
+
return {
|
|
149
|
+
result: state.streamedText,
|
|
150
|
+
isError: false,
|
|
151
|
+
numTurns: 1,
|
|
152
|
+
totalCostUsd: 0,
|
|
153
|
+
sessionId: null,
|
|
154
|
+
stopReason: null,
|
|
155
|
+
permissionDenials: [],
|
|
156
|
+
durationMs: null,
|
|
157
|
+
durationApiMs: null,
|
|
158
|
+
ttftMs: null,
|
|
159
|
+
model: null,
|
|
160
|
+
contextWindow: null,
|
|
161
|
+
maxOutputTokens: null,
|
|
162
|
+
usage: null,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
},
|
|
167
|
+
permissionMap: {
|
|
168
|
+
readonly: ['read-only'],
|
|
169
|
+
edit: ['workspace-write'],
|
|
170
|
+
danger: ['danger-full-access'],
|
|
171
|
+
},
|
|
172
|
+
};
|