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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +179 -0
  3. package/extensions/activity.ts +254 -0
  4. package/extensions/command.ts +91 -0
  5. package/extensions/config.ts +158 -0
  6. package/extensions/harnesses/amp.ts +137 -0
  7. package/extensions/harnesses/claude.ts +129 -0
  8. package/extensions/harnesses/codex.ts +172 -0
  9. package/extensions/harnesses/opencode.ts +138 -0
  10. package/extensions/harnesses/registry.ts +51 -0
  11. package/extensions/harnesses/types.ts +93 -0
  12. package/extensions/hint.ts +57 -0
  13. package/extensions/index.ts +816 -0
  14. package/extensions/progress.ts +142 -0
  15. package/extensions/run-claude.ts +52 -0
  16. package/extensions/runner.ts +120 -0
  17. package/extensions/stream-parse.ts +29 -0
  18. package/extensions/templates.ts +166 -0
  19. package/extensions/usage.ts +40 -0
  20. package/package.json +58 -0
  21. package/templates/amp/docs.md +11 -0
  22. package/templates/amp/general.md +9 -0
  23. package/templates/amp/implement.md +13 -0
  24. package/templates/amp/plan.md +18 -0
  25. package/templates/amp/review.md +19 -0
  26. package/templates/amp/security-audit.md +18 -0
  27. package/templates/claude/docs.md +11 -0
  28. package/templates/claude/general.md +8 -0
  29. package/templates/claude/implement.md +13 -0
  30. package/templates/claude/plan.md +18 -0
  31. package/templates/claude/review.md +19 -0
  32. package/templates/claude/security-audit.md +18 -0
  33. package/templates/codex/docs.md +11 -0
  34. package/templates/codex/general.md +9 -0
  35. package/templates/codex/implement.md +13 -0
  36. package/templates/codex/plan.md +18 -0
  37. package/templates/codex/review.md +19 -0
  38. package/templates/codex/security-audit.md +18 -0
  39. package/templates/docs.md +11 -0
  40. package/templates/general.md +8 -0
  41. package/templates/implement.md +13 -0
  42. package/templates/opencode/docs.md +11 -0
  43. package/templates/opencode/general.md +9 -0
  44. package/templates/opencode/implement.md +13 -0
  45. package/templates/opencode/plan.md +18 -0
  46. package/templates/opencode/review.md +19 -0
  47. package/templates/opencode/security-audit.md +18 -0
  48. package/templates/plan.md +18 -0
  49. package/templates/review.md +19 -0
  50. package/templates/security-audit.md +18 -0
  51. package/templates/shared/docs.md +11 -0
  52. package/templates/shared/general.md +8 -0
  53. package/templates/shared/implement.md +13 -0
  54. package/templates/shared/plan.md +18 -0
  55. package/templates/shared/review.md +19 -0
  56. package/templates/shared/security-audit.md +18 -0
