@plimeor/harness 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/src/output.ts ADDED
@@ -0,0 +1,183 @@
1
+ import type { StandardSchemaV1 } from '@standard-schema/spec'
2
+
3
+ import { HarnessRunOutputError } from './errors'
4
+ import type { HarnessRunEvent, HarnessRunResult, RunOutputRequest, StructuredOutputRequest } from './types'
5
+
6
+ export async function decodeRunOutput<Output extends RunOutputRequest>(input: {
7
+ output: Output
8
+ finalText: string
9
+ exitCode: number | null
10
+ signal?: string
11
+ }): Promise<{ events: HarnessRunEvent[]; result: HarnessRunResult<Output> }> {
12
+ const mode = input.output.mode ?? 'text'
13
+
14
+ if (mode === 'text') {
15
+ return {
16
+ events: input.finalText ? [{ text: input.finalText, type: 'text' }] : [],
17
+ result: baseResult(input) as HarnessRunResult<Output>
18
+ }
19
+ }
20
+
21
+ if (mode === 'jsonl') {
22
+ const values = parseJsonLines(input.finalText, input.exitCode, input.signal)
23
+ return {
24
+ events: values.map(value => ({ type: 'json', value })),
25
+ result: {
26
+ ...baseResult(input),
27
+ finalText: extractFinalText(values) ?? input.finalText
28
+ } as HarnessRunResult<Output>
29
+ }
30
+ }
31
+
32
+ return (await decodeStructuredOutput(
33
+ input as { output: StructuredOutputRequest; finalText: string; exitCode: number | null; signal?: string }
34
+ )) as { events: HarnessRunEvent[]; result: HarnessRunResult<Output> }
35
+ }
36
+
37
+ function extractFinalText(values: unknown[]): string | undefined {
38
+ for (const value of values.toReversed()) {
39
+ const text = extractTextFromEvent(value)
40
+ if (text) {
41
+ return text
42
+ }
43
+ }
44
+
45
+ return undefined
46
+ }
47
+
48
+ function extractTextFromEvent(value: unknown): string | undefined {
49
+ if (!isRecord(value)) {
50
+ return undefined
51
+ }
52
+
53
+ const result = value.result
54
+ if (typeof result === 'string' && result.length > 0) {
55
+ return result
56
+ }
57
+
58
+ const structuredOutput = value.structured_output
59
+ if (structuredOutput !== undefined) {
60
+ return JSON.stringify(structuredOutput)
61
+ }
62
+
63
+ const item = value.item
64
+ if (isRecord(item) && item.type === 'agent_message' && typeof item.text === 'string' && item.text.length > 0) {
65
+ return item.text
66
+ }
67
+
68
+ const message = value.message
69
+ if (isRecord(message)) {
70
+ const messageText = extractTextFromContent(message.content)
71
+ if (messageText) {
72
+ return messageText
73
+ }
74
+ }
75
+
76
+ return undefined
77
+ }
78
+
79
+ function extractTextFromContent(content: unknown): string | undefined {
80
+ if (!Array.isArray(content)) {
81
+ return undefined
82
+ }
83
+
84
+ const text = content
85
+ .map(part => {
86
+ if (isRecord(part) && part.type === 'text' && typeof part.text === 'string') {
87
+ return part.text
88
+ }
89
+
90
+ return ''
91
+ })
92
+ .join('')
93
+
94
+ return text.length > 0 ? text : undefined
95
+ }
96
+
97
+ function isRecord(value: unknown): value is Record<string, unknown> {
98
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
99
+ }
100
+
101
+ function baseResult(input: { finalText: string; exitCode: number | null; signal?: string }): {
102
+ exitCode: number | null
103
+ signal?: string
104
+ finalText: string
105
+ } {
106
+ return {
107
+ exitCode: input.exitCode,
108
+ finalText: input.finalText,
109
+ signal: input.signal
110
+ }
111
+ }
112
+
113
+ function parseJsonLines(text: string, exitCode: number | null, signal: string | undefined): unknown[] {
114
+ const lines = text.split(/\r?\n/).filter(line => line.trim().length > 0)
115
+
116
+ try {
117
+ return lines.map(line => JSON.parse(line))
118
+ } catch (cause) {
119
+ throw new HarnessRunOutputError({
120
+ cause,
121
+ exitCode,
122
+ finalText: text,
123
+ kind: 'json_parse_failed',
124
+ outputMode: 'jsonl',
125
+ signal
126
+ })
127
+ }
128
+ }
129
+
130
+ async function decodeStructuredOutput<Schema extends StandardSchemaV1>(input: {
131
+ output: StructuredOutputRequest<Schema>
132
+ finalText: string
133
+ exitCode: number | null
134
+ signal?: string
135
+ }): Promise<{
136
+ events: HarnessRunEvent[]
137
+ result: HarnessRunResult<StructuredOutputRequest<Schema>>
138
+ }> {
139
+ let value: unknown
140
+
141
+ try {
142
+ value = JSON.parse(input.finalText)
143
+ } catch (cause) {
144
+ throw new HarnessRunOutputError({
145
+ cause,
146
+ exitCode: input.exitCode,
147
+ finalText: input.finalText,
148
+ kind: 'json_parse_failed',
149
+ outputMode: 'structured',
150
+ signal: input.signal
151
+ })
152
+ }
153
+
154
+ const structuredValue = extractStructuredValue(value)
155
+ const validation = await input.output.schema['~standard'].validate(structuredValue)
156
+ if (validation.issues) {
157
+ throw new HarnessRunOutputError({
158
+ cause: validation.issues,
159
+ exitCode: input.exitCode,
160
+ finalText: input.finalText,
161
+ kind: 'structured_validation_failed',
162
+ outputMode: 'structured',
163
+ signal: input.signal
164
+ })
165
+ }
166
+
167
+ return {
168
+ events: [{ type: 'json', value }],
169
+ result: {
170
+ ...baseResult(input),
171
+ finalText: extractTextFromEvent(value) ?? input.finalText,
172
+ structured: validation.value as StandardSchemaV1.InferOutput<Schema>
173
+ }
174
+ }
175
+ }
176
+
177
+ function extractStructuredValue(value: unknown): unknown {
178
+ if (isRecord(value) && value.structured_output !== undefined) {
179
+ return value.structured_output
180
+ }
181
+
182
+ return value
183
+ }
package/src/process.ts ADDED
@@ -0,0 +1,183 @@
1
+ import { decodeRunOutput } from './output'
2
+ import type { CommandPlan, HarnessId, HarnessRun, HarnessRunEvent, RunOutputRequest } from './types'
3
+
4
+ class AsyncQueue<T> implements AsyncIterable<T> {
5
+ private readonly values: T[] = []
6
+ private readonly waiters: Array<(result: IteratorResult<T>) => void> = []
7
+ private closed = false
8
+
9
+ push(value: T): void {
10
+ const waiter = this.waiters.shift()
11
+ if (waiter) {
12
+ waiter({ done: false, value })
13
+ return
14
+ }
15
+
16
+ this.values.push(value)
17
+ }
18
+
19
+ close(): void {
20
+ this.closed = true
21
+ for (const waiter of this.waiters.splice(0)) {
22
+ waiter({ done: true, value: undefined })
23
+ }
24
+ }
25
+
26
+ [Symbol.asyncIterator](): AsyncIterator<T> {
27
+ return {
28
+ next: () => this.next()
29
+ }
30
+ }
31
+
32
+ private next(): Promise<IteratorResult<T>> {
33
+ const value = this.values.shift()
34
+ if (value !== undefined) {
35
+ return Promise.resolve({ done: false, value })
36
+ }
37
+
38
+ if (this.closed) {
39
+ return Promise.resolve({ done: true, value: undefined })
40
+ }
41
+
42
+ return new Promise(resolve => {
43
+ this.waiters.push(resolve)
44
+ })
45
+ }
46
+ }
47
+
48
+ export function runCommandPlan<Output extends RunOutputRequest>(
49
+ harnessId: HarnessId,
50
+ plan: CommandPlan<Output>
51
+ ): HarnessRun<Output> {
52
+ if (plan.harnessId !== harnessId) {
53
+ throw new Error(`CommandPlan harnessId mismatch: expected ${harnessId}, received ${plan.harnessId}`)
54
+ }
55
+
56
+ const stdout = new AsyncQueue<Uint8Array>()
57
+ const stderr = new AsyncQueue<Uint8Array>()
58
+ const events = new AsyncQueue<HarnessRunEvent>()
59
+ const decoder = new TextDecoder()
60
+ let stdoutText = ''
61
+
62
+ const subprocess = Bun.spawn({
63
+ cmd: [plan.command, ...plan.args],
64
+ cwd: plan.cwd,
65
+ env: resolveEnv(plan.env),
66
+ stderr: 'pipe',
67
+ stdin: plan.stdin === undefined ? 'ignore' : 'pipe',
68
+ stdout: 'pipe'
69
+ })
70
+
71
+ if (plan.stdin !== undefined && subprocess.stdin) {
72
+ void writeStdin(subprocess.stdin, plan.stdin)
73
+ }
74
+
75
+ const timeout = plan.timeoutMs
76
+ ? setTimeout(() => {
77
+ subprocess.kill()
78
+ }, plan.timeoutMs)
79
+ : undefined
80
+
81
+ const stdoutDone = pipeStream(subprocess.stdout, stdout, chunk => {
82
+ const text = decoder.decode(chunk, { stream: true })
83
+ stdoutText += text
84
+ })
85
+ const stderrDone = pipeStream(subprocess.stderr, stderr)
86
+
87
+ const result = (async () => {
88
+ const exitCode = await subprocess.exited
89
+ clearTimeout(timeout)
90
+ await Promise.all([stdoutDone, stderrDone])
91
+ const tail = decoder.decode()
92
+ stdoutText += tail
93
+
94
+ try {
95
+ const decoded = await decodeRunOutput({
96
+ exitCode,
97
+ finalText: stdoutText,
98
+ output: plan.output,
99
+ signal: subprocess.signalCode ?? undefined
100
+ })
101
+
102
+ for (const event of decoded.events) {
103
+ events.push(event)
104
+ }
105
+ return decoded.result
106
+ } finally {
107
+ events.close()
108
+ }
109
+ })()
110
+
111
+ return {
112
+ events,
113
+ kill(signal?: string) {
114
+ subprocess.kill(signal as NodeJS.Signals | undefined)
115
+ },
116
+ plan,
117
+ result,
118
+ stderr,
119
+ stdout
120
+ }
121
+ }
122
+
123
+ export function createProcessRunner(harnessId: HarnessId): {
124
+ run<Output extends RunOutputRequest>(plan: CommandPlan<Output>): Promise<HarnessRun<Output>>
125
+ } {
126
+ return {
127
+ async run<Output extends RunOutputRequest>(plan: CommandPlan<Output>): Promise<HarnessRun<Output>> {
128
+ return runCommandPlan(harnessId, plan)
129
+ }
130
+ }
131
+ }
132
+
133
+ function resolveEnv(patch: Record<string, string | undefined> | undefined): Record<string, string> {
134
+ const env: Record<string, string> = {}
135
+ for (const [name, value] of Object.entries(process.env)) {
136
+ if (typeof value === 'string') {
137
+ env[name] = value
138
+ }
139
+ }
140
+
141
+ for (const [name, value] of Object.entries(patch ?? {})) {
142
+ if (value === undefined) {
143
+ delete env[name]
144
+ continue
145
+ }
146
+
147
+ env[name] = value
148
+ }
149
+
150
+ return env
151
+ }
152
+
153
+ async function pipeStream(
154
+ stream: ReadableStream<Uint8Array> | null,
155
+ queue: AsyncQueue<Uint8Array>,
156
+ onChunk?: (chunk: Uint8Array) => void
157
+ ): Promise<void> {
158
+ if (!stream) {
159
+ queue.close()
160
+ return
161
+ }
162
+
163
+ const reader = stream.getReader()
164
+ try {
165
+ while (true) {
166
+ const chunk = await reader.read()
167
+ if (chunk.done) {
168
+ return
169
+ }
170
+
171
+ queue.push(chunk.value)
172
+ onChunk?.(chunk.value)
173
+ }
174
+ } finally {
175
+ queue.close()
176
+ reader.releaseLock()
177
+ }
178
+ }
179
+
180
+ async function writeStdin(stdin: Bun.FileSink, value: string | Uint8Array): Promise<void> {
181
+ stdin.write(value)
182
+ stdin.end()
183
+ }
@@ -0,0 +1,34 @@
1
+ import type { HarnessAdapter, HarnessContext, HarnessDetection, HarnessId, HarnessRegistry } from './types'
2
+
3
+ function createHarnessRegistry(): HarnessRegistry {
4
+ const adapters: HarnessAdapter[] = []
5
+
6
+ return {
7
+ async detectAll(context?: HarnessContext): Promise<HarnessDetection[]> {
8
+ return Promise.all(adapters.map(adapter => adapter.detect(context)))
9
+ },
10
+
11
+ list(): HarnessAdapter[] {
12
+ return [...adapters]
13
+ },
14
+
15
+ async open(id: HarnessId, context?: HarnessContext) {
16
+ const adapter = adapters.find(candidate => candidate.id === id)
17
+ if (!adapter) {
18
+ throw new Error(`Unknown harness adapter: ${id}`)
19
+ }
20
+
21
+ return adapter.open(context)
22
+ },
23
+
24
+ use(adapter: HarnessAdapter): void {
25
+ if (adapters.some(candidate => candidate.id === adapter.id)) {
26
+ throw new Error(`Duplicate harness adapter: ${adapter.id}`)
27
+ }
28
+
29
+ adapters.push(adapter)
30
+ }
31
+ }
32
+ }
33
+
34
+ export const harness = createHarnessRegistry()
package/src/schema.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { StandardJSONSchemaV1, StandardSchemaV1 } from '@standard-schema/spec'
2
+
3
+ export function resolveOutputJsonSchema(schema: StandardSchemaV1): Record<string, unknown> | undefined {
4
+ if (!isStandardJsonSchema(schema)) {
5
+ return undefined
6
+ }
7
+
8
+ try {
9
+ return schema['~standard'].jsonSchema.output({ target: 'draft-07' })
10
+ } catch {
11
+ return undefined
12
+ }
13
+ }
14
+
15
+ function isStandardJsonSchema(schema: StandardSchemaV1): schema is StandardSchemaV1 & StandardJSONSchemaV1 {
16
+ const candidate = schema as StandardSchemaV1 & Partial<StandardJSONSchemaV1>
17
+ return typeof candidate['~standard'].jsonSchema?.output === 'function'
18
+ }
package/src/types.ts ADDED
@@ -0,0 +1,166 @@
1
+ import type { StandardSchemaV1 } from '@standard-schema/spec'
2
+
3
+ export type HarnessId = 'claude' | 'codex' | 'kiro' | 'pi' | (string & {})
4
+
5
+ export type HarnessContext = {
6
+ /** Default working directory for adapter operations; relative extension paths resolve from here. */
7
+ cwd?: string
8
+ /** Base environment available to detection, planning, and adapter-owned native commands. */
9
+ env?: Record<string, string | undefined>
10
+ /** User home directory used for user-scope native config resolution. */
11
+ home?: string
12
+ }
13
+
14
+ export type HarnessRegistry = {
15
+ use(adapter: HarnessAdapter): void
16
+ list(): HarnessAdapter[]
17
+ detectAll(context?: HarnessContext): Promise<HarnessDetection[]>
18
+ open(id: HarnessId, context?: HarnessContext): Promise<HarnessHandle>
19
+ }
20
+
21
+ export type HarnessAdapter = {
22
+ id: HarnessId
23
+ detect(context?: HarnessContext): Promise<HarnessDetection>
24
+ open(context?: HarnessContext): Promise<HarnessHandle>
25
+ }
26
+
27
+ export type HarnessHandle = {
28
+ detection: HarnessDetection
29
+ health: HealthFacet
30
+ process: ProcessFacet
31
+ extensions: ExtensionFacet
32
+ }
33
+
34
+ export type HarnessDetection = {
35
+ id: HarnessId
36
+ detected: boolean
37
+ binary?: {
38
+ /** Executable name or path used to invoke the detected harness CLI. */
39
+ command: string
40
+ /** Adapter-verified identity string, such as a version or product marker. */
41
+ identity?: string
42
+ }
43
+ }
44
+
45
+ export type HealthFacet = {
46
+ check(): Promise<HealthReport>
47
+ }
48
+
49
+ export type HealthReport = { success: true } | { success: false; message: string }
50
+
51
+ export type ProcessFacet = {
52
+ plan<Output extends RunOutputRequest = TextOutputRequest>(request: RunRequest<Output>): Promise<CommandPlan<Output>>
53
+ run<Output extends RunOutputRequest = TextOutputRequest>(request: RunRequest<Output>): Promise<HarnessRun<Output>>
54
+ run<Output extends RunOutputRequest = TextOutputRequest>(plan: CommandPlan<Output>): Promise<HarnessRun<Output>>
55
+ }
56
+
57
+ export type RunRequest<Output extends RunOutputRequest = TextOutputRequest> = {
58
+ prompt: string
59
+ /** Requested working directory before adapter resolution. */
60
+ cwd?: string
61
+ stdin?: string | Uint8Array
62
+ /** Process environment patch requested by the caller for this run. */
63
+ env?: Record<string, string | undefined>
64
+ /** Defaults to text output when omitted. */
65
+ output?: Output
66
+ timeoutMs?: number
67
+ }
68
+
69
+ export type RunOutputRequest = TextOutputRequest | JsonlOutputRequest | StructuredOutputRequest
70
+
71
+ export type TextOutputRequest = { mode?: 'text' }
72
+
73
+ export type JsonlOutputRequest = { mode: 'jsonl' }
74
+
75
+ export type StructuredOutputRequest<Schema extends StandardSchemaV1 = StandardSchemaV1> = {
76
+ mode: 'structured'
77
+ schema: Schema
78
+ }
79
+
80
+ export type CommandPlan<Output extends RunOutputRequest = TextOutputRequest> = {
81
+ harnessId: HarnessId
82
+ /** Executable name or path to spawn; arguments stay in args. */
83
+ command: string
84
+ args: string[]
85
+ /** Resolved working directory used by process.run. */
86
+ cwd: string
87
+ /** Process environment patch: string sets a variable, undefined removes it. */
88
+ env?: Record<string, string | undefined>
89
+ stdin?: string | Uint8Array
90
+ output: Output
91
+ timeoutMs?: number
92
+ }
93
+
94
+ export type HarnessRun<Output extends RunOutputRequest = TextOutputRequest> = {
95
+ plan: CommandPlan<Output>
96
+ stdout: AsyncIterable<Uint8Array>
97
+ stderr: AsyncIterable<Uint8Array>
98
+ events: AsyncIterable<HarnessRunEvent>
99
+ result: Promise<HarnessRunResult<Output>>
100
+ kill(signal?: string): void
101
+ }
102
+
103
+ export type HarnessRunEvent = { type: 'text'; text: string } | { type: 'json'; value: unknown }
104
+
105
+ export type HarnessRunResult<Output extends RunOutputRequest = TextOutputRequest> = {
106
+ exitCode: number | null
107
+ signal?: string
108
+ finalText: string
109
+ } & StructuredRunResult<Output>
110
+
111
+ export type StructuredRunResult<Output extends RunOutputRequest> =
112
+ Output extends StructuredOutputRequest<infer Schema>
113
+ ? { structured: StandardSchemaV1.InferOutput<Schema> }
114
+ : { structured?: never }
115
+
116
+ export type ExtensionFacet = {
117
+ check(extension: HarnessExtension): Promise<ExtensionCheckResult>
118
+ install(extension: HarnessExtension): Promise<ExtensionResult>
119
+ uninstall(extensionId: string): Promise<ExtensionResult>
120
+ }
121
+
122
+ export type HarnessExtension = {
123
+ /** Stable extension id used by adapter-owned install records and uninstall. */
124
+ id: string
125
+ resources: ExtensionResources
126
+ }
127
+
128
+ export type ExtensionResources = {
129
+ /** Filesystem paths to skill files or directories; relative paths resolve from HarnessContext.cwd. */
130
+ skills?: string[]
131
+ hooks?: HookResource[]
132
+ mcpServers?: Record<string, McpServerResource>
133
+ }
134
+
135
+ export type ExtensionResourceKind = keyof ExtensionResources
136
+
137
+ export type McpServerResource = {
138
+ /** Executable name or path for the stdio MCP server; arguments stay in args. */
139
+ command: string
140
+ args?: string[]
141
+ /** Environment variables added for this MCP server process. */
142
+ env?: Record<string, string>
143
+ }
144
+
145
+ export type HookResource = {
146
+ /** Stable hook resource name within this extension. */
147
+ name: string
148
+ /** Native hook event name validated by the adapter. */
149
+ event: string
150
+ /** Command string installed into the target harness native hook config. */
151
+ command: string
152
+ }
153
+
154
+ export type ExtensionResult = {
155
+ success: boolean
156
+ issues: ExtensionIssue[]
157
+ }
158
+
159
+ export type ExtensionCheckResult = {
160
+ compatible: boolean
161
+ issues: ExtensionIssue[]
162
+ }
163
+
164
+ export type ExtensionIssue =
165
+ | { kind: 'unsupported'; resourceKind: ExtensionResourceKind; resourceName?: string; reason: string }
166
+ | { kind: 'conflict'; resourceKind?: ExtensionResourceKind; resourceName?: string; reason: string }