573 lines
28 KiB
TypeScript
573 lines
28 KiB
TypeScript
/**
|
|
* The engine behavior for each mods API call this bridge serves, mapped onto
|
|
* harness services: commands and tools on the agent's scoped registries,
|
|
* `ui.ask` on the user-questions answerer, files on `ctx.fs`, processes on
|
|
* `ctx.subprocess`, the store on a storage domain, session facts on the
|
|
* agent's Session and projections. A service a deployment did not compose
|
|
* fails the call with the service name.
|
|
* @module
|
|
*/
|
|
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
// Type-only: activate the `ctx.get('subprocess')` Context declaration.
|
|
import type {} from '@deepseek-ai/dsh-subprocess'
|
|
import type { CommandDefinition } from '@deepseek-ai/dsh-commands'
|
|
import type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs'
|
|
import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
|
|
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
|
|
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
|
import type { WorkingDirectoryService } from '@deepseek-ai/dsh-working-directory'
|
|
import type {} from '@deepseek-ai/dsh-token-meter'
|
|
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
|
|
import type { Domain } from '@deepseek-ai/dsh-storage-domain'
|
|
import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools'
|
|
import type { ToolDefinition, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
|
import type { AskUserQuestionOption } from '@deepseek-ai/dsh-user-questions'
|
|
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
|
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
import { record, requireString, stringify } from './values.ts'
|
|
import { z as zod } from 'zod'
|
|
import type { LoadedMod } from './chain.ts'
|
|
import type { ModsEngine, OpContext, OpTable } from './engine.ts'
|
|
import type { ToolNameAliases } from './tool-names.ts'
|
|
import type {
|
|
CommandInfo, CommandRunInput, CommandRunResult, FsEntry, FsStat, HttpResponse, ProcessRunResult, SessionMessage,
|
|
SessionUsage, SessionVersion, ToolCallResult, ToolInfo,
|
|
} from './types.ts'
|
|
|
|
/** The binding every event of this bridge carries: the agent it belongs to, when one does. */
|
|
export interface AgentBinding {
|
|
readonly agent: Agent | undefined
|
|
}
|
|
|
|
/** The mods API version string `$.session.version()` reports. */
|
|
export const MODS_API_VERSION: SessionVersion = Object.freeze({ version: 'claude-code-mods/0.1', engine: 'deepseek-harness' })
|
|
|
|
/** Largest file `$.fs.read` returns and `$.fs.write` accepts, Claude Code's own limit. */
|
|
export const FS_MAX_BYTES = 4 * 1024 * 1024
|
|
|
|
/** Largest JSON a plugin's `$.store` holds in total, Claude Code's own limit. */
|
|
export const STORE_MAX_BYTES = 4 * 1024 * 1024
|
|
|
|
/** Largest stdout or stderr `$.process.run` keeps. */
|
|
const PROCESS_OUTPUT_MAX_BYTES = 1024 * 1024
|
|
|
|
/** Largest response body `$.http.fetch` returns. */
|
|
export const HTTP_MAX_BYTES = 4 * 1024 * 1024
|
|
|
|
/**
|
|
* Full name the model sees for a mod-registered tool: Claude Code's MCP-style spelling.
|
|
* @param plugin - the registering plugin's name.
|
|
* @param tool - the name the mod registered.
|
|
* @returns `mcp__<plugin>__<tool>`.
|
|
*/
|
|
export function modToolName(plugin: string, tool: string): string {
|
|
return `mcp__${plugin}__${tool}`
|
|
}
|
|
|
|
/** Tool-local names stay short and id-safe, Claude Code's own rule. */
|
|
const REGISTERED_NAME = /^[A-Za-z0-9_-]{1,64}$/u
|
|
|
|
/** Per-plugin `$.store` records: one JSON object of keys per plugin. */
|
|
const storeDomainSpec = defineDomain({
|
|
name: 'claude_code_mods',
|
|
version: 1,
|
|
tables: { store: domainTable<string, Record<string, JsonValue>>(zod.record(zod.string(), zod.json())) },
|
|
})
|
|
|
|
/** Options the host op table is built from. */
|
|
export interface HostOpsOptions {
|
|
readonly ctx: Context
|
|
readonly aliases: ToolNameAliases
|
|
/** Default `$.process.run` timeout in milliseconds. */
|
|
readonly processTimeoutMs: number
|
|
/**
|
|
* Disposers of registrations mods made, keyed by the session they were scoped to
|
|
* (`''` for global ones), so bridge disposal undoes them and a disposed agent's
|
|
* entries can be dropped.
|
|
*/
|
|
readonly registrations: Map<string, Set<() => void>>
|
|
/** The mod that raised each in-flight `$.tool.call`, by call id, so the pipeline raises `tool.call` from it. */
|
|
readonly callOrigins: Map<string, LoadedMod>
|
|
/** Names of commands mods registered, for `$.command.list` sources. */
|
|
readonly modCommands: Set<string>
|
|
/** Full names of tools mods registered, whose calls a `tool.call` hook may answer with a successful result. */
|
|
readonly modTools: Set<string>
|
|
/** Redraw a session's band after a `$.ui` call that changes what it shows; absent when no surface is drawn. */
|
|
readonly redraw?: (sessionId: string) => void
|
|
/** Note a prompt a mod submitted, so the `prompt.submit` it raises carries the mod as its origin. */
|
|
readonly submitted?: (messageId: string, mod: string) => void
|
|
}
|
|
|
|
function requireAgent(context: OpContext<AgentBinding>, op: string): Agent {
|
|
const agent = context.binding.agent
|
|
if (agent === undefined) throw new Error(`$.${op} needs a session, and this event has none`)
|
|
return agent
|
|
}
|
|
|
|
function textOf(content: readonly ContentBlock[]): string {
|
|
return content.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text').map(block => block.text).join('')
|
|
}
|
|
|
|
/**
|
|
* Project one tool outcome onto Claude Code's `tool.call` result.
|
|
* @param result - the harness tool outcome.
|
|
* @returns its text as `result`, with `isError` on a failure.
|
|
*/
|
|
export function toolCallResultOf(result: ToolExecutionResult): ToolCallResult {
|
|
return result.isError ? { result: textOf(result.content), isError: true } : { result: textOf(result.content) }
|
|
}
|
|
|
|
/** The model's raw JSON arguments as an object; an unparsable string is an empty input. */
|
|
function argumentsOf(raw: string): Record<string, unknown> {
|
|
try {
|
|
return record(JSON.parse(raw))
|
|
} catch {
|
|
// A truncated or malformed tool call keeps its id and name; its input is unknown.
|
|
return {}
|
|
}
|
|
}
|
|
|
|
function sessionMessages(messages: readonly Message[], aliases: ToolNameAliases): SessionMessage[] {
|
|
const out: SessionMessage[] = []
|
|
for (const message of messages) {
|
|
if (message.role !== 'user' && message.role !== 'assistant') continue
|
|
const toolUses = message.content
|
|
.filter((block): block is Extract<ContentBlock, { type: 'tool-call' }> => block.type === 'tool-call')
|
|
.map(block => ({ tool_use_id: block.id, tool: aliases.toMod(block.name), input: argumentsOf(block.arguments) }))
|
|
out.push({ role: message.role, text: textOf(message.content), toolUses })
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** Entry kind in Claude Code's vocabulary. */
|
|
function kindOf(type: 'file' | 'directory' | 'symlink' | 'other'): FsEntry['kind'] {
|
|
return type === 'file' ? 'file' : type === 'directory' ? 'dir' : 'other'
|
|
}
|
|
|
|
function readOutput(reader: { readFrom(fromByte: number): { text: string } } | undefined): string {
|
|
/* v8 ignore next -- both streams are spawned collected, so their readers exist */
|
|
return reader === undefined ? '' : reader.readFrom(0).text
|
|
}
|
|
|
|
/** An entry's size, or 0 where the provider reports none. */
|
|
function sizeOf(info: { size?: number } | undefined): number {
|
|
return info?.size ?? 0
|
|
}
|
|
|
|
/**
|
|
* Build the op table over the composed harness services.
|
|
* @param options - the context, aliases, limits, and registration tracking.
|
|
* @returns engine behaviors by event name.
|
|
*/
|
|
export function createHostOps(options: HostOpsOptions): OpTable<AgentBinding> {
|
|
const { ctx, aliases } = options
|
|
let storeDomain: Promise<Domain<typeof storeDomainSpec>> | undefined
|
|
|
|
function store(): Promise<Domain<typeof storeDomainSpec>> {
|
|
if (storeDomain !== undefined) return storeDomain
|
|
const domains = ctx.get('storageDomain')
|
|
if (domains === undefined) throw new Error('$.store needs a storage domain service (dsh-storage-domain), which this deployment did not compose')
|
|
const opened = domains.open(storeDomainSpec)
|
|
storeDomain = opened
|
|
ctx.effect(() => () => opened.then(domain => domain.close()), 'claude-code-mods: close store domain')
|
|
return opened
|
|
}
|
|
|
|
async function target(path: string, agent: Agent | undefined, signal: AbortSignal): Promise<FsTarget> {
|
|
const cwd = await currentDirectory(agent, signal)
|
|
return fs().resolve(path, { ...cwd === undefined ? {} : { cwd }, signal })
|
|
}
|
|
|
|
function directories(): WorkingDirectoryService {
|
|
const service = ctx.get('workingDirectory')
|
|
if (service === undefined) throw new Error('$.session.cwd and session-owned operations need dsh-working-directory, which this deployment did not compose')
|
|
return service
|
|
}
|
|
|
|
function currentDirectory(agent: Agent | undefined, signal: AbortSignal): Promise<string | undefined> {
|
|
return agent === undefined ? Promise.resolve(undefined) : directories().ensure(agent, signal)
|
|
}
|
|
|
|
function fs(): FileSystem {
|
|
const service = ctx.get('fs')
|
|
if (service === undefined) throw new Error('$.fs needs a filesystem service (dsh-fs), which this deployment did not compose')
|
|
return service
|
|
}
|
|
|
|
function track(dispose: () => void, agent: Agent | undefined): void {
|
|
const key = agent?.session.id ?? ''
|
|
let owned = options.registrations.get(key)
|
|
if (owned === undefined) {
|
|
owned = new Set()
|
|
options.registrations.set(key, owned)
|
|
}
|
|
owned.add(dispose)
|
|
}
|
|
|
|
/** Per-plugin write chains: `$.store` read-modify-write runs one at a time per plugin. */
|
|
const storeWrites = new Map<string, Promise<void>>()
|
|
|
|
function serializeStore(plugin: string, write: () => Promise<void>): Promise<void> {
|
|
const previous = storeWrites.get(plugin) ?? Promise.resolve()
|
|
const next = previous.then(write, write)
|
|
storeWrites.set(plugin, next.catch(() => undefined))
|
|
return next
|
|
}
|
|
|
|
/** Ask the band of the event's session to redraw, when a surface is drawn at all. */
|
|
function redraw(agent: Agent | undefined): void {
|
|
if (agent !== undefined) options.redraw?.(agent.session.id)
|
|
}
|
|
|
|
return {
|
|
// ---- ui: no mod drawing surface exists; log-style calls reach the host log ----
|
|
'ui.log': (input, { mod }) => {
|
|
const { text, to } = record(input)
|
|
const line = `${mod.name}: ${requireString(text, '$.ui.log text')}`
|
|
if (to === 'debug') ctx.logger.debug(line)
|
|
else ctx.logger.info(line)
|
|
},
|
|
'ui.toast': (input, { mod }) => { ctx.logger.info(`${mod.name} (toast): ${requireString(record(input).text, '$.ui.toast text')}`) },
|
|
'ui.status': (input, { mod }) => {
|
|
const { text } = record(input)
|
|
ctx.logger.info(`${mod.name} (status): ${text === undefined ? '(cleared)' : requireString(text, '$.ui.status text')}`)
|
|
},
|
|
'ui.invalidate': (_input, { binding }) => { redraw(binding.agent) },
|
|
// No pane is placed: a mod that degrades to the band (`isPlaced: false`) is drawn there after this call.
|
|
'ui.open': (input, { binding }) => {
|
|
redraw(binding.agent)
|
|
return { id: requireString(record(input).id, '$.ui.open id'), isPlaced: false, reason: 'this host places no panes; draw in AbovePrompt' }
|
|
},
|
|
'ui.close': (_input, { binding }) => { redraw(binding.agent) },
|
|
'ui.panes': () => [],
|
|
'ui.ask': async (input, context) => {
|
|
const agent = requireAgent(context, 'ui.ask')
|
|
const questions = ctx.get('userQuestions')
|
|
if (questions === undefined) throw new Error('$.ui.ask needs a user-questions service (dsh-user-questions), which this deployment did not compose')
|
|
const { question, options: labels, header, multiSelect } = record(input)
|
|
const choices: AskUserQuestionOption[] | undefined = Array.isArray(labels)
|
|
? labels.map(label => ({ label: requireString(label, '$.ui.ask option') }))
|
|
: undefined
|
|
const answer = await questions.ask({
|
|
questions: [{
|
|
id: 'answer',
|
|
question: requireString(question, '$.ui.ask question'),
|
|
...typeof header === 'string' ? { header } : {},
|
|
...choices === undefined ? {} : { options: choices },
|
|
...multiSelect === true ? { multiSelect: true } : {},
|
|
}],
|
|
agent,
|
|
signal: context.signal,
|
|
})
|
|
const item = answer.answers[0]
|
|
if (item === undefined) throw new Error('$.ui.ask: the user dismissed the question')
|
|
return item.custom ?? item.selected.join(', ')
|
|
},
|
|
|
|
// ---- commands ----
|
|
'command.register': (input, { mod, binding, engine }) => {
|
|
const spec = record(input)
|
|
const name = requireString(spec.name, '$.command.register name')
|
|
if (!REGISTERED_NAME.test(name)) throw new Error(`"/${name}" refused: command names are letters, digits, "_" and "-", up to 64 characters`)
|
|
const description = requireString(spec.description, '$.command.register description')
|
|
const registry = (binding.agent?.ctx ?? ctx).get('commands')
|
|
if (registry === undefined) throw new Error('$.command.register needs a command registry (dsh-commands), which this deployment did not compose')
|
|
const lowered = name.toLowerCase()
|
|
const definition: CommandDefinition = {
|
|
name: lowered,
|
|
description,
|
|
...typeof spec.argumentHint === 'string' ? { input: { hint: spec.argumentHint } } : {},
|
|
handler: async (invocation) => {
|
|
const run = await engine.raise<CommandRunInput, CommandRunResult>(
|
|
'command.run',
|
|
{ command: lowered, args: invocation.rawInput.trim(), origin: { kind: 'composer' } },
|
|
() => ({ text: `${mod.name} registered /${lowered} but no command.run hook answered it; add on('command.run', { command: '${lowered}' }, hook)` }),
|
|
{ binding: { agent: invocation.agent }, signal: invocation.signal },
|
|
)
|
|
return { kind: 'success', ...run.text === undefined ? {} : { text: `${mod.name}: ${run.text}` } }
|
|
},
|
|
}
|
|
const dispose = registry.register(definition)
|
|
options.modCommands.add(lowered)
|
|
track(dispose, binding.agent)
|
|
},
|
|
'command.run': async (input, context) => {
|
|
const agent = requireAgent(context, 'command.run')
|
|
const registry = ctx.get('commands')
|
|
if (registry === undefined) throw new Error('$.command.run needs a command registry (dsh-commands), which this deployment did not compose')
|
|
const { command, args } = record(input)
|
|
const name = requireString(command, '$.command.run command')
|
|
const line = typeof args === 'string' && args.length > 0 ? `/${name} ${args}` : `/${name}`
|
|
const execution = await registry.execute(agent, line, [], context.signal)
|
|
if (execution === undefined) throw new Error(`/${name} is not a command`)
|
|
const { result } = execution
|
|
if (result.kind === 'error') throw new Error(result.text)
|
|
return { ...result.text === undefined ? {} : { text: result.text } } satisfies CommandRunResult
|
|
},
|
|
'command.list': (_input, context) => {
|
|
const agent = requireAgent(context, 'command.list')
|
|
const registry = ctx.get('commands')
|
|
if (registry === undefined) return []
|
|
return registry.list(agent).map((descriptor): CommandInfo => ({
|
|
name: descriptor.name,
|
|
description: descriptor.description,
|
|
source: options.modCommands.has(descriptor.name) ? 'plugin' : 'builtin',
|
|
}))
|
|
},
|
|
|
|
// ---- tools ----
|
|
'tool.register': (input, { mod, binding }) => {
|
|
const spec = record(input)
|
|
const name = requireString(spec.name, '$.tool.register name')
|
|
if (!REGISTERED_NAME.test(name)) throw new Error(`tool "${name}" refused: tool names are letters, digits, "_" and "-", up to 64 characters`)
|
|
const description = requireString(spec.description, '$.tool.register description')
|
|
const inputSchema = spec.inputSchema === undefined ? { type: 'object', properties: {} } : record(spec.inputSchema)
|
|
// The schema reaches the model, so an unsupported keyword fails this call, not a later request.
|
|
assertObjectJsonSchema(inputSchema)
|
|
const registry = (binding.agent?.ctx ?? ctx).get('tools')
|
|
if (registry === undefined) throw new Error('$.tool.register needs a tool registry (dsh-tools), which this deployment did not compose')
|
|
const fullName = modToolName(mod.name, name)
|
|
const definition: ToolDefinition = {
|
|
name: fullName,
|
|
description,
|
|
parameters: inputSchema,
|
|
output: {
|
|
// The answering hook's result text; the schema admits only a string.
|
|
schema: { type: 'string' },
|
|
render: (_args, value) => [{ type: 'text', text: value as string }],
|
|
},
|
|
execute() {
|
|
return Promise.reject(new Error(`${mod.name} registered ${fullName} but no tool.call hook answered it; add on('tool.call', { tool: '${fullName}' }, hook)`))
|
|
},
|
|
}
|
|
track(registry.register(definition), binding.agent)
|
|
options.modTools.add(fullName)
|
|
},
|
|
'tool.call': async (input, context) => {
|
|
const agent = context.binding.agent
|
|
const registry = ctx.get('tools')
|
|
if (registry === undefined) throw new Error('$.tool.call needs a tool registry (dsh-tools), which this deployment did not compose')
|
|
const { tool, tool_use_id: _ignored, agentId: _agent, ...args } = record(input)
|
|
const callId = ToolCallId(`mod-${randomUUID()}`)
|
|
// The pipeline raises `tool.call` for this call from the calling mod, so only earlier mods see it.
|
|
options.callOrigins.set(callId, context.mod)
|
|
try {
|
|
const batch = await registry.execute({
|
|
calls: [{
|
|
type: 'tool-call', id: callId,
|
|
name: aliases.toHarness(requireString(tool, '$.tool.call tool')),
|
|
arguments: JSON.stringify(args),
|
|
}],
|
|
...agent === undefined ? {} : { agent },
|
|
signal: context.signal,
|
|
})
|
|
// Guidance a tool defers to the next request still reaches the model when a mod made the call.
|
|
if (agent !== undefined) for (const context of batch.additionalContexts) agent.inject(context)
|
|
// oxlint-disable-next-line typescript/no-non-null-assertion -- execute returns one result per call
|
|
return toolCallResultOf(batch.results[0]!.result)
|
|
} finally {
|
|
options.callOrigins.delete(callId)
|
|
}
|
|
},
|
|
'tool.list': (_input, context) => {
|
|
const registry = ctx.get('tools')
|
|
if (registry === undefined) return []
|
|
const agent = context.binding.agent
|
|
return registry.schemas(agent === undefined ? undefined : scopeOf(agent.ctx))
|
|
.map((schema): ToolInfo => ({ name: aliases.toMod(schema.name), description: schema.description }))
|
|
},
|
|
|
|
// ---- prompt ----
|
|
'prompt.submit': (input, context) => {
|
|
const agent = requireAgent(context, 'prompt.submit')
|
|
const { text, asUser } = record(input)
|
|
const body = requireString(text, '$.prompt.submit text')
|
|
const framed = asUser === true ? body : `Message from the "${context.mod.name}" mod:\n${body}`
|
|
// Claude Code submits the prompt as the user's own; the framing names the mod unless `asUser` is set.
|
|
const message = createUserMessage({ content: [{ type: 'text', text: framed }], source: { kind: 'user' } })
|
|
options.submitted?.(message.id, context.mod.name)
|
|
agent.followup(message)
|
|
return { text: framed }
|
|
},
|
|
|
|
// ---- session facts ----
|
|
'session.id': (_input, context) => requireAgent(context, 'session.id').session.id,
|
|
'session.cwd': (_input, context) => {
|
|
const agent = requireAgent(context, 'session.cwd')
|
|
return directories().get(agent.session)
|
|
},
|
|
'session.root': (_input, context) => requireAgent(context, 'session.root').session.header.cwd ?? process.cwd(),
|
|
'session.model': (_input, context) => {
|
|
const agent = requireAgent(context, 'session.model')
|
|
return agent.session.requestHeader()?.config.model ?? agent.options.model ?? ''
|
|
},
|
|
'session.turns': (_input, context) => {
|
|
const agent = requireAgent(context, 'session.turns')
|
|
return ctx.agents.turnBoundaryOf(agent.session).lastTurn
|
|
},
|
|
'session.messages': (_input, context) => sessionMessages(requireAgent(context, 'session.messages').session.deriveMessages(), aliases),
|
|
'session.usage': (_input, context): SessionUsage => {
|
|
const agent = requireAgent(context, 'session.usage')
|
|
const pressure = ctx.get('tokenMeter')?.contextPressure(agent.session)
|
|
const tokens = pressure?.pressureTokens
|
|
const window = pressure?.contextWindow ?? 0
|
|
return {
|
|
startedAt: agent.session.header.createdAt,
|
|
context: {
|
|
...tokens === undefined ? {} : { tokens },
|
|
window,
|
|
...tokens === undefined || window === 0 ? {} : { percent: Math.round((tokens / window) * 100) },
|
|
},
|
|
rateLimits: [],
|
|
}
|
|
},
|
|
'session.version': () => MODS_API_VERSION,
|
|
|
|
// ---- store: one durable JSON object per plugin ----
|
|
'store.get': async (input, { mod }) => (await store()).table('store').get(mod.name)?.[requireString(record(input).key, '$.store.get key')],
|
|
'store.set': async (input, { mod }) => {
|
|
const { key, value } = record(input)
|
|
const name = requireString(key, '$.store.set key')
|
|
await serializeStore(mod.name, async () => {
|
|
const table = (await store()).table('store')
|
|
const next = { ...table.get(mod.name), [name]: value as JsonValue }
|
|
const encoded = stringify(next)
|
|
if (encoded === undefined || Buffer.byteLength(encoded, 'utf8') > STORE_MAX_BYTES) {
|
|
throw new Error(`$.store.set: ${mod.name}'s store would exceed ${STORE_MAX_BYTES} bytes of JSON`)
|
|
}
|
|
await table.put(mod.name, JSON.parse(encoded) as Record<string, JsonValue>)
|
|
})
|
|
},
|
|
'store.delete': async (input, { mod }) => {
|
|
const name = requireString(record(input).key, '$.store.delete key')
|
|
await serializeStore(mod.name, async () => {
|
|
const table = (await store()).table('store')
|
|
const current = table.get(mod.name)
|
|
if (current === undefined || !(name in current)) return
|
|
const { [name]: _removed, ...rest } = current
|
|
await table.put(mod.name, rest)
|
|
})
|
|
},
|
|
'store.keys': async (_input, { mod }) => Object.keys((await store()).table('store').get(mod.name) ?? {}),
|
|
|
|
// ---- files: through the filesystem seam, relative to the session workspace ----
|
|
'fs.read': async (input, context) => {
|
|
const path = requireString(record(input).path, '$.fs.read path')
|
|
const resolved = await target(path, context.binding.agent, context.signal)
|
|
const info = await fs().stat(resolved, context.signal)
|
|
if (info?.size !== undefined && info.size > FS_MAX_BYTES) throw new Error(`$.fs.read: ${path} is larger than ${FS_MAX_BYTES} bytes`)
|
|
return fs().readText(resolved, context.signal)
|
|
},
|
|
'fs.write': async (input, context) => {
|
|
const { path, text } = record(input)
|
|
const body = requireString(text, '$.fs.write text')
|
|
if (Buffer.byteLength(body, 'utf8') > FS_MAX_BYTES) throw new Error(`$.fs.write: content is larger than ${FS_MAX_BYTES} bytes`)
|
|
const resolved = await target(requireString(path, '$.fs.write path'), context.binding.agent, context.signal)
|
|
await fs().writeText(resolved, body, undefined, context.signal)
|
|
},
|
|
'fs.list': async (input, context) => {
|
|
const resolved = await target(requireString(record(input).path, '$.fs.list path'), context.binding.agent, context.signal)
|
|
const entries = await fs().listDir(resolved, context.signal)
|
|
return entries.map((entry): FsEntry => ({ name: entry.name, kind: kindOf(entry.type), size: sizeOf(entry), isLink: false }))
|
|
},
|
|
'fs.exists': async (input, context) => {
|
|
const resolved = await target(requireString(record(input).path, '$.fs.exists path'), context.binding.agent, context.signal)
|
|
return await fs().stat(resolved, context.signal) !== undefined
|
|
},
|
|
'fs.stat': async (input, context) => {
|
|
const path = requireString(record(input).path, '$.fs.stat path')
|
|
const cwd = await currentDirectory(context.binding.agent, context.signal)
|
|
const info = await fs().lstat(path, cwd === undefined ? {} : { cwd }, context.signal)
|
|
if (info === undefined) throw new Error(`$.fs.stat: ${path} does not exist`)
|
|
if (info.type !== 'symlink') return { kind: kindOf(info.type), size: sizeOf(info), mtimeMs: 0, isLink: false } satisfies FsStat
|
|
// A link reports what it points at, as Claude Code's `resolve: true` does; a dangling one is `other`.
|
|
const resolved = await fs().resolve(path, { ...cwd === undefined ? {} : { cwd }, signal: context.signal })
|
|
const followed = await fs().stat(resolved, context.signal)
|
|
return { kind: kindOf(followed?.type ?? 'other'), size: sizeOf(followed), mtimeMs: 0, isLink: true } satisfies FsStat
|
|
},
|
|
|
|
// ---- processes: argv, no shell, collected output ----
|
|
'process.run': async (input, context) => {
|
|
const subprocess = ctx.get('subprocess')
|
|
if (subprocess === undefined) throw new Error('$.process.run needs a subprocess service (dsh-subprocess), which this deployment did not compose')
|
|
const { argv, init } = record(input)
|
|
if (!Array.isArray(argv) || argv.length === 0 || !argv.every(item => typeof item === 'string')) {
|
|
throw new TypeError('$.process.run needs a non-empty argv list of strings')
|
|
}
|
|
const settings = record(init)
|
|
const timeoutMs = typeof settings.timeoutMs === 'number' ? settings.timeoutMs : options.processTimeoutMs
|
|
const cwd = typeof settings.cwd === 'string' ? settings.cwd : await currentDirectory(context.binding.agent, context.signal) ?? process.cwd()
|
|
const env = typeof settings.env === 'object' && settings.env !== null ? settings.env as Record<string, string> : undefined
|
|
const deadline = AbortSignal.any([context.signal, AbortSignal.timeout(timeoutMs)])
|
|
const handle = subprocess.spawn({
|
|
argv,
|
|
cwd,
|
|
stdio: { stdin: 'ignore', stdout: { maxBytes: PROCESS_OUTPUT_MAX_BYTES }, stderr: { maxBytes: PROCESS_OUTPUT_MAX_BYTES } },
|
|
graceMs: 1000,
|
|
signal: deadline,
|
|
...env === undefined ? {} : { env },
|
|
})
|
|
const outcome = await handle.done
|
|
if (deadline.aborted) throw new Error(`$.process.run: ${argv[0]} did not exit within ${timeoutMs} ms`)
|
|
/* v8 ignore next -- a Windows child reports an exit code even when terminated; POSIX coverage owns this branch */
|
|
if (outcome.exitCode === null) throw new Error(`$.process.run: ${argv[0]} was terminated by signal ${String(outcome.signal)}`)
|
|
return {
|
|
exitCode: outcome.exitCode,
|
|
stdout: readOutput(handle.collected.stdout),
|
|
stderr: readOutput(handle.collected.stderr),
|
|
} satisfies ProcessRunResult
|
|
},
|
|
|
|
// ---- network ----
|
|
'http.fetch': async (input, context) => {
|
|
const { url, init } = record(input)
|
|
const settings = record(init)
|
|
const timeoutMs = typeof settings.timeoutMs === 'number' ? settings.timeoutMs : options.processTimeoutMs
|
|
const response = await fetch(requireString(url, '$.http.fetch url'), {
|
|
...typeof settings.method === 'string' ? { method: settings.method } : {},
|
|
...typeof settings.headers === 'object' && settings.headers !== null ? { headers: settings.headers as Record<string, string> } : {},
|
|
...typeof settings.body === 'string' ? { body: settings.body } : {},
|
|
signal: AbortSignal.any([context.signal, AbortSignal.timeout(timeoutMs)]),
|
|
})
|
|
const headers: Record<string, string> = {}
|
|
response.headers.forEach((value, name) => {
|
|
headers[name] = value
|
|
})
|
|
const body = await readBounded(response, HTTP_MAX_BYTES)
|
|
return { status: response.status, ok: response.ok, headers, text: body } satisfies HttpResponse
|
|
},
|
|
|
|
// ---- environment of this process ----
|
|
'env.get': input => process.env[requireString(record(input).name, '$.env.get name')],
|
|
'env.set': (input) => {
|
|
const { name, value } = record(input)
|
|
const key = requireString(name, '$.env.set name')
|
|
if (value === undefined) Reflect.deleteProperty(process.env, key)
|
|
else process.env[key] = requireString(value, '$.env.set value')
|
|
},
|
|
}
|
|
}
|
|
|
|
export type { ModsEngine }
|
|
|
|
/** Read a response body up to the bound, cancelling the stream at the first byte past it. */
|
|
async function readBounded(response: Response, maxBytes: number): Promise<string> {
|
|
const reader = response.body?.getReader()
|
|
if (reader === undefined) return ''
|
|
const chunks: Uint8Array[] = []
|
|
let total = 0
|
|
for (;;) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
total += value.byteLength
|
|
if (total > maxBytes) {
|
|
await reader.cancel()
|
|
throw new Error(`$.http.fetch: the response body is larger than ${maxBytes} bytes`)
|
|
}
|
|
chunks.push(value)
|
|
}
|
|
return new TextDecoder().decode(Buffer.concat(chunks))
|
|
}
|