@@ -0,0 +1,138 @@
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: 'allow-edit',
14
+ danger: 'danger',
15
+ };
16
+
17
+ function extractOpencodeText(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
+ if (isRecord(o.event) && typeof o.event.text === 'string') return o.event.text;
23
+ if (isRecord(o.message) && typeof o.message.text === 'string') return o.message.text;
24
+ return undefined;
25
+ }
26
+
27
+ export function parseOpencodeLine(line: string, state: ParseState): ParseOutcome {
28
+ let o: unknown;
29
+ try {
30
+ o = JSON.parse(line);
31
+ } catch {
32
+ if (line.trim().length > 0) return { streamedText: line + '\n', activities: [] };
33
+ return { activities: [] };
34
+ }
35
+ if (!isRecord(o)) return { activities: [] };
36
+ const activities: ParseOutcome['activities'] = [];
37
+ let streamedText: string | undefined;
38
+ const typeStr = typeof o.type === 'string' ? o.type : '';
39
+
40
+ if (typeStr.includes('tool') || o.type === 'tool_use' || o.type === 'tool_result') {
41
+ const name = typeof o.name === 'string' ? o.name : typeof (o as Record<string, unknown>).tool === 'string' ? (o as Record<string, unknown>).tool as string : 'tool';
42
+ if (typeStr.includes('start') || o.type === 'tool_use') {
43
+ activities.push({ kind: 'tool_start', name });
44
+ if (isRecord(o.input)) activities.push({ kind: 'tool_input', name, input: o.input as Record<string, unknown> });
45
+ } else if (typeStr.includes('result') || typeStr.includes('completed') || o.type === 'tool_result') {
46
+ activities.push({ kind: 'tool_result', isError: o.is_error === true || o.error === true });
47
+ }
48
+ }
49
+ if (typeStr.includes('thinking') || o.type === 'reasoning') {
50
+ activities.push({ kind: 'thinking', chars: 10 });
51
+ }
52
+
53
+ const text = extractOpencodeText(o);
54
+ if (text && !typeStr.includes('tool') && !typeStr.includes('thinking')) streamedText = text;
55
+
56
+ if (o.type === 'result' || o.type === 'completed' || o.type === 'done') {
57
+ const usage = isRecord(o.usage) ? o.usage : null;
58
+ const result: StreamedResult = {
59
+ result: typeof o.result === 'string' ? o.result : typeof o.output === 'string' ? o.output : state.streamedText + (streamedText ?? ''),
60
+ isError: o.is_error === true,
61
+ numTurns: typeof o.num_turns === 'number' ? o.num_turns : 0,
62
+ totalCostUsd: typeof o.total_cost_usd === 'number' ? o.total_cost_usd : 0,
63
+ sessionId: typeof o.session_id === 'string' ? o.session_id : typeof o.id === 'string' ? o.id : null,
64
+ stopReason: typeof o.stop_reason === 'string' ? o.stop_reason : null,
65
+ permissionDenials: [],
66
+ durationMs: typeof o.duration_ms === 'number' ? o.duration_ms : null,
67
+ durationApiMs: null,
68
+ ttftMs: null,
69
+ model: typeof o.model === 'string' ? o.model : null,
70
+ contextWindow: null,
71
+ maxOutputTokens: null,
72
+ usage: usage
73
+ ? {
74
+ inputTokens: typeof usage.input_tokens === 'number' ? usage.input_tokens : 0,
75
+ outputTokens: typeof usage.output_tokens === 'number' ? usage.output_tokens : 0,
76
+ cacheCreationInputTokens: 0,
77
+ cacheReadInputTokens: 0,
78
+ }
79
+ : null,
80
+ };
81
+ return { activities, streamedText, result };
82
+ }
83
+ return { activities, streamedText };
84
+ }
85
+
86
+ export const opencodeHarness: Harness = {
87
+ name: 'opencode',
88
+ displayName: 'OpenCode',
89
+ binary: 'opencode',
90
+ async detect() {
91
+ try {
92
+ const { stdout } = await execFileAsync('opencode', ['--version'], { timeout: 5000 });
93
+ return { ok: true, version: stdout.trim() };
94
+ } catch {
95
+ return { ok: false, hint: 'Install OpenCode: https://opencode.ai' };
96
+ }
97
+ },
98
+ buildArgs(opts: BuildArgsOpts): string[] {
99
+ const args = ['run', '--format', 'json', opts.prompt];
100
+ // permission not fully standardized; pass as --permission if supported
101
+ const perm = opts.nativePermission ?? PERMISSION_MAP[opts.permission] ?? 'allow-edit';
102
+ args.push('--permission', perm);
103
+ if (opts.model) args.push('--model', opts.model);
104
+ if (opts.resumeSessionId) args.push('--session', opts.resumeSessionId);
105
+ for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
106
+ return args;
107
+ },
108
+ parseLine(line: string, state: ParseState): ParseOutcome {
109
+ return parseOpencodeLine(line, state);
110
+ },
111
+ extractResult(state: ParseState): StreamedResult | null {
112
+ if (state.result) return state.result;
113
+ if (state.streamedText.trim().length > 0) {
114
+ return {
115
+ result: state.streamedText,
116
+ isError: false,
117
+ numTurns: 1,
118
+ totalCostUsd: 0,
119
+ sessionId: null,
120
+ stopReason: null,
121
+ permissionDenials: [],
122
+ durationMs: null,
123
+ durationApiMs: null,
124
+ ttftMs: null,
125
+ model: null,
126
+ contextWindow: null,
127
+ maxOutputTokens: null,
128
+ usage: null,
129
+ };
130
+ }
131
+ return null;
132
+ },
133
+ permissionMap: {
134
+ readonly: ['read-only'],
135
+ edit: ['allow-edit'],
136
+ danger: ['danger'],
137
+ },
138
+ };
@@ -0,0 +1,51 @@
1
+ import { claudeHarness } from './claude.ts';
2
+ import { codexHarness } from './codex.ts';
3
+ import { opencodeHarness } from './opencode.ts';
4
+ import { ampHarness } from './amp.ts';
5
+ import type { Harness } from './types.ts';
6
+
7
+ export const HARNESSES: Record<string, Harness> = {
8
+ claude: claudeHarness,
9
+ codex: codexHarness,
10
+ opencode: opencodeHarness,
11
+ amp: ampHarness,
12
+ };
13
+
14
+ export const ALIASES: Record<string, string> = {
15
+ omp: 'amp',
16
+ };
17
+
18
+ export const HARNESS_NAMES = Object.keys(HARNESSES);
19
+
20
+ export function resolveHarnessName(name: string): string {
21
+ const lower = name.toLowerCase();
22
+ if (HARNESSES[lower]) return lower;
23
+ if (ALIASES[lower] && HARNESSES[ALIASES[lower]]) return ALIASES[lower];
24
+ return lower;
25
+ }
26
+
27
+ export function getHarness(name: string): Harness | undefined {
28
+ const resolved = resolveHarnessName(name);
29
+ return HARNESSES[resolved];
30
+ }
31
+
32
+ export function getAllHarnesses(): Harness[] {
33
+ return Object.values(HARNESSES);
34
+ }
35
+
36
+ export async function detectAll(): Promise<Record<string, { ok: boolean; version?: string; hint?: string }>> {
37
+ const out: Record<string, { ok: boolean; version?: string; hint?: string }> = {};
38
+ await Promise.all(
39
+ Object.entries(HARNESSES).map(async ([name, h]) => {
40
+ out[name] = await h.detect();
41
+ }),
42
+ );
43
+ return out;
44
+ }
45
+
46
+ export function isKnownHarness(name: string): boolean {
47
+ const r = resolveHarnessName(name);
48
+ return r in HARNESSES;
49
+ }
50
+
51
+ export const normalizeHarnessName = resolveHarnessName;
@@ -0,0 +1,93 @@
1
+ /** Harness abstraction — normalized permission + generic runner contract. */
2
+
3
+ export type NormalizedPermission = 'readonly' | 'edit' | 'danger';
4
+
5
+ export interface StreamedUsage {
6
+ inputTokens: number;
7
+ outputTokens: number;
8
+ cacheCreationInputTokens: number;
9
+ cacheReadInputTokens: number;
10
+ }
11
+
12
+ export interface StreamedResult {
13
+ result: string;
14
+ isError: boolean;
15
+ numTurns: number;
16
+ totalCostUsd: number;
17
+ sessionId: string | null;
18
+ stopReason: string | null;
19
+ permissionDenials: unknown[];
20
+ usage: StreamedUsage | null;
21
+ durationMs: number | null;
22
+ durationApiMs: number | null;
23
+ ttftMs: number | null;
24
+ model: string | null;
25
+ contextWindow: number | null;
26
+ maxOutputTokens: number | null;
27
+ }
28
+
29
+ export type ActivityEvent =
30
+ | { kind: 'tool_start'; name: string }
31
+ | { kind: 'tool_input'; name: string; input: Record<string, unknown> }
32
+ | { kind: 'tool_result'; isError: boolean }
33
+ | { kind: 'thinking'; chars: number };
34
+
35
+ export interface ParseState {
36
+ streamedText: string;
37
+ activities: ActivityEvent[];
38
+ result: StreamedResult | null;
39
+ /** Harness-internal scratch space. */
40
+ _harness?: Record<string, unknown>;
41
+ }
42
+
43
+ export interface ParseOutcome {
44
+ streamedText?: string;
45
+ activities?: ActivityEvent[];
46
+ result?: StreamedResult | null;
47
+ }
48
+
49
+ export interface StreamParseOutcome {
50
+ streamedText: string;
51
+ result: StreamedResult | null;
52
+ activities: ActivityEvent[];
53
+ }
54
+
55
+ export interface BuildArgsOpts {
56
+ prompt: string;
57
+ cwd: string;
58
+ permission: NormalizedPermission;
59
+ nativePermission?: string;
60
+ model?: string;
61
+ maxBudgetUsd?: number;
62
+ addDirs?: string[];
63
+ resumeSessionId?: string;
64
+ resumeId?: string;
65
+ }
66
+
67
+ export type HarnessBuildOpts = BuildArgsOpts;
68
+
69
+ export interface DetectResult {
70
+ ok: boolean;
71
+ version?: string;
72
+ hint?: string;
73
+ }
74
+
75
+ export interface Harness {
76
+ name: string;
77
+ displayName: string;
78
+ binary: string;
79
+ aliases?: string[];
80
+ /** Check if binary is available. */
81
+ detect(): Promise<DetectResult>;
82
+ /** Build CLI args (excluding binary). */
83
+ buildArgs(opts: BuildArgsOpts): string[];
84
+ /** Parse a single stdout line. State is mutated by runner; return deltas. */
85
+ parseLine(line: string, state: ParseState): ParseOutcome;
86
+ /** Extract final result after process exit (state.result may already be set). */
87
+ extractResult(state: ParseState): StreamedResult | null;
88
+ /** Normalized -> native arg fragments. */
89
+ permissionMap?: Record<NormalizedPermission, string[]>;
90
+ permissionHint?: (permission: NormalizedPermission) => string[];
91
+ }
92
+
93
+ export const DEFAULT_TIMEOUT_MS = 600_000;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Delegation-intent detection for user input.
3
+ *
4
+ * Gated entirely by `autoDelegateHints` — when off, user input is never
5
+ * touched and nothing nudges the agent toward this tool (the tool still
6
+ * exists in the available-tools list, so the agent may still choose it).
7
+ * When on:
8
+ * - Explicit markers: `@claude`, `@codex`, "…with claude/codex …"
9
+ * (marker stripped, hint appended)
10
+ * - Keyword phrasing: imperative review/plan/audit/docs (hint appended)
11
+ *
12
+ * Never hints when the text already names the tool or the /delegate command.
13
+ */
14
+
15
+ export interface HintConfig {
16
+ /** Master switch — false = no hinting at all. */
17
+ autoDelegateHints: boolean;
18
+ }
19
+
20
+ const EXPLICIT_MARKER_RE =
21
+ /(?:^|\s)@(?:claude|codex|opencode|amp)\b|(?:\b(with|via|using)\s+(?:claude|codex|opencode|amp|delegate)\b)|(?:\bdelegate\b[\s\S]*\b(?:claude|codex|opencode|amp|delegate)\b)/i;
22
+
23
+ const KEYWORD_RE = /^\s*(review|plan|audit|security\s*audit|document|implement|write\s+tests?)\b/i;
24
+
25
+ const HINT_TEXT =
26
+ '[delegate] The user wants this delegated to a harness. ' +
27
+ 'Call the delegate tool with a fitting harness (claude, codex, opencode, amp) and mode (review, plan, security-audit, docs, implement, …) ' +
28
+ 'rather than doing the work yourself. Only skip it if delegation is clearly inappropriate.';
29
+
30
+ const LEGACY_HINT_TEXT =
31
+ '[claude-delegate] The user wants this delegated to Claude Code. ' +
32
+ 'Call the claude_delegate tool with a fitting mode (review, plan, security-audit, docs, implement, …) ' +
33
+ 'rather than doing the work yourself. Only skip it if delegation is clearly inappropriate.';
34
+
35
+ export function delegationHint(text: string, cfg: HintConfig): string | null {
36
+ if (!cfg.autoDelegateHints) return null;
37
+
38
+ // already explicit about the tool or command — nothing to add
39
+ if (/\bclaude_delegate\b|\/(?:claude|delegate|codex|opencode|amp)\b/.test(text)) return null;
40
+
41
+ if (EXPLICIT_MARKER_RE.test(text)) return HINT_TEXT;
42
+
43
+ if (KEYWORD_RE.test(text)) return HINT_TEXT;
44
+
45
+ return null;
46
+ }
47
+
48
+ export function claudeDelegationHint(text: string, cfg: HintConfig): string | null {
49
+ const h = delegationHint(text, cfg);
50
+ if (h === HINT_TEXT) return LEGACY_HINT_TEXT;
51
+ return h;
52
+ }
53
+
54
+ /** Remove the @harness prefix marker from the text before sending. */
55
+ export function stripMarker(text: string): string {
56
+ return text.replace(/(?:^|\s)@(?:claude|codex|opencode|amp|delegate)\b/g, ' ').replace(/\s{2,}/g, ' ').trim();
57
+ }