typeclaw 0.3.1 → 0.5.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/README.md +20 -15
- package/auth.schema.json +113 -0
- package/package.json +1 -1
- package/secrets.schema.json +113 -0
- package/src/agent/auth.ts +4 -2
- package/src/agent/index.ts +16 -28
- package/src/agent/model-fallback.ts +127 -0
- package/src/agent/session-meta.ts +1 -1
- package/src/agent/session-origin.ts +3 -2
- package/src/agent/tools/curl-impersonate.ts +300 -0
- package/src/agent/tools/ddg.ts +13 -88
- package/src/agent/tools/webfetch/fetch.ts +105 -2
- package/src/agent/tools/webfetch/tool.ts +4 -0
- package/src/bundled-plugins/agent-browser/shim.ts +47 -0
- package/src/bundled-plugins/backup/subagents.ts +2 -0
- package/src/bundled-plugins/memory/README.md +49 -12
- package/src/bundled-plugins/memory/citation-superset.ts +63 -0
- package/src/bundled-plugins/memory/dreaming.ts +105 -17
- package/src/bundled-plugins/memory/index.ts +2 -2
- package/src/bundled-plugins/memory/memory-logger.ts +45 -26
- package/src/bundled-plugins/memory/strength.ts +127 -0
- package/src/bundled-plugins/memory/topics.ts +75 -0
- package/src/bundled-plugins/security/index.ts +88 -43
- package/src/bundled-plugins/security/permissions.ts +36 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
- package/src/channels/adapters/github/auth-app.ts +120 -0
- package/src/channels/adapters/github/auth-pat.ts +50 -0
- package/src/channels/adapters/github/auth.ts +33 -0
- package/src/channels/adapters/github/channel-resolver.ts +30 -0
- package/src/channels/adapters/github/dedup.ts +26 -0
- package/src/channels/adapters/github/event-allowlist.ts +8 -0
- package/src/channels/adapters/github/fetch-attachment.ts +5 -0
- package/src/channels/adapters/github/history.ts +63 -0
- package/src/channels/adapters/github/inbound.ts +286 -0
- package/src/channels/adapters/github/index.ts +370 -0
- package/src/channels/adapters/github/managed-path.ts +54 -0
- package/src/channels/adapters/github/membership.ts +35 -0
- package/src/channels/adapters/github/outbound.ts +145 -0
- package/src/channels/adapters/github/webhook-register.ts +349 -0
- package/src/channels/manager.ts +94 -9
- package/src/channels/router.ts +194 -28
- package/src/channels/schema.ts +31 -1
- package/src/channels/tunnel-bridge.ts +51 -0
- package/src/channels/types.ts +3 -1
- package/src/cli/builtins.ts +28 -0
- package/src/cli/channel.ts +511 -25
- package/src/cli/container-command-client.ts +244 -0
- package/src/cli/cron.ts +173 -0
- package/src/cli/host-command-runner.ts +150 -0
- package/src/cli/index.ts +42 -1
- package/src/cli/init.ts +400 -67
- package/src/cli/model.ts +14 -4
- package/src/cli/oauth-callbacks.ts +49 -0
- package/src/cli/plugin-command-help.ts +49 -0
- package/src/cli/plugin-commands-dispatch.ts +112 -0
- package/src/cli/plugin-commands.ts +118 -0
- package/src/cli/provider.ts +3 -20
- package/src/cli/tui.ts +10 -2
- package/src/cli/tunnel.ts +533 -0
- package/src/cli/ui.ts +8 -3
- package/src/config/config.ts +134 -24
- package/src/config/models-mutation.ts +42 -8
- package/src/config/providers-mutation.ts +12 -8
- package/src/container/start.ts +48 -4
- package/src/cron/bridge.ts +136 -0
- package/src/cron/consumer.ts +174 -48
- package/src/cron/index.ts +19 -2
- package/src/cron/list.ts +105 -0
- package/src/cron/scheduler.ts +12 -3
- package/src/cron/schema.ts +11 -3
- package/src/doctor/checks.ts +0 -50
- package/src/init/dockerfile.ts +165 -13
- package/src/init/ensure-deps.ts +15 -4
- package/src/init/github-webhook-install.ts +109 -0
- package/src/init/hatching.ts +2 -2
- package/src/init/index.ts +519 -12
- package/src/init/oauth-login.ts +17 -3
- package/src/init/run-bun-install.ts +17 -3
- package/src/init/run-owner-claim.ts +11 -2
- package/src/permissions/builtins.ts +29 -2
- package/src/permissions/match-rule.ts +24 -2
- package/src/permissions/permissions.ts +24 -7
- package/src/permissions/resolve.ts +1 -0
- package/src/plugin/define.ts +44 -1
- package/src/plugin/index.ts +18 -3
- package/src/plugin/manager.ts +16 -0
- package/src/plugin/registry.ts +85 -3
- package/src/plugin/types.ts +144 -1
- package/src/plugin/zod-introspect.ts +100 -0
- package/src/role-claim/match-rule.ts +2 -1
- package/src/run/index.ts +112 -4
- package/src/secrets/index.ts +1 -1
- package/src/secrets/schema.ts +21 -0
- package/src/server/command-runner.ts +476 -0
- package/src/server/index.ts +388 -5
- package/src/shared/index.ts +8 -0
- package/src/shared/protocol.ts +80 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
- package/src/skills/typeclaw-config/SKILL.md +27 -26
- package/src/skills/typeclaw-cron/SKILL.md +234 -3
- package/src/skills/typeclaw-memory/SKILL.md +25 -15
- package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
- package/src/skills/typeclaw-permissions/SKILL.md +35 -16
- package/src/skills/typeclaw-plugins/SKILL.md +251 -5
- package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
- package/src/test-helpers/wait-for.ts +50 -0
- package/src/tui/index.ts +70 -7
- package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
- package/src/tunnels/events.ts +14 -0
- package/src/tunnels/index.ts +12 -0
- package/src/tunnels/log-ring.ts +54 -0
- package/src/tunnels/manager.ts +139 -0
- package/src/tunnels/providers/cloudflare-quick.ts +189 -0
- package/src/tunnels/providers/external.ts +53 -0
- package/src/tunnels/quick-url-parser.ts +5 -0
- package/src/tunnels/types.ts +43 -0
- package/src/usage/report.ts +15 -12
- package/typeclaw.schema.json +311 -26
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
import { createSessionWithDispose, type SessionOrigin } from '@/agent'
|
|
2
|
+
import type { PermissionService } from '@/permissions'
|
|
3
|
+
import type {
|
|
4
|
+
CommandExecResult,
|
|
5
|
+
ContainerCommand,
|
|
6
|
+
ContainerCommandContext,
|
|
7
|
+
EitherCommand,
|
|
8
|
+
EitherCommandContext,
|
|
9
|
+
PluginLogger,
|
|
10
|
+
RegisteredCommand,
|
|
11
|
+
SpawnSubagentOptions,
|
|
12
|
+
} from '@/plugin'
|
|
13
|
+
import type { PluginRuntime } from '@/run/plugin-runtime'
|
|
14
|
+
|
|
15
|
+
export type CommandSpawnSubagent = (name: string, payload?: unknown, options?: SpawnSubagentOptions) => Promise<void>
|
|
16
|
+
|
|
17
|
+
export type CommandOutbound = {
|
|
18
|
+
stdout: (callId: string, chunk: Uint8Array) => void
|
|
19
|
+
stderr: (callId: string, chunk: Uint8Array) => void
|
|
20
|
+
exit: (callId: string, code: number) => void
|
|
21
|
+
error: (callId: string, message: string) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type CommandRunnerOptions = {
|
|
25
|
+
pluginRuntime: PluginRuntime
|
|
26
|
+
permissions: PermissionService
|
|
27
|
+
spawnSubagent: CommandSpawnSubagent
|
|
28
|
+
agentDir: string
|
|
29
|
+
runtimeVersion: string | undefined
|
|
30
|
+
containerName: string | undefined
|
|
31
|
+
outbound: CommandOutbound
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type CommandHandle = {
|
|
35
|
+
callId: string
|
|
36
|
+
abortController: AbortController
|
|
37
|
+
stdinQueue: StdinQueue
|
|
38
|
+
ownerKey: WsOwnerKey
|
|
39
|
+
done: Promise<void>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type WsOwnerKey = object | null
|
|
43
|
+
|
|
44
|
+
export type CommandRunner = {
|
|
45
|
+
start: (
|
|
46
|
+
msg: {
|
|
47
|
+
callId: string
|
|
48
|
+
name: string
|
|
49
|
+
args: unknown
|
|
50
|
+
isolated?: boolean
|
|
51
|
+
parentOrigin?: SessionOrigin
|
|
52
|
+
},
|
|
53
|
+
ownerKey: WsOwnerKey,
|
|
54
|
+
) => void
|
|
55
|
+
feedStdin: (callId: string, chunkBase64: string) => void
|
|
56
|
+
endStdin: (callId: string) => void
|
|
57
|
+
abort: (callId: string, reason: string) => void
|
|
58
|
+
abortForOwner: (ownerKey: WsOwnerKey) => void
|
|
59
|
+
inFlightCount: () => number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createCommandRunner(opts: CommandRunnerOptions): CommandRunner {
|
|
63
|
+
const inFlight = new Map<string, CommandHandle>()
|
|
64
|
+
|
|
65
|
+
function lookup(name: string): RegisteredCommand | undefined {
|
|
66
|
+
const snapshot = opts.pluginRuntime.get()
|
|
67
|
+
return snapshot.registry.commands.find((c) => c.commandName === name)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function start(
|
|
71
|
+
msg: { callId: string; name: string; args: unknown; isolated?: boolean; parentOrigin?: SessionOrigin },
|
|
72
|
+
ownerKey: WsOwnerKey,
|
|
73
|
+
): void {
|
|
74
|
+
const { callId, name, args } = msg
|
|
75
|
+
if (inFlight.has(callId)) {
|
|
76
|
+
opts.outbound.error(callId, `callId "${callId}" is already in flight`)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const registered = lookup(name)
|
|
81
|
+
if (registered === undefined) {
|
|
82
|
+
opts.outbound.error(callId, `command "${name}" is not registered`)
|
|
83
|
+
opts.outbound.exit(callId, 1)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const command = registered.command
|
|
88
|
+
if (command.surface === 'host') {
|
|
89
|
+
opts.outbound.error(callId, `command "${name}" is host-only; cannot run inside the container`)
|
|
90
|
+
opts.outbound.exit(callId, 1)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const argsParse = parseArgs(command, args)
|
|
95
|
+
if (!argsParse.ok) {
|
|
96
|
+
opts.outbound.error(callId, argsParse.message)
|
|
97
|
+
opts.outbound.exit(callId, 2)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const abortController = new AbortController()
|
|
102
|
+
const stdinQueue = createStdinQueue(abortController.signal)
|
|
103
|
+
|
|
104
|
+
// Subagent-shaped (NOT TUI) so the prompt session this command may spawn
|
|
105
|
+
// via ctx.prompt resolves to the `slim` system prompt mode, saving ~2000
|
|
106
|
+
// tokens per LLM call.
|
|
107
|
+
//
|
|
108
|
+
// spawnedByOrigin carries the caller's provenance. By default we stamp a
|
|
109
|
+
// synthetic TUI origin (host CLI operator → owner role). When the caller
|
|
110
|
+
// forwarded a parentOrigin (e.g. a cron exec job reading
|
|
111
|
+
// TYPECLAW_PARENT_ORIGIN_JSON), we use that verbatim so permission
|
|
112
|
+
// resolution chases through to the cron's scheduledByRole instead of
|
|
113
|
+
// silently elevating to owner.
|
|
114
|
+
const syntheticTuiOrigin: SessionOrigin = { kind: 'tui', sessionId: `command:${name}:${callId}` }
|
|
115
|
+
const spawnedByOrigin: SessionOrigin = msg.parentOrigin ?? syntheticTuiOrigin
|
|
116
|
+
const origin: SessionOrigin = {
|
|
117
|
+
kind: 'subagent',
|
|
118
|
+
subagent: `plugin-command:${name}`,
|
|
119
|
+
parentSessionId: syntheticTuiOrigin.sessionId,
|
|
120
|
+
spawnedByOrigin,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const stdoutSink = makeWritable((chunk) => opts.outbound.stdout(callId, chunk))
|
|
124
|
+
const stderrSink = makeWritable((chunk) => opts.outbound.stderr(callId, chunk))
|
|
125
|
+
|
|
126
|
+
const logger: PluginLogger = {
|
|
127
|
+
info: (m) => writeLine(stderrSink, `[command:${registered.pluginName}] info: ${m}`),
|
|
128
|
+
warn: (m) => writeLine(stderrSink, `[command:${registered.pluginName}] warn: ${m}`),
|
|
129
|
+
error: (m) => writeLine(stderrSink, `[command:${registered.pluginName}] error: ${m}`),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Emit the isolated-fallback warning through the per-command stderr
|
|
133
|
+
// stream so the invoking CLI sees it. The plugin's boot-time logger
|
|
134
|
+
// (registered.logger) writes to container logs which the caller never
|
|
135
|
+
// reads.
|
|
136
|
+
if (msg.isolated === true) {
|
|
137
|
+
logger.warn(
|
|
138
|
+
`command "${name}" requested isolated=true; this build does not yet implement subprocess isolation, falling back to in-process`,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const sharedCtx = {
|
|
143
|
+
name: registered.pluginName,
|
|
144
|
+
version: registered.command.surface === 'container' ? undefined : undefined,
|
|
145
|
+
agentDir: opts.agentDir,
|
|
146
|
+
logger,
|
|
147
|
+
signal: abortController.signal,
|
|
148
|
+
stdin: stdinQueue.readable,
|
|
149
|
+
stdout: stdoutSink,
|
|
150
|
+
stderr: stderrSink,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const ctxPromise = (async (): Promise<number> => {
|
|
154
|
+
if (command.surface === 'container') {
|
|
155
|
+
const ctx: ContainerCommandContext = {
|
|
156
|
+
...sharedCtx,
|
|
157
|
+
permissions: opts.permissions,
|
|
158
|
+
origin,
|
|
159
|
+
prompt: (text) =>
|
|
160
|
+
runPromptForCommand({
|
|
161
|
+
text,
|
|
162
|
+
origin,
|
|
163
|
+
runtime: opts.pluginRuntime,
|
|
164
|
+
agentDir: opts.agentDir,
|
|
165
|
+
runtimeVersion: opts.runtimeVersion,
|
|
166
|
+
containerName: opts.containerName,
|
|
167
|
+
permissions: opts.permissions,
|
|
168
|
+
signal: abortController.signal,
|
|
169
|
+
}),
|
|
170
|
+
subagent: (subName, payload) =>
|
|
171
|
+
opts.spawnSubagent(subName, payload, {
|
|
172
|
+
spawnedByOrigin: origin,
|
|
173
|
+
parentSessionId: syntheticTuiOrigin.sessionId,
|
|
174
|
+
}),
|
|
175
|
+
exec: (strings, ...values) =>
|
|
176
|
+
runExecForCommand(strings, values, { cwd: opts.agentDir, signal: abortController.signal }),
|
|
177
|
+
}
|
|
178
|
+
return (command as ContainerCommand<unknown>).run(ctx, argsParse.value)
|
|
179
|
+
}
|
|
180
|
+
const ctx: EitherCommandContext = sharedCtx
|
|
181
|
+
return (command as EitherCommand<unknown>).run(ctx, argsParse.value)
|
|
182
|
+
})()
|
|
183
|
+
|
|
184
|
+
const done = ctxPromise
|
|
185
|
+
.then((code) => {
|
|
186
|
+
if (typeof code !== 'number' || !Number.isFinite(code)) {
|
|
187
|
+
opts.outbound.error(callId, `command "${name}" returned a non-numeric exit code`)
|
|
188
|
+
opts.outbound.exit(callId, 1)
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
opts.outbound.exit(callId, code)
|
|
192
|
+
})
|
|
193
|
+
.catch((err: unknown) => {
|
|
194
|
+
const detail = err instanceof Error ? err.message : String(err)
|
|
195
|
+
opts.outbound.error(callId, detail)
|
|
196
|
+
opts.outbound.exit(callId, 1)
|
|
197
|
+
})
|
|
198
|
+
.finally(() => {
|
|
199
|
+
inFlight.delete(callId)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
inFlight.set(callId, { callId, abortController, stdinQueue, ownerKey, done })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function feedStdin(callId: string, chunkBase64: string): void {
|
|
206
|
+
const handle = inFlight.get(callId)
|
|
207
|
+
if (handle === undefined) return
|
|
208
|
+
try {
|
|
209
|
+
const bytes = Uint8Array.from(atob(chunkBase64), (c) => c.charCodeAt(0))
|
|
210
|
+
handle.stdinQueue.push(bytes)
|
|
211
|
+
} catch (err) {
|
|
212
|
+
const detail = err instanceof Error ? err.message : String(err)
|
|
213
|
+
opts.outbound.error(callId, `command_stdin decode failed: ${detail}`)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function endStdin(callId: string): void {
|
|
218
|
+
const handle = inFlight.get(callId)
|
|
219
|
+
if (handle === undefined) return
|
|
220
|
+
handle.stdinQueue.close()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function abort(callId: string, reason: string): void {
|
|
224
|
+
const handle = inFlight.get(callId)
|
|
225
|
+
if (handle === undefined) return
|
|
226
|
+
handle.abortController.abort(reason)
|
|
227
|
+
handle.stdinQueue.close()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function abortForOwner(ownerKey: WsOwnerKey): void {
|
|
231
|
+
for (const handle of inFlight.values()) {
|
|
232
|
+
if (handle.ownerKey === ownerKey) {
|
|
233
|
+
handle.abortController.abort('ws closed')
|
|
234
|
+
handle.stdinQueue.close()
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function inFlightCount(): number {
|
|
240
|
+
return inFlight.size
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { start, feedStdin, endStdin, abort, abortForOwner, inFlightCount }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
type ArgsParseResult = { ok: true; value: unknown } | { ok: false; message: string }
|
|
247
|
+
|
|
248
|
+
function parseArgs(command: { args?: { safeParse?: (input: unknown) => unknown } }, args: unknown): ArgsParseResult {
|
|
249
|
+
if (command.args === undefined) return { ok: true, value: undefined }
|
|
250
|
+
const safe = (
|
|
251
|
+
command.args as {
|
|
252
|
+
safeParse: (input: unknown) => {
|
|
253
|
+
success: boolean
|
|
254
|
+
data?: unknown
|
|
255
|
+
error?: { issues: { path: (string | number)[]; message: string }[] }
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
).safeParse(args)
|
|
259
|
+
if (safe.success === true) return { ok: true, value: safe.data }
|
|
260
|
+
const issues = safe.error?.issues ?? []
|
|
261
|
+
const message =
|
|
262
|
+
issues.length === 0
|
|
263
|
+
? 'args validation failed'
|
|
264
|
+
: issues.map((i) => `${i.path.length > 0 ? i.path.join('.') : '<root>'}: ${i.message}`).join('; ')
|
|
265
|
+
return { ok: false, message }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
type StdinQueue = {
|
|
269
|
+
readable: ReadableStream<Uint8Array>
|
|
270
|
+
push: (chunk: Uint8Array) => void
|
|
271
|
+
close: () => void
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function createStdinQueue(signal: AbortSignal): StdinQueue {
|
|
275
|
+
let controller: ReadableStreamDefaultController<Uint8Array> | null = null
|
|
276
|
+
let closed = false
|
|
277
|
+
const buffered: Uint8Array[] = []
|
|
278
|
+
|
|
279
|
+
const readable = new ReadableStream<Uint8Array>({
|
|
280
|
+
start(c) {
|
|
281
|
+
controller = c
|
|
282
|
+
for (const chunk of buffered) c.enqueue(chunk)
|
|
283
|
+
buffered.length = 0
|
|
284
|
+
if (closed) c.close()
|
|
285
|
+
signal.addEventListener('abort', () => {
|
|
286
|
+
if (!closed) {
|
|
287
|
+
closed = true
|
|
288
|
+
try {
|
|
289
|
+
c.close()
|
|
290
|
+
} catch {
|
|
291
|
+
// already closed
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
})
|
|
295
|
+
},
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
function push(chunk: Uint8Array): void {
|
|
299
|
+
if (closed) return
|
|
300
|
+
if (controller === null) {
|
|
301
|
+
buffered.push(chunk)
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
controller.enqueue(chunk)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function close(): void {
|
|
308
|
+
if (closed) return
|
|
309
|
+
closed = true
|
|
310
|
+
if (controller === null) return
|
|
311
|
+
try {
|
|
312
|
+
controller.close()
|
|
313
|
+
} catch {
|
|
314
|
+
// already closed
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return { readable, push, close }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function makeWritable(onChunk: (chunk: Uint8Array) => void): WritableStream<Uint8Array> {
|
|
322
|
+
return new WritableStream<Uint8Array>({
|
|
323
|
+
write(chunk) {
|
|
324
|
+
onChunk(chunk)
|
|
325
|
+
},
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function writeLine(stream: WritableStream<Uint8Array>, line: string): void {
|
|
330
|
+
const writer = stream.getWriter()
|
|
331
|
+
void writer.write(new TextEncoder().encode(`${line}\n`)).then(() => writer.releaseLock())
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export async function runPromptForCommand(args: {
|
|
335
|
+
text: string
|
|
336
|
+
origin: SessionOrigin
|
|
337
|
+
runtime: PluginRuntime
|
|
338
|
+
agentDir: string
|
|
339
|
+
runtimeVersion: string | undefined
|
|
340
|
+
containerName: string | undefined
|
|
341
|
+
permissions: PermissionService
|
|
342
|
+
signal: AbortSignal
|
|
343
|
+
}): Promise<string> {
|
|
344
|
+
// Mirrors src/agent/multimodal/look-at.ts: spawn a session, prompt, capture
|
|
345
|
+
// the final assistant text, dispose. Unlike look-at we want the FULL agent
|
|
346
|
+
// toolset (no `tools: []` / `customTools: []` overrides) so the model can
|
|
347
|
+
// call channel_send, websearch, etc. The system prompt is composed from
|
|
348
|
+
// the agent folder's IDENTITY/SOUL/MEMORY files via the default resource
|
|
349
|
+
// loader (no `systemPromptOverride`).
|
|
350
|
+
const snapshot = args.runtime.get()
|
|
351
|
+
const sessionId = resolveSessionIdForOrigin(args.origin)
|
|
352
|
+
const { session, dispose } = await createSessionWithDispose({
|
|
353
|
+
origin: args.origin,
|
|
354
|
+
permissions: args.permissions,
|
|
355
|
+
plugins: {
|
|
356
|
+
registry: snapshot.registry,
|
|
357
|
+
hooks: snapshot.hooks,
|
|
358
|
+
sessionId,
|
|
359
|
+
agentDir: args.agentDir,
|
|
360
|
+
},
|
|
361
|
+
...(args.runtimeVersion !== undefined ? { runtimeVersion: args.runtimeVersion } : {}),
|
|
362
|
+
...(args.containerName !== undefined ? { containerName: args.containerName } : {}),
|
|
363
|
+
})
|
|
364
|
+
const detachAbort = bindSignalToSession(args.signal, session)
|
|
365
|
+
try {
|
|
366
|
+
await session.prompt(args.text)
|
|
367
|
+
return session.getLastAssistantText() ?? ''
|
|
368
|
+
} finally {
|
|
369
|
+
detachAbort()
|
|
370
|
+
session.dispose()
|
|
371
|
+
await dispose()
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Propagate abort into the live session: without this, abort flips the
|
|
376
|
+
// signal but session.prompt() keeps waiting on the provider until the LLM
|
|
377
|
+
// call completes naturally — which for a long generation means the whole
|
|
378
|
+
// command, its dispose() chain, and command_exit hang for minutes.
|
|
379
|
+
// Returns a detach function the caller must invoke (in finally) so the
|
|
380
|
+
// listener doesn't leak after a clean prompt completion.
|
|
381
|
+
export function bindSignalToSession(signal: AbortSignal, session: { abort: () => Promise<void> }): () => void {
|
|
382
|
+
const onAbort = (): void => {
|
|
383
|
+
void session.abort()
|
|
384
|
+
}
|
|
385
|
+
if (signal.aborted) {
|
|
386
|
+
onAbort()
|
|
387
|
+
return () => {}
|
|
388
|
+
}
|
|
389
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
390
|
+
return () => signal.removeEventListener('abort', onAbort)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function resolveSessionIdForOrigin(origin: SessionOrigin): string {
|
|
394
|
+
if (origin.kind === 'tui') return origin.sessionId
|
|
395
|
+
if (origin.kind === 'subagent') return origin.parentSessionId
|
|
396
|
+
return crypto.randomUUID()
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Grace period before escalating SIGTERM to SIGKILL on aborted ctx.exec.
|
|
400
|
+
// Long enough for an interactive child to flush stdout and exit cleanly;
|
|
401
|
+
// short enough that a wedged shell wrapper doesn't keep command_exit
|
|
402
|
+
// hanging visibly past user expectations.
|
|
403
|
+
const EXEC_ABORT_GRACE_MS = 5_000
|
|
404
|
+
|
|
405
|
+
export async function runExecForCommand(
|
|
406
|
+
strings: TemplateStringsArray,
|
|
407
|
+
values: readonly unknown[],
|
|
408
|
+
opts: { cwd: string; signal: AbortSignal },
|
|
409
|
+
): Promise<CommandExecResult> {
|
|
410
|
+
// Construct the shell command by interpolating template values verbatim.
|
|
411
|
+
// The command author is trusted (their plugin runs in-process anyway), so
|
|
412
|
+
// we do not add shell-quoting; if they need it, they format the string
|
|
413
|
+
// themselves.
|
|
414
|
+
let cmd = strings[0] ?? ''
|
|
415
|
+
for (let i = 0; i < values.length; i++) {
|
|
416
|
+
cmd += String(values[i])
|
|
417
|
+
cmd += strings[i + 1] ?? ''
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Spawn detached so the child is the leader of its own process group.
|
|
421
|
+
// We kill via -pid (the process group) on abort, which targets sh AND
|
|
422
|
+
// every grandchild it spawned. Without detached:true, Bun's signal hook
|
|
423
|
+
// sends SIGTERM only to sh; orphaned grandchildren (e.g. a long-running
|
|
424
|
+
// server started by sh -c "node server.js") would keep stdout pipes open
|
|
425
|
+
// for minutes, masking the abort. See src/agent/tools/ddg.ts for the
|
|
426
|
+
// same pattern applied to curl-impersonate wrappers.
|
|
427
|
+
const proc = Bun.spawn({
|
|
428
|
+
cmd: ['sh', '-c', cmd],
|
|
429
|
+
cwd: opts.cwd,
|
|
430
|
+
stdout: 'pipe',
|
|
431
|
+
stderr: 'pipe',
|
|
432
|
+
detached: true,
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
let escalationTimer: ReturnType<typeof setTimeout> | null = null
|
|
436
|
+
const onAbort = (): void => {
|
|
437
|
+
killProcessGroup(proc.pid, 'SIGTERM')
|
|
438
|
+
escalationTimer = setTimeout(() => {
|
|
439
|
+
killProcessGroup(proc.pid, 'SIGKILL')
|
|
440
|
+
}, EXEC_ABORT_GRACE_MS)
|
|
441
|
+
}
|
|
442
|
+
if (opts.signal.aborted) {
|
|
443
|
+
onAbort()
|
|
444
|
+
} else {
|
|
445
|
+
opts.signal.addEventListener('abort', onAbort, { once: true })
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
try {
|
|
449
|
+
const [exitCode, stdoutText, stderrText] = await Promise.all([
|
|
450
|
+
proc.exited,
|
|
451
|
+
new Response(proc.stdout as unknown as ReadableStream<Uint8Array>).text(),
|
|
452
|
+
new Response(proc.stderr as unknown as ReadableStream<Uint8Array>).text(),
|
|
453
|
+
])
|
|
454
|
+
return { stdout: stdoutText, stderr: stderrText, exitCode }
|
|
455
|
+
} finally {
|
|
456
|
+
opts.signal.removeEventListener('abort', onAbort)
|
|
457
|
+
if (escalationTimer !== null) clearTimeout(escalationTimer)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Kills the leader-pgid'd process and every member of its group. Falls
|
|
462
|
+
// back to the single-pid kill if the pgid kill fails (e.g. the process
|
|
463
|
+
// already exited and the negative pid is invalid). Errors during cleanup
|
|
464
|
+
// are swallowed; the only consumer is the abort path where the process is
|
|
465
|
+
// almost certainly gone by the time we observe the error.
|
|
466
|
+
function killProcessGroup(pid: number, sig: 'SIGTERM' | 'SIGKILL'): void {
|
|
467
|
+
try {
|
|
468
|
+
process.kill(-pid, sig)
|
|
469
|
+
} catch {
|
|
470
|
+
try {
|
|
471
|
+
process.kill(pid, sig)
|
|
472
|
+
} catch {
|
|
473
|
+
// Already exited; nothing to clean up.
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|