mirab 0.0.0-stage → 0.2.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 +201 -0
- package/README.md +226 -2
- package/package.json +58 -4
- package/src/agent/approved.ts +261 -0
- package/src/agent/define.ts +235 -0
- package/src/agent/guest.ts +138 -0
- package/src/agent/init.ts +150 -0
- package/src/agent/manifest.ts +141 -0
- package/src/agent/modules.ts +23 -0
- package/src/agent/runtime.ts +239 -0
- package/src/agent-profiles.ts +327 -0
- package/src/api/bootstrap-contract.ts +95 -0
- package/src/api/channel-contract.ts +19 -0
- package/src/api/client.ts +100 -0
- package/src/api/contract.ts +2454 -0
- package/src/api/events-contract.ts +103 -0
- package/src/api/import-contract.ts +22 -0
- package/src/api/orchestrator-contract.ts +73 -0
- package/src/api/runner-contract.ts +25 -0
- package/src/api/scopes.ts +41 -0
- package/src/api/sessions-contract.ts +71 -0
- package/src/api/settings-contract.ts +50 -0
- package/src/channel/bridge.ts +97 -0
- package/src/channel/local.ts +154 -0
- package/src/channel/state.ts +11 -0
- package/src/channel/wire.ts +104 -0
- package/src/dev-sync.ts +206 -0
- package/src/dev.ts +453 -0
- package/src/events-tail.ts +75 -0
- package/src/images.ts +498 -0
- package/src/index.ts +21 -0
- package/src/invocation.ts +34 -0
- package/src/isolation/anchored-fs.ts +284 -0
- package/src/isolation/colima.ts +159 -0
- package/src/isolation/container.ts +189 -0
- package/src/isolation/firecracker.ts +414 -0
- package/src/isolation/guest-protocol.json +1 -0
- package/src/isolation/host-user.ts +247 -0
- package/src/isolation/image.ts +336 -0
- package/src/isolation/index.ts +477 -0
- package/src/isolation/multipass.ts +541 -0
- package/src/isolation/network.ts +167 -0
- package/src/isolation/policy.ts +86 -0
- package/src/isolation/session.ts +385 -0
- package/src/isolation/sync.ts +329 -0
- package/src/isolation/vz-network-setup.ts +247 -0
- package/src/isolation/vz.ts +312 -0
- package/src/mirab-main.ts +536 -0
- package/src/mirab.ts +13 -0
- package/src/runner/define.ts +187 -0
- package/src/runner/image.ts +239 -0
- package/src/runner/index.ts +37 -0
- package/src/runner/serve.ts +491 -0
- package/src/runner/toolsets.ts +205 -0
- package/src/spawn-adapters/claude.ts +112 -0
- package/src/spawn-adapters/codex-logs.ts +130 -0
- package/src/spawn-adapters/codex-notify.ts +73 -0
- package/src/spawn-adapters/codex.ts +89 -0
- package/src/spawn-adapters/index.ts +40 -0
- package/src/spawn-adapters/types.ts +55 -0
- package/src/spawn-attach.ts +243 -0
- package/src/spawn-binary.ts +90 -0
- package/src/spawn-bridge.ts +504 -0
- package/src/spawn-bundle.ts +16 -0
- package/src/spawn-detach.ts +301 -0
- package/src/spawn-events.ts +35 -0
- package/src/spawn-hook.ts +86 -0
- package/src/spawn-input.ts +107 -0
- package/src/spawn-native.ts +49 -0
- package/src/spawn-nudge-worker.ts +61 -0
- package/src/spawn-nudge.ts +446 -0
- package/src/spawn-output.ts +45 -0
- package/src/spawn-queue.ts +135 -0
- package/src/spawn-quiet.ts +69 -0
- package/src/spawn-report.ts +114 -0
- package/src/spawn-sessions.ts +166 -0
- package/src/spawn-vm.ts +356 -0
- package/src/spawn-worker.ts +766 -0
- package/src/spawn.ts +468 -0
- package/src/station/config.ts +89 -0
- package/src/station/gateway.ts +94 -0
- package/src/station/vault.ts +96 -0
- package/src/test-cli.ts +90 -0
- package/src/tools.ts +3 -0
- package/types/agent/define.d.ts +76 -0
- package/types/agent/manifest.d.ts +53 -0
- package/types/agent/modules.d.ts +1 -0
- package/types/images.d.ts +124 -0
- package/types/index.d.ts +6 -0
- package/types/isolation/colima.d.ts +37 -0
- package/types/isolation/container.d.ts +25 -0
- package/types/isolation/firecracker.d.ts +55 -0
- package/types/isolation/host-user.d.ts +47 -0
- package/types/isolation/image.d.ts +158 -0
- package/types/isolation/index.d.ts +128 -0
- package/types/isolation/network.d.ts +33 -0
- package/types/isolation/policy.d.ts +29 -0
- package/types/isolation/session.d.ts +99 -0
- package/types/isolation/vz.d.ts +63 -0
- package/types/runner/define.d.ts +101 -0
- package/types/runner/image.d.ts +52 -0
- package/types/runner/index.d.ts +13 -0
- package/types/runner/serve.d.ts +69 -0
- package/types/runner/toolsets.d.ts +35 -0
- package/types/tools.d.ts +2 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { rmSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import {
|
|
5
|
+
event,
|
|
6
|
+
payload,
|
|
7
|
+
shellQuote,
|
|
8
|
+
nativeOptions,
|
|
9
|
+
type AgentAdapter,
|
|
10
|
+
} from './types'
|
|
11
|
+
// A new session sits at its prompt, so it starts idle. Claude's idle
|
|
12
|
+
// notification ("waiting for your input") is also ready for a prompt; every
|
|
13
|
+
// other notification (permission, elicitation) still needs the human.
|
|
14
|
+
const types = {
|
|
15
|
+
SessionStart: 'idle',
|
|
16
|
+
UserPromptSubmit: 'busy',
|
|
17
|
+
PreToolUse: 'tool_start',
|
|
18
|
+
PostToolUse: 'tool_end',
|
|
19
|
+
PostToolUseFailure: 'tool_end',
|
|
20
|
+
Notification: 'waiting',
|
|
21
|
+
PermissionRequest: 'waiting',
|
|
22
|
+
Stop: 'idle',
|
|
23
|
+
} as const
|
|
24
|
+
export const claudeAdapter: AgentAdapter = {
|
|
25
|
+
name: 'claude',
|
|
26
|
+
profileArgs({ model, effort }) {
|
|
27
|
+
if (effort && !['low', 'medium', 'high', 'xhigh', 'max'].includes(effort))
|
|
28
|
+
throw Error('Claude effort must be low, medium, high, xhigh or max')
|
|
29
|
+
return [
|
|
30
|
+
...(model ? ['--model', model] : []),
|
|
31
|
+
...(effort ? ['--effort', effort] : []),
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
async prepare(context, args) {
|
|
35
|
+
// Modes explicitly disabling hooks must retain their native semantics.
|
|
36
|
+
if (nativeOptions(args).some((a) => ['--bare', '--safe-mode'].includes(a)))
|
|
37
|
+
return { args, cleanup() {} }
|
|
38
|
+
const directory = join(context.directory, context.id)
|
|
39
|
+
await mkdir(directory, { mode: 0o700 })
|
|
40
|
+
const file = join(directory, 'settings.json')
|
|
41
|
+
try {
|
|
42
|
+
context.onCleanup?.(() =>
|
|
43
|
+
rmSync(directory, { recursive: true, force: true }),
|
|
44
|
+
)
|
|
45
|
+
const command = [...context.command, 'hook', '--session', context.id]
|
|
46
|
+
.map(shellQuote)
|
|
47
|
+
.join(' ')
|
|
48
|
+
const hooks = Object.fromEntries(
|
|
49
|
+
Object.keys(types).map((name) => [
|
|
50
|
+
name,
|
|
51
|
+
[{ hooks: [{ type: 'command', command, timeout: 1 }] }],
|
|
52
|
+
]),
|
|
53
|
+
)
|
|
54
|
+
await writeFile(file, JSON.stringify({ hooks }), {
|
|
55
|
+
mode: 0o600,
|
|
56
|
+
flag: 'wx',
|
|
57
|
+
})
|
|
58
|
+
// Installed Claude accepts only the last --settings. If the caller has
|
|
59
|
+
// one, additive plugin hooks preserve their entire settings source.
|
|
60
|
+
const extra: string[] = []
|
|
61
|
+
if (
|
|
62
|
+
nativeOptions(args).some(
|
|
63
|
+
(a) => a === '--settings' || a.startsWith('--settings='),
|
|
64
|
+
)
|
|
65
|
+
) {
|
|
66
|
+
await mkdir(join(directory, '.claude-plugin'), { mode: 0o700 })
|
|
67
|
+
await mkdir(join(directory, 'hooks'), { mode: 0o700 })
|
|
68
|
+
await writeFile(
|
|
69
|
+
join(directory, '.claude-plugin', 'plugin.json'),
|
|
70
|
+
JSON.stringify({ name: 'mirab-session-observer', version: '1.0.0' }),
|
|
71
|
+
{ mode: 0o600 },
|
|
72
|
+
)
|
|
73
|
+
await writeFile(
|
|
74
|
+
join(directory, 'hooks', 'hooks.json'),
|
|
75
|
+
JSON.stringify({ hooks }),
|
|
76
|
+
{ mode: 0o600 },
|
|
77
|
+
)
|
|
78
|
+
extra.push('--plugin-dir', directory)
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
args: ['--settings', file, ...extra, ...args],
|
|
82
|
+
cleanup: () => rmSync(directory, { recursive: true, force: true }),
|
|
83
|
+
}
|
|
84
|
+
} catch (error) {
|
|
85
|
+
rmSync(directory, { recursive: true, force: true })
|
|
86
|
+
throw error
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
translate(session, raw) {
|
|
90
|
+
const p = payload(raw)
|
|
91
|
+
if (
|
|
92
|
+
!p ||
|
|
93
|
+
typeof p.hook_event_name !== 'string' ||
|
|
94
|
+
typeof p.agent_id === 'string'
|
|
95
|
+
)
|
|
96
|
+
return null
|
|
97
|
+
const type =
|
|
98
|
+
p.hook_event_name === 'Notification' &&
|
|
99
|
+
p.notification_type === 'idle_prompt'
|
|
100
|
+
? 'idle'
|
|
101
|
+
: types[p.hook_event_name as keyof typeof types]
|
|
102
|
+
return type
|
|
103
|
+
? event(
|
|
104
|
+
session,
|
|
105
|
+
'claude',
|
|
106
|
+
type,
|
|
107
|
+
raw,
|
|
108
|
+
typeof p.tool_name === 'string' ? p.tool_name : undefined,
|
|
109
|
+
)
|
|
110
|
+
: null
|
|
111
|
+
},
|
|
112
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { open, readdir, stat } from 'node:fs/promises'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
|
+
import type { AgentEvent } from '../spawn-events'
|
|
4
|
+
import { event, payload } from './types'
|
|
5
|
+
/** Best effort, bound only by a notify-proven thread ID; never guess by cwd. */
|
|
6
|
+
export class CodexLogs {
|
|
7
|
+
private path?: string
|
|
8
|
+
private offset = 0
|
|
9
|
+
private partial = ''
|
|
10
|
+
private tools = new Map<string, string>()
|
|
11
|
+
private timer?: ReturnType<typeof setInterval>
|
|
12
|
+
private reading = false
|
|
13
|
+
private closed = false
|
|
14
|
+
private bound = false
|
|
15
|
+
private notBefore = 0
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly root: string,
|
|
18
|
+
private readonly session: string,
|
|
19
|
+
private readonly emit: (event: AgentEvent) => void,
|
|
20
|
+
) {}
|
|
21
|
+
async bind(raw: unknown) {
|
|
22
|
+
this.notBefore = Date.now()
|
|
23
|
+
this.tools.clear()
|
|
24
|
+
const p = payload(raw),
|
|
25
|
+
id = p?.['thread-id']
|
|
26
|
+
if (
|
|
27
|
+
this.bound ||
|
|
28
|
+
typeof id !== 'string' ||
|
|
29
|
+
!/^[a-zA-Z0-9_-]{1,100}$/.test(id)
|
|
30
|
+
)
|
|
31
|
+
return
|
|
32
|
+
this.bound = true
|
|
33
|
+
try {
|
|
34
|
+
const entries = await readdir(this.root, { recursive: true })
|
|
35
|
+
const match = entries.find(
|
|
36
|
+
(path) =>
|
|
37
|
+
basename(path) === id + '.jsonl' ||
|
|
38
|
+
basename(path).endsWith('-' + id + '.jsonl'),
|
|
39
|
+
)
|
|
40
|
+
if (!match || this.closed) return
|
|
41
|
+
this.path = join(this.root, match)
|
|
42
|
+
// Old history cannot change the live state after a notify event.
|
|
43
|
+
this.offset = (await stat(this.path)).size
|
|
44
|
+
if (!this.closed)
|
|
45
|
+
this.timer = setInterval(() => {
|
|
46
|
+
void this.poll()
|
|
47
|
+
}, 250)
|
|
48
|
+
} catch {
|
|
49
|
+
/* Missing/paginated logs do not disable notify. */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async poll() {
|
|
53
|
+
if (this.reading || this.closed || !this.path) return
|
|
54
|
+
this.reading = true
|
|
55
|
+
let file: Awaited<ReturnType<typeof open>> | undefined
|
|
56
|
+
try {
|
|
57
|
+
file = await open(this.path, 'r')
|
|
58
|
+
const size = (await file.stat()).size
|
|
59
|
+
if (size < this.offset) {
|
|
60
|
+
this.offset = size
|
|
61
|
+
this.partial = ''
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
if (size === this.offset) return
|
|
65
|
+
if (size - this.offset > 1024 * 1024) {
|
|
66
|
+
this.offset = size
|
|
67
|
+
this.partial = ''
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
const buffer = Buffer.alloc(size - this.offset)
|
|
71
|
+
const { bytesRead } = await file.read(
|
|
72
|
+
buffer,
|
|
73
|
+
0,
|
|
74
|
+
buffer.length,
|
|
75
|
+
this.offset,
|
|
76
|
+
)
|
|
77
|
+
this.offset += bytesRead
|
|
78
|
+
const lines = (
|
|
79
|
+
this.partial + buffer.subarray(0, bytesRead).toString('utf8')
|
|
80
|
+
).split('\n')
|
|
81
|
+
this.partial = lines.pop() ?? ''
|
|
82
|
+
if (this.partial.length > 65536) this.partial = ''
|
|
83
|
+
for (const line of lines) {
|
|
84
|
+
if (line.length > 65536 || this.closed) continue
|
|
85
|
+
try {
|
|
86
|
+
const raw = JSON.parse(line),
|
|
87
|
+
p = payload(raw.payload)
|
|
88
|
+
const timestamp =
|
|
89
|
+
typeof raw.timestamp === 'string' ? Date.parse(raw.timestamp) : NaN
|
|
90
|
+
if (!p || !Number.isFinite(timestamp) || timestamp <= this.notBefore)
|
|
91
|
+
continue
|
|
92
|
+
if (raw.type === 'event_msg' && p.type === 'task_started')
|
|
93
|
+
this.emit(event(this.session, 'codex', 'busy', raw))
|
|
94
|
+
if (raw.type !== 'response_item') continue
|
|
95
|
+
if (
|
|
96
|
+
['function_call', 'custom_tool_call'].includes(String(p.type)) &&
|
|
97
|
+
typeof p.name === 'string' &&
|
|
98
|
+
typeof p.call_id === 'string'
|
|
99
|
+
) {
|
|
100
|
+
if (this.tools.size >= 1000) this.tools.clear()
|
|
101
|
+
this.tools.set(p.call_id, p.name)
|
|
102
|
+
this.emit(event(this.session, 'codex', 'tool_start', raw, p.name))
|
|
103
|
+
} else if (
|
|
104
|
+
['function_call_output', 'custom_tool_call_output'].includes(
|
|
105
|
+
String(p.type),
|
|
106
|
+
) &&
|
|
107
|
+
typeof p.call_id === 'string'
|
|
108
|
+
) {
|
|
109
|
+
const tool = this.tools.get(p.call_id)
|
|
110
|
+
if (tool) {
|
|
111
|
+
this.tools.delete(p.call_id)
|
|
112
|
+
this.emit(event(this.session, 'codex', 'tool_end', raw, tool))
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Completion comes only from notify; delayed log records cannot unlock injection.
|
|
116
|
+
} catch {
|
|
117
|
+
/* Internal JSONL shapes are not a protocol. */
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
} finally {
|
|
122
|
+
await file?.close().catch(() => {})
|
|
123
|
+
this.reading = false
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
close() {
|
|
127
|
+
this.closed = true
|
|
128
|
+
clearInterval(this.timer)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { parse } from 'smol-toml'
|
|
5
|
+
|
|
6
|
+
function notifyCommand(value: unknown): string[] {
|
|
7
|
+
if (value === undefined) return []
|
|
8
|
+
if (!Array.isArray(value) || !value.every((arg) => typeof arg === 'string'))
|
|
9
|
+
throw new Error('Codex notify must be an array of strings')
|
|
10
|
+
return value
|
|
11
|
+
}
|
|
12
|
+
async function config(path: string) {
|
|
13
|
+
try {
|
|
14
|
+
return parse(await readFile(path, 'utf8'))
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
17
|
+
return {}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Resolve notification layers only; project config cannot set notify. */
|
|
21
|
+
export async function originalNotify(
|
|
22
|
+
args: string[],
|
|
23
|
+
environment = process.env,
|
|
24
|
+
) {
|
|
25
|
+
const home =
|
|
26
|
+
environment.CODEX_HOME ?? join(environment.HOME ?? homedir(), '.codex')
|
|
27
|
+
const system = await config('/etc/codex/config.toml')
|
|
28
|
+
const user = await config(join(home, 'config.toml'))
|
|
29
|
+
let command = notifyCommand(user.notify ?? system.notify)
|
|
30
|
+
let profile = user.profile ?? system.profile
|
|
31
|
+
let explicitProfile: string | undefined
|
|
32
|
+
let override: string[] | undefined
|
|
33
|
+
const remaining: string[] = []
|
|
34
|
+
for (let i = 0; i < args.length; i++) {
|
|
35
|
+
const arg = args[i]
|
|
36
|
+
if (arg === '--') {
|
|
37
|
+
remaining.push(...args.slice(i))
|
|
38
|
+
break
|
|
39
|
+
}
|
|
40
|
+
if (arg === '-p' || arg === '--profile') {
|
|
41
|
+
explicitProfile = args[i + 1]
|
|
42
|
+
remaining.push(arg, args[++i])
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
if (arg.startsWith('--profile=')) explicitProfile = arg.slice(10)
|
|
46
|
+
else if (arg.startsWith('-p') && arg.length > 2)
|
|
47
|
+
explicitProfile = arg.slice(2)
|
|
48
|
+
const separate = arg === '-c' || arg === '--config'
|
|
49
|
+
const value = separate
|
|
50
|
+
? args[i + 1]
|
|
51
|
+
: arg.startsWith('--config=')
|
|
52
|
+
? arg.slice(9)
|
|
53
|
+
: arg.startsWith('-c')
|
|
54
|
+
? arg.slice(2)
|
|
55
|
+
: undefined
|
|
56
|
+
if (value && /^\s*notify\s*=/.test(value)) {
|
|
57
|
+
override = notifyCommand(parse(value).notify)
|
|
58
|
+
if (separate) i++
|
|
59
|
+
} else {
|
|
60
|
+
if (value && /^\s*profile\s*=/.test(value)) profile = parse(value).profile
|
|
61
|
+
remaining.push(arg)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
profile = explicitProfile ?? profile
|
|
65
|
+
if (profile !== undefined) {
|
|
66
|
+
if (typeof profile !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(profile))
|
|
67
|
+
throw new Error('Invalid Codex profile name')
|
|
68
|
+
const selected = await config(join(home, `${profile}.config.toml`))
|
|
69
|
+
if (selected.notify !== undefined) command = notifyCommand(selected.notify)
|
|
70
|
+
}
|
|
71
|
+
if (override !== undefined) command = override
|
|
72
|
+
return { command, args: remaining }
|
|
73
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { originalNotify } from './codex-notify'
|
|
4
|
+
import { CodexLogs } from './codex-logs'
|
|
5
|
+
import { event, payload, type AgentAdapter } from './types'
|
|
6
|
+
/** Codex settings every mirab session needs, because mirab types its prompts.
|
|
7
|
+
* - The startup "Update available" menu would swallow the first prompt (its
|
|
8
|
+
* Enter picks "Update now").
|
|
9
|
+
* - Paste-burst detection treats typed prompt text as a paste and the Enter
|
|
10
|
+
* that follows it as part of the paste, so the prompt sat in the composer
|
|
11
|
+
* unsent: 0 of 3 fresh sessions answered on 10 Oct, 3 of 3 with it off.
|
|
12
|
+
* Profile args come later on the command line, so a profile can still turn
|
|
13
|
+
* either back on. */
|
|
14
|
+
export const SESSION_CONFIG = [
|
|
15
|
+
'-c',
|
|
16
|
+
'check_for_update_on_startup=false',
|
|
17
|
+
'-c',
|
|
18
|
+
'tui.disable_paste_burst=true',
|
|
19
|
+
]
|
|
20
|
+
export const codexAdapter: AgentAdapter = {
|
|
21
|
+
name: 'codex',
|
|
22
|
+
profileArgs({ model, effort }) {
|
|
23
|
+
return [
|
|
24
|
+
...(model ? ['-m', model] : []),
|
|
25
|
+
...(effort
|
|
26
|
+
? ['-c', `model_reasoning_effort=${JSON.stringify(effort)}`]
|
|
27
|
+
: []),
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
async prepare(context, args) {
|
|
31
|
+
const environment = context.environment ?? process.env
|
|
32
|
+
const original = await originalNotify(args, environment)
|
|
33
|
+
const command = [
|
|
34
|
+
...context.command,
|
|
35
|
+
'hook',
|
|
36
|
+
'--session',
|
|
37
|
+
context.id,
|
|
38
|
+
'--agent',
|
|
39
|
+
'codex',
|
|
40
|
+
...(original.command.length
|
|
41
|
+
? ['--notify-command', JSON.stringify(original.command)]
|
|
42
|
+
: []),
|
|
43
|
+
]
|
|
44
|
+
const logs = context.emit
|
|
45
|
+
? new CodexLogs(
|
|
46
|
+
join(
|
|
47
|
+
environment.CODEX_HOME ??
|
|
48
|
+
join(environment.HOME ?? homedir(), '.codex'),
|
|
49
|
+
'sessions',
|
|
50
|
+
),
|
|
51
|
+
context.id,
|
|
52
|
+
context.emit,
|
|
53
|
+
)
|
|
54
|
+
: undefined
|
|
55
|
+
context.onCleanup?.(() => logs?.close())
|
|
56
|
+
// JSON array syntax is also a TOML array of quoted strings.
|
|
57
|
+
return {
|
|
58
|
+
args: [
|
|
59
|
+
'-c',
|
|
60
|
+
`notify=${JSON.stringify(command)}`,
|
|
61
|
+
...SESSION_CONFIG,
|
|
62
|
+
...original.args,
|
|
63
|
+
],
|
|
64
|
+
cleanup: () => logs?.close(),
|
|
65
|
+
receive: (raw) => {
|
|
66
|
+
void logs?.bind(raw)
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
// Codex's notify fires only after a turn, and its SessionStart hook only on
|
|
71
|
+
// the first prompt, so nothing says a fresh TUI is ready; without this a
|
|
72
|
+
// mention to a new Codex session waits forever as "agent not idle". Codex
|
|
73
|
+
// draws its composer first and its trust dialog up to ~3 s later (10 Oct),
|
|
74
|
+
// so a fresh screen must stay quiet for 5 s. A busy Codex redraws its
|
|
75
|
+
// "Working (Ns)" timer every second, so 5 s of silence after an Enter means
|
|
76
|
+
// no turn is running. Its startup dialogs (trust, update) are for a person,
|
|
77
|
+
// never for a channel message.
|
|
78
|
+
quiet: {
|
|
79
|
+
startupMs: 5000,
|
|
80
|
+
busyMs: 5000,
|
|
81
|
+
dialogs: /Trustthisfolder|Updateavailable|Updatenow/i,
|
|
82
|
+
},
|
|
83
|
+
translate(session, raw) {
|
|
84
|
+
const p = payload(raw)
|
|
85
|
+
return p?.type === 'agent-turn-complete'
|
|
86
|
+
? event(session, 'codex', 'idle', raw)
|
|
87
|
+
: null
|
|
88
|
+
},
|
|
89
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
2
|
+
import { claudeAdapter } from './claude'
|
|
3
|
+
import { codexAdapter } from './codex'
|
|
4
|
+
import {
|
|
5
|
+
nativeOptions,
|
|
6
|
+
type AgentAdapter,
|
|
7
|
+
type RuntimeSettings,
|
|
8
|
+
type RuntimeFlags,
|
|
9
|
+
} from './types'
|
|
10
|
+
const adapters: AgentAdapter[] = [claudeAdapter, codexAdapter]
|
|
11
|
+
export function adapterFor(
|
|
12
|
+
agent: string,
|
|
13
|
+
args: string[],
|
|
14
|
+
): AgentAdapter | undefined {
|
|
15
|
+
if (
|
|
16
|
+
basename(agent) === 'claude' &&
|
|
17
|
+
nativeOptions(args).some((a) => ['--bare', '--safe-mode'].includes(a))
|
|
18
|
+
)
|
|
19
|
+
return undefined
|
|
20
|
+
return adapters.find((adapter) => adapter.name === basename(agent))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Kimi has no structured event observer yet. Its verified flags share the
|
|
24
|
+
// runtime registry; absent effort support is an error, never a guessed flag.
|
|
25
|
+
const runtimeFlags = new Map<string, RuntimeFlags>(
|
|
26
|
+
adapters.map((a) => [a.name, a.profileArgs]),
|
|
27
|
+
)
|
|
28
|
+
runtimeFlags.set('kimi', ({ model, effort }) => {
|
|
29
|
+
if (effort)
|
|
30
|
+
throw Error(
|
|
31
|
+
'Kimi --help exposes no effort flag; omit effort and use native args',
|
|
32
|
+
)
|
|
33
|
+
return model ? ['--model', model] : []
|
|
34
|
+
})
|
|
35
|
+
export function profileArguments(
|
|
36
|
+
runtime: string,
|
|
37
|
+
settings: RuntimeSettings,
|
|
38
|
+
): string[] {
|
|
39
|
+
return runtimeFlags.get(basename(runtime))?.(settings) ?? []
|
|
40
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { AgentEvent } from '../spawn-events'
|
|
2
|
+
export type AdapterContext = {
|
|
3
|
+
id: string
|
|
4
|
+
directory: string
|
|
5
|
+
command: string[]
|
|
6
|
+
environment?: NodeJS.ProcessEnv
|
|
7
|
+
emit?: (event: AgentEvent) => void
|
|
8
|
+
onCleanup?: (cleanup: () => void) => void
|
|
9
|
+
}
|
|
10
|
+
export type RuntimeSettings = { model?: string; effort?: string }
|
|
11
|
+
export type RuntimeFlags = (settings: RuntimeSettings) => string[]
|
|
12
|
+
export type AgentAdapter = {
|
|
13
|
+
profileArgs: RuntimeFlags
|
|
14
|
+
name: string
|
|
15
|
+
prepare(
|
|
16
|
+
context: AdapterContext,
|
|
17
|
+
args: string[],
|
|
18
|
+
): Promise<{
|
|
19
|
+
args: string[]
|
|
20
|
+
cleanup(): void
|
|
21
|
+
receive?: (raw: unknown) => void
|
|
22
|
+
}>
|
|
23
|
+
translate(session: string, raw: unknown): AgentEvent | null
|
|
24
|
+
/** For a runtime that reports only turn ends: terminal-quiet readiness
|
|
25
|
+
* (see QuietIdle). `dialogs` matches screen text with whitespace removed. */
|
|
26
|
+
quiet?: { startupMs: number; busyMs: number; dialogs?: RegExp }
|
|
27
|
+
}
|
|
28
|
+
export function event(
|
|
29
|
+
session: string,
|
|
30
|
+
agent: string,
|
|
31
|
+
type: AgentEvent['type'],
|
|
32
|
+
raw: unknown,
|
|
33
|
+
tool?: string,
|
|
34
|
+
): AgentEvent {
|
|
35
|
+
return {
|
|
36
|
+
session,
|
|
37
|
+
agent,
|
|
38
|
+
type,
|
|
39
|
+
...(tool ? { tool } : {}),
|
|
40
|
+
ts: new Date().toISOString(),
|
|
41
|
+
raw,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function payload(raw: unknown): Record<string, unknown> | null {
|
|
45
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw)
|
|
46
|
+
? (raw as Record<string, unknown>)
|
|
47
|
+
: null
|
|
48
|
+
}
|
|
49
|
+
export const shellQuote = (value: string) =>
|
|
50
|
+
"'" + value.replaceAll("'", "'\\''") + "'"
|
|
51
|
+
|
|
52
|
+
export function nativeOptions(args: string[]) {
|
|
53
|
+
const separator = args.indexOf('--')
|
|
54
|
+
return separator < 0 ? args : args.slice(0, separator)
|
|
55
|
+
}
|