@agimon-ai/doompi-runner 0.0.1-alpha.122 → 0.0.1-alpha.123
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/dist/extensions/mcp.cjs +14 -3
- package/dist/extensions/mcp.cjs.map +1 -1
- package/dist/extensions/mcp.mjs +14 -3
- package/dist/extensions/mcp.mjs.map +1 -1
- package/dist/extensions/server.cjs +11 -9
- package/dist/extensions/server.cjs.map +1 -1
- package/dist/extensions/server.mjs +11 -9
- package/dist/extensions/server.mjs.map +1 -1
- package/package.json +4 -4
- package/src/prompts/doompi-use-runner/SKILL.md +6 -4
package/dist/extensions/mcp.cjs
CHANGED
|
@@ -71,13 +71,24 @@ const { Terminal: HeadlessTerminal } = _xterm_headless.default;
|
|
|
71
71
|
const RUNNER_SERVER_SCOPE_SERVICE = "doom/runner-server-scope";
|
|
72
72
|
//#endregion
|
|
73
73
|
//#region src/extensions/workspaces/sessions/(backend)/tool/bash.mcp.ts
|
|
74
|
+
const pollHint = (id) => `You will not be notified when this runner ends. Check it with \`doom-runner status ${id}\` and read output with \`doom-runner logs ${id} --lines 100\`, both through this tool, until State is completed.`;
|
|
74
75
|
var bash_mcp_default = (0, _agimon_ai_doompi_core_mcpFacet.defineMcpTool)((context) => {
|
|
75
76
|
const scope = context.services.get(RUNNER_SERVER_SCOPE_SERVICE);
|
|
76
77
|
if (!scope) throw new Error("Runner session service is unavailable.");
|
|
77
78
|
return {
|
|
78
|
-
...scope.
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
...scope.remoteTool,
|
|
80
|
+
description: "Run a shell command (bash, terminal, CLI) in the DoomPi session's repository on the host machine, not on your local machine. Use it for tests, builds, linters, git, and package scripts. Short commands return bounded output. Long-running ones return a runner id. No completion notice reaches you, so check them with `doom-runner status <id>` and `doom-runner logs <id> --lines 100` instead of relaunching.",
|
|
81
|
+
async execute(toolCallId, parameters, signal, onUpdate, execution) {
|
|
82
|
+
const result = await scope.remoteTool.execute(toolCallId, parameters, AbortSignal.any([context.signal, signal ?? context.signal]), onUpdate, execution);
|
|
83
|
+
const details = result.details;
|
|
84
|
+
if (details?.promoted !== true || typeof details.id !== "string") return result;
|
|
85
|
+
return {
|
|
86
|
+
...result,
|
|
87
|
+
content: [...result.content, {
|
|
88
|
+
type: "text",
|
|
89
|
+
text: pollHint(details.id)
|
|
90
|
+
}]
|
|
91
|
+
};
|
|
81
92
|
}
|
|
82
93
|
};
|
|
83
94
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mcp.cjs","names":["defineMcpSkill","readPackageResource","Type","headless","promisify","execFile","defineMcpTool","defineMcpPlugin","skillDoompiUseRunner","toolBash"],"sources":["../../src/extensions/workspaces/sessions/(backend)/skill/doompi-use-runner.mcp.ts","../../src/schemas/bashTool.ts","../../src/services/ansiScrub/index.ts","../../src/services/ptyHost/index.ts","../../src/services/runnerRegistry/index.ts","../../src/extensions/workspaces/sessions/(backend)/_lib/serverRoot.ts","../../src/extensions/workspaces/sessions/(backend)/tool/bash.mcp.ts","../../generated/mcp.ts"],"sourcesContent":["import { defineMcpSkill } from '@agimon-ai/doompi-core/mcpFacet';\nimport { readPackageResource } from '@agimon-ai/doompi-core/serverFacet';\n\nexport default defineMcpSkill({\n name: 'doompi-use-runner',\n description:\n 'Use Doom Pi Runner to supervise shell commands, inspect durable logs, provide interactive input, and stop background runs.',\n read: () => readPackageResource(import.meta.url, 'src/prompts/doompi-use-runner/SKILL.md'),\n});\n","import { type Static, Type } from 'typebox';\n\n/** Replaces pi's built-in tool of the same name, so hooks keyed on `bash` keep working. */\n\n/**\n * Built-in bash parameters plus the backgrounding controls.\n *\n * `command` and `timeout` keep their original names and wording so the tool is\n * a drop-in replacement. Every `description` is prompt copy the model reads.\n */\nexport const BashParamsSchema = Type.Object({\n command: Type.String({ description: 'Bash command to execute' }),\n timeout: Type.Optional(Type.Number({ description: 'Timeout in seconds (optional, no default timeout)' })),\n background: Type.Optional(\n Type.Boolean({\n description:\n 'Start the command in the background immediately instead of waiting. Use for dev servers, watchers and other commands you already know will not finish.',\n }),\n ),\n interactive: Type.Optional(\n Type.Boolean({\n description:\n 'Run under a pseudo terminal so the command can prompt. Use only when the command asks for confirmation or input; answer it with doom-runner input. Logs from interactive runs are noisier.',\n }),\n ),\n name: Type.Optional(\n Type.String({\n description: 'Preferred runner name if the command ends up in the background. A name in use gets a suffix.',\n }),\n ),\n});\n\nexport type BashParams = Static<typeof BashParamsSchema>;\n","import { CONTROL_PATTERN } from '../../constants/ansiScrub';\n/**\n * Turns terminal output into text worth grepping.\n *\n * PTY output is written for a screen, not a file: colours, cursor moves and\n * carriage-return redraws all end up in the byte stream. Without this a single\n * progress bar becomes thousands of near-identical log lines.\n */\n\n/**\n * CSI sequences (colours, cursor moves), OSC strings, the two-byte Fe escapes,\n * and the keypad-mode escapes shells emit around every prompt.\n */\nconst ANSI_PATTERN = new RegExp(\n [\n '\\\\u001B\\\\[[0-9;?]*[ -/]*[@-~]',\n '\\\\u001B\\\\][^\\\\u0007\\\\u001B]*(?:\\\\u0007|\\\\u001B\\\\\\\\)',\n '\\\\u001B[@-Z\\\\\\\\-_]',\n '\\\\u001B[=><]',\n ].join('|'),\n 'g',\n);\n\n/** Bell and backspace, which a terminal consumes rather than displays. */\n// oxlint-disable-next-line no-control-regex -- matching terminal control bytes is the whole job\n\n/**\n * The same set minus SGR, the sequence that only sets colour and weight.\n *\n * The final byte class omits `m`, so `ESC[32m` survives while cursor moves,\n * OSC strings and keypad escapes are still removed.\n */\nconst ANSI_PATTERN_KEEPING_COLOUR = new RegExp(\n [\n '\\\\u001B\\\\[[0-9;?]*[ -/]*[@-ln-~]',\n '\\\\u001B\\\\][^\\\\u0007\\\\u001B]*(?:\\\\u0007|\\\\u001B\\\\\\\\)',\n '\\\\u001B[@-Z\\\\\\\\-_]',\n '\\\\u001B[=><]',\n ].join('|'),\n 'g',\n);\n\n/** Removes escape sequences, leaving the characters a user would have seen. */\nexport function stripAnsi(text: string): string {\n return text.replace(ANSI_PATTERN, '').replace(CONTROL_PATTERN, '');\n}\n\n/**\n * Removes everything `stripAnsi` does except colour.\n *\n * Logs are read back by a terminal renderer as well as by grep, and a command\n * that colours its own output has already said how it should look.\n */\nexport function stripAnsiKeepingColour(text: string): string {\n return text.replace(ANSI_PATTERN_KEEPING_COLOUR, '').replace(CONTROL_PATTERN, '');\n}\n\n/**\n * Collapses carriage-return redraws.\n *\n * A bare `\\r` returns the cursor to the start of the current line, so whatever\n * follows overwrites it. Only the final state of each line is kept. The `\\r` of\n * a CRLF pair is a line terminator rather than a redraw, so it is normalised\n * away first: a pseudo terminal ends every line that way, and treating it as a\n * redraw would blank the whole log.\n */\nexport function collapseCarriageReturns(text: string): string {\n return text\n .replace(/\\r\\n/g, '\\n')\n .split('\\n')\n .map((line) => (line.includes('\\r') ? (line.split('\\r').at(-1) ?? '') : line))\n .join('\\n');\n}\n\n/** Full scrub applied to PTY output before it reaches the log file. */\nexport function scrubTerminalOutput(text: string): string {\n return collapseCarriageReturns(stripAnsiKeepingColour(text));\n}\n\n/** Plain-text scrub, for the copy the model reads. */\nexport function scrubTerminalOutputToPlainText(text: string): string {\n return collapseCarriageReturns(stripAnsi(text));\n}\n","import headless, { type Terminal } from '@xterm/headless';\n\nimport {\n DEFAULT_COLS,\n DEFAULT_ROWS,\n LIVENESS_POLL_MS,\n MEMORY_BUFFER_FACTOR,\n NEWLINE,\n SCROLLBACK,\n TERM_GRACE_MS,\n} from '../../constants/ptyHost';\nimport { PI_SESSION_ID_ENV } from '../../constants/session';\nimport type { IClock } from '../../types/clock';\nimport type { ILogFile } from '../../types/logFile';\nimport type { IProcessControl } from '../../types/processControl';\nimport type { IPtyHost, PtyLaunchRequest, PtyRun } from '../../types/ptyHost';\nimport type { IPtySpawner, PtyProcess } from '../../types/ptySpawner';\nimport type { ExitResult } from '../../types/spawner';\nimport { scrubTerminalOutput } from '../ansiScrub';\nimport { getResultMaxBytes } from '../runnerConfig';\nimport { NO_TERMINAL_INPUT_ENV } from '../runnerSupervisor';\n\n/** Enough history for the overlay to scroll without holding a whole build. */\n\n// Use the CommonJS default export: Node cannot detect this package's named exports.\n// A static import also lets server bundles include it without a live node_modules lookup.\nconst { Terminal: HeadlessTerminal } = headless;\n\nexport class PtyHost implements IPtyHost {\n private readonly runs = new Map<string, PtyRun>();\n\n constructor(\n private readonly spawner: IPtySpawner,\n private readonly logFile: ILogFile,\n private readonly processControl: IProcessControl,\n private readonly clock: IClock,\n private readonly environment: Readonly<Record<string, string | undefined>> = process.env,\n ) {}\n\n async launch(request: PtyLaunchRequest): Promise<PtyRun> {\n const cols = request.cols ?? DEFAULT_COLS;\n const rows = request.rows ?? DEFAULT_ROWS;\n const writer = this.logFile.open(request.id);\n const terminal = new HeadlessTerminal({ cols, rows, scrollback: SCROLLBACK, allowProposedApi: true });\n const memoryLimit = getResultMaxBytes(this.environment) * MEMORY_BUFFER_FACTOR;\n\n const child = await this.spawner.spawn({\n command: request.command,\n cwd: request.cwd,\n env: { ...this.environment, ...NO_TERMINAL_INPUT_ENV, [PI_SESSION_ID_ENV]: request.sessionId },\n cols,\n rows,\n });\n\n let buffer = '';\n let buffering = true;\n let settled = false;\n let resolveCompletion: (result: ExitResult) => void = () => undefined;\n const completion = new Promise<ExitResult>((resolve) => {\n resolveCompletion = resolve;\n });\n const dataHandlers = new Set<(data: string) => void>();\n\n child.onData((data) => {\n // The terminal keeps the screen; the log keeps text worth grepping.\n terminal.write(data);\n const scrubbed = scrubTerminalOutput(data);\n writer.append(scrubbed);\n if (buffering) {\n buffer += scrubbed;\n if (buffer.length > memoryLimit) buffer = buffer.slice(-memoryLimit);\n }\n for (const handler of dataHandlers) handler(data);\n });\n\n child.onExit((result) => {\n if (settled) return;\n settled = true;\n this.runs.delete(request.name);\n writer.close();\n resolveCompletion({ code: result.exitCode, signal: null });\n });\n\n const run: PtyRun = {\n id: request.id,\n name: request.name,\n pid: child.pid,\n logPath: writer.path,\n backend: 'native',\n output: () => buffer,\n completion: () => completion,\n detach: () => {\n buffering = false;\n buffer = '';\n },\n stop: () => this.stopChild(child, request.name),\n write: (text) => child.write(text),\n screen: () => renderScreen(terminal),\n onData: (handler) => {\n dataHandlers.add(handler);\n return () => dataHandlers.delete(handler);\n },\n resize: (nextCols, nextRows) => {\n child.resize(nextCols, nextRows);\n terminal.resize(nextCols, nextRows);\n },\n };\n\n this.runs.set(request.name, run);\n return run;\n }\n\n get(name: string): PtyRun | undefined {\n return this.runs.get(name);\n }\n\n write(name: string, text: string): boolean {\n const run = this.runs.get(name);\n if (!run) return false;\n run.write(text.endsWith(NEWLINE) ? text : `${text}${NEWLINE}`);\n return true;\n }\n\n list(): PtyRun[] {\n return [...this.runs.values()];\n }\n\n async disposeAll(): Promise<void> {\n const runs = [...this.runs.values()];\n this.runs.clear();\n for (const run of runs) await run.stop();\n }\n\n private async stopChild(child: PtyProcess, name: string): Promise<boolean> {\n this.runs.delete(name);\n if (!this.processControl.isAlive(child.pid)) return false;\n\n child.kill('SIGTERM');\n const deadline = this.clock.now() + TERM_GRACE_MS;\n while (this.clock.now() < deadline) {\n if (!this.processControl.isAlive(child.pid)) return true;\n await new Promise<void>((resolve) => {\n this.clock.after(LIVENESS_POLL_MS, resolve);\n });\n }\n child.kill('SIGKILL');\n return true;\n }\n}\n\n/** The visible rows, trimmed of trailing blanks, as plain text. */\nfunction renderScreen(terminal: Terminal): string {\n const buffer = terminal.buffer.active;\n const lines: string[] = [];\n for (let row = buffer.baseY; row < buffer.baseY + terminal.rows; row += 1) {\n lines.push(buffer.getLine(row)?.translateToString(true) ?? '');\n }\n while (lines.length > 0 && lines.at(-1) === '') lines.pop();\n return lines.join(NEWLINE);\n}\n","import { execFile } from 'node:child_process';\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport { promisify } from 'node:util';\n\nimport { resolveRootSessionId } from '@agimon-ai/doompi-core/childProcess';\nimport {\n createProcessRegistryService,\n normalizeRepositoryPath,\n type ProcessRegistryRecord,\n type ProcessRegistryService,\n resolveProcessTags,\n} from '@agimon-ai/foundation-process-registry';\n\nimport {\n COMMAND_SIDECAR_SUFFIX,\n EXIT_SIDECAR_SUFFIX,\n JSON_EXTENSION,\n NOT_FOUND_ERROR_CODE,\n REGISTRY_PATH_ENV,\n RUNNER_TAG,\n SERVICE_PREFIX,\n SERVICE_TYPE,\n} from '../../constants/runnerRegistry';\nimport type { IProcessControl } from '../../types/processControl';\nimport type {\n CompleteRunnerInput,\n IRunnerRegistry,\n RegisterRunnerInput,\n RunnerRecord,\n RunnerTerminationIntent,\n} from '../../types/runnerRegistry';\nimport type { IRunnerPaths } from '../runnerPaths/type';\nimport { isRunnerRecord } from '../runnerRecord';\n\n/** The slice of the shared registry service this wrapper depends on. */\nexport type ProcessRegistryPort = Pick<\n ProcessRegistryService,\n 'registerProcess' | 'releaseProcess' | 'listProcesses' | 'close'\n>;\n\n/** Opens the SQLite-backed registry the rest of the monorepo already shares. */\nexport function createDefaultProcessRegistry(\n env: Readonly<Record<string, string | undefined>> = process.env,\n): ProcessRegistryPort {\n return createProcessRegistryService(env[REGISTRY_PATH_ENV]);\n}\n\nfunction sessionTag(sessionId: string): string {\n return `session:${sessionId}`;\n}\n\nfunction readString(record: ProcessRegistryRecord, key: string): string {\n const value = record.metadata?.[key];\n return typeof value === 'string' ? value : '';\n}\n\nfunction readNumber(record: ProcessRegistryRecord, key: string): number {\n const value = record.metadata?.[key];\n return typeof value === 'number' ? value : 0;\n}\n\nconst runCommand = promisify(execFile);\n\n/**\n * The process start time the registry stores to prove a pid is still the\n * process it registered.\n *\n * The registry resolves it itself when the caller omits it, and it does so with\n * `execFileSync`, which stops this process for the length of a `ps` on every\n * runner that starts. Reading it here costs the same subprocess and blocks\n * nothing: the command is already running by the time a runner is registered.\n *\n * The value has to match what the registry would have read, so the command and\n * the trimming are deliberately identical to its own.\n */\nasync function processStartedAt(pid: number): Promise<string | undefined> {\n if (process.platform === 'win32') return undefined;\n try {\n const { stdout } = await runCommand('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8' });\n return stdout.trim() || undefined;\n } catch {\n return undefined;\n }\n}\n\ntype ListenerRegistration = Readonly<{ key: string; listener: () => void }>;\n\n// Facet replacement can leave an in-flight tool and its publisher on different registry instances.\nconst listenersBySession = new Map<string, Set<() => void>>();\n\nexport class RunnerRegistry implements IRunnerRegistry {\n private readonly subscriptions = new Set<ListenerRegistration>();\n\n constructor(\n private readonly paths: IRunnerPaths,\n private readonly processControl: IProcessControl,\n private readonly registry: ProcessRegistryPort,\n private readonly environment: Readonly<Record<string, string | undefined>>,\n ) {}\n\n async register(input: RegisterRunnerInput): Promise<RunnerRecord> {\n const startedAt = new Date().toISOString();\n const rootSessionId = resolveRootSessionId(input.sessionId, this.environment);\n const identity = await processStartedAt(input.pid);\n const response = await this.registry.registerProcess({\n repositoryPath: this.repositoryPath(),\n serviceName: `${SERVICE_PREFIX}${input.id}`,\n serviceType: SERVICE_TYPE,\n pid: input.pid,\n command: input.command,\n ...(identity === undefined ? {} : { processStartedAt: identity }),\n tags: resolveProcessTags([RUNNER_TAG, `runner:${input.id}`, sessionTag(input.sessionId)]),\n metadata: {\n id: input.id,\n name: input.name,\n command: input.command,\n cwd: input.cwd,\n logPath: input.logPath,\n interactive: input.interactive,\n sessionId: input.sessionId,\n rootSessionId,\n backend: input.backend,\n ...(input.backendTarget ? { backendTarget: input.backendTarget } : {}),\n startedAt,\n hostPid: process.pid,\n },\n // The caller has already resolved name collisions against live records,\n // so anything still holding this name is stale.\n force: true,\n });\n if (!response.success) throw new Error(response.error ?? `Failed to register runner ${input.name}`);\n const record: RunnerRecord = {\n id: input.id,\n name: input.name,\n pid: input.pid,\n command: input.command,\n cwd: input.cwd,\n logPath: input.logPath,\n interactive: input.interactive,\n sessionId: input.sessionId,\n rootSessionId,\n startedAt,\n state: 'running',\n promoted: false,\n backend: input.backend,\n ...(input.backendTarget ? { backendTarget: input.backendTarget } : {}),\n hostPid: process.pid,\n };\n this.writeRecord(record);\n this.notify(record.sessionId);\n return record;\n }\n\n async list(): Promise<RunnerRecord[]> {\n const records = await this.registry.listProcesses({\n repositoryPath: this.repositoryPath(),\n serviceType: SERVICE_TYPE,\n tags: resolveProcessTags([RUNNER_TAG]),\n });\n return records.map(toRunnerRecord);\n }\n\n async listAcrossRepositories(): Promise<RunnerRecord[]> {\n const records = await this.registry.listProcesses({\n serviceType: SERVICE_TYPE,\n tags: resolveProcessTags([RUNNER_TAG]),\n });\n return records.map(toRunnerRecord);\n }\n\n async listBySession(sessionId: string): Promise<RunnerRecord[]> {\n const records = await this.list();\n return records.filter((record) => record.sessionId === sessionId);\n }\n\n async listByRootSession(rootSessionId: string): Promise<RunnerRecord[]> {\n const records = await this.list();\n return records.filter((record) => (record.rootSessionId ?? record.sessionId) === rootSessionId);\n }\n\n async listAll(sessionId?: string): Promise<RunnerRecord[]> {\n this.paths.ensureDirectories(sessionId);\n const records: RunnerRecord[] = [];\n const directory = this.paths.stateDirectory(sessionId);\n for (const entry of fs.readdirSync(directory)) {\n if (!isPrimaryMetadataEntry(entry)) continue;\n try {\n const parsed: unknown = JSON.parse(fs.readFileSync(`${directory}/${entry}`, 'utf8'));\n if (isRunnerRecord(parsed)) records.push(parsed);\n } catch (error) {\n process.emitWarning(`Could not read runner metadata ${entry}: ${String(error)}`);\n }\n }\n return records.sort((left, right) => right.startedAt.localeCompare(left.startedAt));\n }\n\n async get(id: string, sessionId?: string): Promise<RunnerRecord | undefined> {\n const target = this.paths.statePathFor(id, sessionId);\n try {\n const parsed: unknown = JSON.parse(fs.readFileSync(target, 'utf8'));\n if (!isRunnerRecord(parsed) || parsed.id !== id) {\n process.emitWarning(`Could not read runner metadata ${path.basename(target)}: invalid runner record`);\n return undefined;\n }\n return parsed;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === NOT_FOUND_ERROR_CODE) return undefined;\n process.emitWarning(`Could not read runner metadata ${path.basename(target)}: ${String(error)}`);\n return undefined;\n }\n }\n\n async markPromoted(id: string): Promise<RunnerRecord | undefined> {\n const record = await this.get(id);\n if (!record) return undefined;\n const promoted = { ...record, promoted: true };\n this.writeRecord(promoted);\n this.notify(promoted.sessionId);\n return promoted;\n }\n\n async requestTermination(\n id: string,\n intent: RunnerTerminationIntent | undefined,\n sessionId?: string,\n ): Promise<RunnerRecord | undefined> {\n const record = await this.get(id, sessionId);\n if (!record) return undefined;\n if (record.state === 'completed') return record;\n if (intent !== undefined && record.terminationIntent !== undefined) return record;\n const { terminationIntent: _previous, ...rest } = record;\n const updated: RunnerRecord = { ...rest, ...(intent ? { terminationIntent: intent } : {}) };\n this.writeRecord(updated);\n return updated;\n }\n\n async complete(id: string, outcome: CompleteRunnerInput, sessionId?: string): Promise<RunnerRecord | undefined> {\n const persisted = await this.get(id, sessionId);\n if (persisted?.state === 'completed') {\n await this.release(id, persisted.sessionId);\n return persisted;\n }\n const active = await this.list();\n const record =\n persisted ??\n active.find((candidate) => candidate.id === id && (sessionId === undefined || candidate.sessionId === sessionId));\n if (!record) {\n const belongsToAnotherSession =\n sessionId !== undefined && active.some((candidate) => candidate.id === id && candidate.sessionId !== sessionId);\n if (!belongsToAnotherSession) await this.release(id, sessionId);\n return undefined;\n }\n const completed: RunnerRecord = {\n ...record,\n state: 'completed',\n exit: {\n ...outcome,\n ...(outcome.signal !== null && outcome.terminationReason === undefined && record.terminationIntent === undefined\n ? { terminationReason: 'external_signal' as const }\n : {}),\n ...record.terminationIntent,\n finishedAt: new Date().toISOString(),\n },\n };\n this.writeRecord(completed);\n await this.release(id, completed.sessionId);\n return completed;\n }\n\n async release(id: string, sessionId?: string): Promise<void> {\n const activeSessionId = sessionId ?? (await this.list()).find((record) => record.id === id)?.sessionId;\n const ownedSessionId = activeSessionId ?? (await this.get(id))?.sessionId;\n const response = await this.registry.releaseProcess({\n repositoryPath: this.repositoryPath(),\n serviceName: `${SERVICE_PREFIX}${id}`,\n serviceType: SERVICE_TYPE,\n kill: false,\n releasePort: false,\n force: true,\n });\n if (!response.success && !response.error?.includes('No matching process entry')) {\n throw new Error(response.error ?? `Failed to release runner ${id}`);\n }\n if (ownedSessionId !== undefined) this.notify(ownedSessionId);\n }\n\n async pruneDead(): Promise<string[]> {\n const dead = (await this.list()).filter((record) => !this.processControl.isAlive(record.pid));\n for (const record of dead) {\n await this.complete(record.id, { reason: 'backend_lost', code: null, signal: null }, record.sessionId);\n }\n return dead.map((record) => record.id);\n }\n\n subscribe(listener: () => void, sessionId: string): () => void {\n const subscribed = (): void => listener();\n const registration = { key: this.subscriptionKey(sessionId), listener: subscribed };\n const listeners = listenersBySession.get(registration.key) ?? new Set<() => void>();\n listeners.add(subscribed);\n listenersBySession.set(registration.key, listeners);\n this.subscriptions.add(registration);\n return () => this.removeSubscription(registration);\n }\n\n close(): void {\n for (const registration of this.subscriptions) this.removeSubscription(registration);\n this.registry.close();\n }\n\n private notify(sessionId: string): void {\n for (const listener of listenersBySession.get(this.subscriptionKey(sessionId)) ?? []) {\n try {\n listener();\n } catch (error) {\n process.emitWarning(`Runner registry listener failed: ${String(error)}`);\n }\n }\n }\n\n private removeSubscription(registration: ListenerRegistration): void {\n const listeners = listenersBySession.get(registration.key);\n listeners?.delete(registration.listener);\n if (listeners?.size === 0) listenersBySession.delete(registration.key);\n this.subscriptions.delete(registration);\n }\n\n private subscriptionKey(sessionId: string): string {\n return JSON.stringify([path.resolve(this.paths.stateDirectory(sessionId)), sessionId]);\n }\n\n private repositoryPath(): string {\n return normalizeRepositoryPath(this.paths.repositoryPath());\n }\n\n private writeRecord(record: RunnerRecord): void {\n this.paths.ensureDirectories(record.sessionId);\n const target = this.paths.statePathFor(record.id, record.sessionId);\n const temporary = `${target}.${process.pid}.tmp`;\n fs.writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\\n`, 'utf8');\n fs.renameSync(temporary, target);\n }\n}\n\n/** Every field is read back from metadata this class wrote on registration. */\nfunction toRunnerRecord(record: ProcessRegistryRecord): RunnerRecord {\n return {\n id: readString(record, 'id'),\n name: readString(record, 'name'),\n pid: record.pid,\n command: readString(record, 'command'),\n cwd: readString(record, 'cwd'),\n logPath: readString(record, 'logPath'),\n interactive: record.metadata?.interactive === true,\n sessionId: readString(record, 'sessionId'),\n ...(readString(record, 'rootSessionId') ? { rootSessionId: readString(record, 'rootSessionId') } : {}),\n startedAt: readString(record, 'startedAt'),\n state: 'running',\n promoted: false,\n backend: readBackend(record.metadata?.backend),\n ...(readString(record, 'backendTarget') ? { backendTarget: readString(record, 'backendTarget') } : {}),\n hostPid: readNumber(record, 'hostPid'),\n };\n}\n\nfunction readBackend(value: unknown): RunnerRecord['backend'] {\n return value === 'rmux' || value === 'tmux' ? value : 'native';\n}\n\nfunction isPrimaryMetadataEntry(entry: string): boolean {\n return (\n entry.endsWith(JSON_EXTENSION) && !entry.endsWith(COMMAND_SIDECAR_SUFFIX) && !entry.endsWith(EXIT_SIDECAR_SUFFIX)\n );\n}\n","import type { DoomServerPluginContext } from '@agimon-ai/doompi-core/serverFacet';\nimport type { Context } from '@deepseek-ai/cordis';\n\nimport { createHeadlessBashTool, createHeadlessRunnersCommand } from '../../../../../services/headless';\nimport { summarizeLog } from '../../../../../services/logReader';\nimport { createRunnerDependencies } from '../../../../../services/runnerDependencies';\nimport { createRunnerServerRuntime } from '../../../../../services/runnerServerRuntime';\n\nexport const RUNNER_SERVER_SCOPE_SERVICE = 'doom/runner-server-scope';\n\ndeclare module '@deepseek-ai/cordis' {\n interface Context {\n 'doom/runner-server-scope': RunnerServerScope;\n }\n}\n\nexport const createRunnerServerRoot = ({ host, agent, signal }: DoomServerPluginContext) => {\n if (!host.context.directEvents) throw new Error('Runner headless facet requires the session direct event bus.');\n if (!host.context.cwd || !host.context.environment) {\n throw new Error('Runner headless facet requires session cwd and environment.');\n }\n const dependencies = createRunnerDependencies({\n cwd: host.context.cwd,\n environment: host.context.environment,\n ...(host.context.executionBudget === undefined ? {} : { executionBudget: host.context.executionBudget }),\n });\n const execution = agent?.context;\n const wakeAgent =\n execution &&\n (async (content: string, requestId: string): Promise<void> => {\n try {\n // 'steer' joins a running turn and starts one on an idle session.\n // Reuse completion identity across retries and acknowledge admission, not settlement.\n if (!execution.session.admitPrompt) throw new Error('The session cannot admit a runner notification.');\n await execution.session.admitPrompt(content, 'steer', undefined, requestId);\n } catch (error) {\n // Keep completion ownership with the watcher until admission succeeds.\n await Promise.resolve()\n .then(() => execution.client.notify({ body: String(error), level: 'warning' }))\n .catch(() => undefined);\n throw error;\n }\n });\n const runtime = createRunnerServerRuntime(dependencies, host.context.directEvents, wakeAgent);\n const value = {\n tool: createHeadlessBashTool(\n {\n run: async (request) => {\n await runtime.ensureSession(request.sessionId);\n const result = await dependencies.bashRunService.run({\n ...request,\n ...(signal ? { signal: request.signal ? AbortSignal.any([signal, request.signal]) : signal } : {}),\n });\n // Without this, nothing tells the agent the runner ended.\n if (result.kind === 'promoted') runtime.watchRunner(result.id);\n return result;\n },\n },\n summarizeLog,\n ),\n command: createHeadlessRunnersCommand(dependencies),\n };\n return {\n value,\n services: [\n runtime.backgroundWorkPlugin,\n (context: Context) => {\n context.plugin((providerContext) => providerContext.provide(RUNNER_SERVER_SCOPE_SERVICE, value));\n },\n ],\n activities: [runtime.activity],\n onStop: runtime.dispose,\n };\n};\n\nexport type RunnerServerScope = Awaited<ReturnType<typeof createRunnerServerRoot>>['value'];\n","import { defineMcpTool, type DoomMcpPluginContext } from '@agimon-ai/doompi-core/mcpFacet';\n\nimport { RUNNER_SERVER_SCOPE_SERVICE, type RunnerServerScope } from '../_lib/serverRoot';\n\nexport default defineMcpTool((context: DoomMcpPluginContext) => {\n const scope = context.services.get<RunnerServerScope>(RUNNER_SERVER_SCOPE_SERVICE);\n if (!scope) throw new Error('Runner session service is unavailable.');\n return {\n ...scope.tool,\n execute(toolCallId, parameters, signal, onUpdate, execution) {\n return scope.tool.execute(\n toolCallId,\n parameters,\n AbortSignal.any([context.signal, signal ?? context.signal]),\n onUpdate,\n execution,\n );\n },\n };\n});\n","// Generated by @agimon-ai/doompi-build. Do not edit by hand.\nimport { defineMcpPlugin, type DoomMcpSessionPlugin } from '@agimon-ai/doompi-core/mcpFacet';\n\nimport skillDoompiUseRunner from '../src/extensions/workspaces/sessions/(backend)/skill/doompi-use-runner.mcp';\nimport toolBash from '../src/extensions/workspaces/sessions/(backend)/tool/bash.mcp';\n\ntype Factory<T, C> = (context: C) => T;\nconst at = <T, C>(value: T | Factory<T, C>, context: C): T =>\n typeof value === 'function' ? (value as Factory<T, C>)(context) : value;\nconst via = <T>(identity: Record<string, unknown>, value: unknown): T =>\n (value === undefined ? undefined : { ...identity, ...(value as object) }) as T;\n\nconst withWidget = <T>(key: string, value: T): T => value === undefined ? value : ({\n ...value, _meta: { ...(value as { _meta?: object })._meta, [\"doompi/widget\"]: key },\n});\n\nexport const mcp = defineMcpPlugin({\n name: '@agimon-ai/doompi-runner',\n session: (context) => ({\n get skills(): DoomMcpSessionPlugin['skills'] { return [at(skillDoompiUseRunner, context)]; },\n get tools(): DoomMcpSessionPlugin['tools'] { return [withWidget(\"@agimon-ai/doompi-runner/bash\", via({ name: 'bash' }, at(toolBash, context)))]; },\n }) satisfies DoomMcpSessionPlugin,\n});\n\nexport default mcp;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAGA,IAAA,iCAAA,GAAeA,gCAAAA,eAAAA,CAAe;CAC5B,MAAM;CACN,aACE;CACF,aAAA,GAAYC,mCAAAA,oBAAAA,CAAAA,QAAAA,KAAAA,CAAAA,CAAAA,cAAAA,UAAAA,CAAAA,CAAAA,MAAqC,wCAAwC;AAC3F,CAAC;ACE+BC,QAAAA,KAAK,OAAO;CAC1C,SAASA,QAAAA,KAAK,OAAO,EAAE,aAAa,0BAA0B,CAAC;CAC/D,SAASA,QAAAA,KAAK,SAASA,QAAAA,KAAK,OAAO,EAAE,aAAa,oDAAoD,CAAC,CAAC;CACxG,YAAYA,QAAAA,KAAK,SACfA,QAAAA,KAAK,QAAQ,EACX,aACE,yJACJ,CAAC,CACH;CACA,aAAaA,QAAAA,KAAK,SAChBA,QAAAA,KAAK,QAAQ,EACX,aACE,6LACJ,CAAC,CACH;CACA,MAAMA,QAAAA,KAAK,SACTA,QAAAA,KAAK,OAAO,EACV,aAAa,+FACf,CAAC,CACH;AACF,CAAC;ACjBoB,IAAI,OACvB;CACE;CACA;CACA;CACA;AACF,CAAC,CAAC,KAAK,GAAG,GACV,GACF;AAWoC,IAAI,OACtC;CACE;CACA;CACA;CACA;AACF,CAAC,CAAC,KAAK,GAAG,GACV,GACF;;;;ACdA,MAAM,EAAE,UAAU,qBAAqBC,gBAAAA;CCoCjC,GAAaC,UAAAA,UAAAA,CAAUC,mBAAAA,QAAQ;;;ACtDrC,MAAa,8BAA8B;;;ACJ3C,IAAA,oBAAA,GAAeC,gCAAAA,cAAAA,EAAe,YAAkC;CAC9D,MAAM,QAAQ,QAAQ,SAAS,IAAuB,2BAA2B;CACjF,IAAI,CAAC,OAAO,MAAM,IAAI,MAAM,wCAAwC;CACpE,OAAO;EACL,GAAG,MAAM;EACT,QAAQ,YAAY,YAAY,QAAQ,UAAU,WAAW;GAC3D,OAAO,MAAM,KAAK,QAChB,YACA,YACA,YAAY,IAAI,CAAC,QAAQ,QAAQ,UAAU,QAAQ,MAAM,CAAC,GAC1D,UACA,SACF;EACF;CACF;AACF,CAAC;;;ACZD,MAAM,MAAY,OAA0B,YAC1C,OAAO,UAAU,aAAc,MAAwB,OAAO,IAAI;AACpE,MAAM,OAAU,UAAmC,UAChD,UAAU,KAAA,IAAY,KAAA,IAAY;CAAE,GAAG;CAAU,GAAI;AAAiB;AAEzE,MAAM,cAAiB,KAAa,UAAgB,UAAU,KAAA,IAAY,QAAS;CACjF,GAAG;CAAO,OAAO;EAAE,GAAI,MAA6B;GAAQ,kBAAkB;CAAI;AACpF;AAEA,MAAa,OAAA,GAAMC,gCAAAA,gBAAAA,CAAgB;CACjC,MAAM;CACN,UAAU,aAAa;EACrB,IAAI,SAAyC;GAAE,OAAO,CAAC,GAAGC,+BAAsB,OAAO,CAAC;EAAG;EAC3F,IAAI,QAAuC;GAAE,OAAO,CAAC,WAAW,iCAAiC,IAAI,EAAE,MAAM,OAAO,GAAG,GAAGC,kBAAU,OAAO,CAAC,CAAC,CAAC;EAAG;CACnJ;AACF,CAAC"}
|
|
1
|
+
{"version":3,"file":"mcp.cjs","names":["defineMcpSkill","readPackageResource","Type","headless","promisify","execFile","defineMcpTool","defineMcpPlugin","skillDoompiUseRunner","toolBash"],"sources":["../../src/extensions/workspaces/sessions/(backend)/skill/doompi-use-runner.mcp.ts","../../src/schemas/bashTool.ts","../../src/services/ansiScrub/index.ts","../../src/services/ptyHost/index.ts","../../src/services/runnerRegistry/index.ts","../../src/extensions/workspaces/sessions/(backend)/_lib/serverRoot.ts","../../src/extensions/workspaces/sessions/(backend)/tool/bash.mcp.ts","../../generated/mcp.ts"],"sourcesContent":["import { defineMcpSkill } from '@agimon-ai/doompi-core/mcpFacet';\nimport { readPackageResource } from '@agimon-ai/doompi-core/serverFacet';\n\nexport default defineMcpSkill({\n name: 'doompi-use-runner',\n description:\n 'Use Doom Pi Runner to supervise shell commands, inspect durable logs, provide interactive input, and stop background runs.',\n read: () => readPackageResource(import.meta.url, 'src/prompts/doompi-use-runner/SKILL.md'),\n});\n","import { type Static, Type } from 'typebox';\n\n/** Replaces pi's built-in tool of the same name, so hooks keyed on `bash` keep working. */\n\n/**\n * Built-in bash parameters plus the backgrounding controls.\n *\n * `command` and `timeout` keep their original names and wording so the tool is\n * a drop-in replacement. Every `description` is prompt copy the model reads.\n */\nexport const BashParamsSchema = Type.Object({\n command: Type.String({ description: 'Bash command to execute' }),\n timeout: Type.Optional(Type.Number({ description: 'Timeout in seconds (optional, no default timeout)' })),\n background: Type.Optional(\n Type.Boolean({\n description:\n 'Start the command in the background immediately instead of waiting. Use for dev servers, watchers and other commands you already know will not finish.',\n }),\n ),\n interactive: Type.Optional(\n Type.Boolean({\n description:\n 'Run under a pseudo terminal so the command can prompt. Use only when the command asks for confirmation or input; answer it with doom-runner input. Logs from interactive runs are noisier.',\n }),\n ),\n name: Type.Optional(\n Type.String({\n description: 'Preferred runner name if the command ends up in the background. A name in use gets a suffix.',\n }),\n ),\n});\n\nexport type BashParams = Static<typeof BashParamsSchema>;\n","import { CONTROL_PATTERN } from '../../constants/ansiScrub';\n/**\n * Turns terminal output into text worth grepping.\n *\n * PTY output is written for a screen, not a file: colours, cursor moves and\n * carriage-return redraws all end up in the byte stream. Without this a single\n * progress bar becomes thousands of near-identical log lines.\n */\n\n/**\n * CSI sequences (colours, cursor moves), OSC strings, the two-byte Fe escapes,\n * and the keypad-mode escapes shells emit around every prompt.\n */\nconst ANSI_PATTERN = new RegExp(\n [\n '\\\\u001B\\\\[[0-9;?]*[ -/]*[@-~]',\n '\\\\u001B\\\\][^\\\\u0007\\\\u001B]*(?:\\\\u0007|\\\\u001B\\\\\\\\)',\n '\\\\u001B[@-Z\\\\\\\\-_]',\n '\\\\u001B[=><]',\n ].join('|'),\n 'g',\n);\n\n/** Bell and backspace, which a terminal consumes rather than displays. */\n// oxlint-disable-next-line no-control-regex -- matching terminal control bytes is the whole job\n\n/**\n * The same set minus SGR, the sequence that only sets colour and weight.\n *\n * The final byte class omits `m`, so `ESC[32m` survives while cursor moves,\n * OSC strings and keypad escapes are still removed.\n */\nconst ANSI_PATTERN_KEEPING_COLOUR = new RegExp(\n [\n '\\\\u001B\\\\[[0-9;?]*[ -/]*[@-ln-~]',\n '\\\\u001B\\\\][^\\\\u0007\\\\u001B]*(?:\\\\u0007|\\\\u001B\\\\\\\\)',\n '\\\\u001B[@-Z\\\\\\\\-_]',\n '\\\\u001B[=><]',\n ].join('|'),\n 'g',\n);\n\n/** Removes escape sequences, leaving the characters a user would have seen. */\nexport function stripAnsi(text: string): string {\n return text.replace(ANSI_PATTERN, '').replace(CONTROL_PATTERN, '');\n}\n\n/**\n * Removes everything `stripAnsi` does except colour.\n *\n * Logs are read back by a terminal renderer as well as by grep, and a command\n * that colours its own output has already said how it should look.\n */\nexport function stripAnsiKeepingColour(text: string): string {\n return text.replace(ANSI_PATTERN_KEEPING_COLOUR, '').replace(CONTROL_PATTERN, '');\n}\n\n/**\n * Collapses carriage-return redraws.\n *\n * A bare `\\r` returns the cursor to the start of the current line, so whatever\n * follows overwrites it. Only the final state of each line is kept. The `\\r` of\n * a CRLF pair is a line terminator rather than a redraw, so it is normalised\n * away first: a pseudo terminal ends every line that way, and treating it as a\n * redraw would blank the whole log.\n */\nexport function collapseCarriageReturns(text: string): string {\n return text\n .replace(/\\r\\n/g, '\\n')\n .split('\\n')\n .map((line) => (line.includes('\\r') ? (line.split('\\r').at(-1) ?? '') : line))\n .join('\\n');\n}\n\n/** Full scrub applied to PTY output before it reaches the log file. */\nexport function scrubTerminalOutput(text: string): string {\n return collapseCarriageReturns(stripAnsiKeepingColour(text));\n}\n\n/** Plain-text scrub, for the copy the model reads. */\nexport function scrubTerminalOutputToPlainText(text: string): string {\n return collapseCarriageReturns(stripAnsi(text));\n}\n","import headless, { type Terminal } from '@xterm/headless';\n\nimport {\n DEFAULT_COLS,\n DEFAULT_ROWS,\n LIVENESS_POLL_MS,\n MEMORY_BUFFER_FACTOR,\n NEWLINE,\n SCROLLBACK,\n TERM_GRACE_MS,\n} from '../../constants/ptyHost';\nimport { PI_SESSION_ID_ENV } from '../../constants/session';\nimport type { IClock } from '../../types/clock';\nimport type { ILogFile } from '../../types/logFile';\nimport type { IProcessControl } from '../../types/processControl';\nimport type { IPtyHost, PtyLaunchRequest, PtyRun } from '../../types/ptyHost';\nimport type { IPtySpawner, PtyProcess } from '../../types/ptySpawner';\nimport type { ExitResult } from '../../types/spawner';\nimport { scrubTerminalOutput } from '../ansiScrub';\nimport { getResultMaxBytes } from '../runnerConfig';\nimport { NO_TERMINAL_INPUT_ENV } from '../runnerSupervisor';\n\n/** Enough history for the overlay to scroll without holding a whole build. */\n\n// Use the CommonJS default export: Node cannot detect this package's named exports.\n// A static import also lets server bundles include it without a live node_modules lookup.\nconst { Terminal: HeadlessTerminal } = headless;\n\nexport class PtyHost implements IPtyHost {\n private readonly runs = new Map<string, PtyRun>();\n\n constructor(\n private readonly spawner: IPtySpawner,\n private readonly logFile: ILogFile,\n private readonly processControl: IProcessControl,\n private readonly clock: IClock,\n private readonly environment: Readonly<Record<string, string | undefined>> = process.env,\n ) {}\n\n async launch(request: PtyLaunchRequest): Promise<PtyRun> {\n const cols = request.cols ?? DEFAULT_COLS;\n const rows = request.rows ?? DEFAULT_ROWS;\n const writer = this.logFile.open(request.id);\n const terminal = new HeadlessTerminal({ cols, rows, scrollback: SCROLLBACK, allowProposedApi: true });\n const memoryLimit = getResultMaxBytes(this.environment) * MEMORY_BUFFER_FACTOR;\n\n const child = await this.spawner.spawn({\n command: request.command,\n cwd: request.cwd,\n env: { ...this.environment, ...NO_TERMINAL_INPUT_ENV, [PI_SESSION_ID_ENV]: request.sessionId },\n cols,\n rows,\n });\n\n let buffer = '';\n let buffering = true;\n let settled = false;\n let resolveCompletion: (result: ExitResult) => void = () => undefined;\n const completion = new Promise<ExitResult>((resolve) => {\n resolveCompletion = resolve;\n });\n const dataHandlers = new Set<(data: string) => void>();\n\n child.onData((data) => {\n // The terminal keeps the screen; the log keeps text worth grepping.\n terminal.write(data);\n const scrubbed = scrubTerminalOutput(data);\n writer.append(scrubbed);\n if (buffering) {\n buffer += scrubbed;\n if (buffer.length > memoryLimit) buffer = buffer.slice(-memoryLimit);\n }\n for (const handler of dataHandlers) handler(data);\n });\n\n child.onExit((result) => {\n if (settled) return;\n settled = true;\n this.runs.delete(request.name);\n writer.close();\n resolveCompletion({ code: result.exitCode, signal: null });\n });\n\n const run: PtyRun = {\n id: request.id,\n name: request.name,\n pid: child.pid,\n logPath: writer.path,\n backend: 'native',\n output: () => buffer,\n completion: () => completion,\n detach: () => {\n buffering = false;\n buffer = '';\n },\n stop: () => this.stopChild(child, request.name),\n write: (text) => child.write(text),\n screen: () => renderScreen(terminal),\n onData: (handler) => {\n dataHandlers.add(handler);\n return () => dataHandlers.delete(handler);\n },\n resize: (nextCols, nextRows) => {\n child.resize(nextCols, nextRows);\n terminal.resize(nextCols, nextRows);\n },\n };\n\n this.runs.set(request.name, run);\n return run;\n }\n\n get(name: string): PtyRun | undefined {\n return this.runs.get(name);\n }\n\n write(name: string, text: string): boolean {\n const run = this.runs.get(name);\n if (!run) return false;\n run.write(text.endsWith(NEWLINE) ? text : `${text}${NEWLINE}`);\n return true;\n }\n\n list(): PtyRun[] {\n return [...this.runs.values()];\n }\n\n async disposeAll(): Promise<void> {\n const runs = [...this.runs.values()];\n this.runs.clear();\n for (const run of runs) await run.stop();\n }\n\n private async stopChild(child: PtyProcess, name: string): Promise<boolean> {\n this.runs.delete(name);\n if (!this.processControl.isAlive(child.pid)) return false;\n\n child.kill('SIGTERM');\n const deadline = this.clock.now() + TERM_GRACE_MS;\n while (this.clock.now() < deadline) {\n if (!this.processControl.isAlive(child.pid)) return true;\n await new Promise<void>((resolve) => {\n this.clock.after(LIVENESS_POLL_MS, resolve);\n });\n }\n child.kill('SIGKILL');\n return true;\n }\n}\n\n/** The visible rows, trimmed of trailing blanks, as plain text. */\nfunction renderScreen(terminal: Terminal): string {\n const buffer = terminal.buffer.active;\n const lines: string[] = [];\n for (let row = buffer.baseY; row < buffer.baseY + terminal.rows; row += 1) {\n lines.push(buffer.getLine(row)?.translateToString(true) ?? '');\n }\n while (lines.length > 0 && lines.at(-1) === '') lines.pop();\n return lines.join(NEWLINE);\n}\n","import { execFile } from 'node:child_process';\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport { promisify } from 'node:util';\n\nimport { resolveRootSessionId } from '@agimon-ai/doompi-core/childProcess';\nimport {\n createProcessRegistryService,\n normalizeRepositoryPath,\n type ProcessRegistryRecord,\n type ProcessRegistryService,\n resolveProcessTags,\n} from '@agimon-ai/foundation-process-registry';\n\nimport {\n COMMAND_SIDECAR_SUFFIX,\n EXIT_SIDECAR_SUFFIX,\n JSON_EXTENSION,\n NOT_FOUND_ERROR_CODE,\n REGISTRY_PATH_ENV,\n RUNNER_TAG,\n SERVICE_PREFIX,\n SERVICE_TYPE,\n} from '../../constants/runnerRegistry';\nimport type { IProcessControl } from '../../types/processControl';\nimport type {\n CompleteRunnerInput,\n IRunnerRegistry,\n RegisterRunnerInput,\n RunnerRecord,\n RunnerTerminationIntent,\n} from '../../types/runnerRegistry';\nimport type { IRunnerPaths } from '../runnerPaths/type';\nimport { isRunnerRecord } from '../runnerRecord';\n\n/** The slice of the shared registry service this wrapper depends on. */\nexport type ProcessRegistryPort = Pick<\n ProcessRegistryService,\n 'registerProcess' | 'releaseProcess' | 'listProcesses' | 'close'\n>;\n\n/** Opens the SQLite-backed registry the rest of the monorepo already shares. */\nexport function createDefaultProcessRegistry(\n env: Readonly<Record<string, string | undefined>> = process.env,\n): ProcessRegistryPort {\n return createProcessRegistryService(env[REGISTRY_PATH_ENV]);\n}\n\nfunction sessionTag(sessionId: string): string {\n return `session:${sessionId}`;\n}\n\nfunction readString(record: ProcessRegistryRecord, key: string): string {\n const value = record.metadata?.[key];\n return typeof value === 'string' ? value : '';\n}\n\nfunction readNumber(record: ProcessRegistryRecord, key: string): number {\n const value = record.metadata?.[key];\n return typeof value === 'number' ? value : 0;\n}\n\nconst runCommand = promisify(execFile);\n\n/**\n * The process start time the registry stores to prove a pid is still the\n * process it registered.\n *\n * The registry resolves it itself when the caller omits it, and it does so with\n * `execFileSync`, which stops this process for the length of a `ps` on every\n * runner that starts. Reading it here costs the same subprocess and blocks\n * nothing: the command is already running by the time a runner is registered.\n *\n * The value has to match what the registry would have read, so the command and\n * the trimming are deliberately identical to its own.\n */\nasync function processStartedAt(pid: number): Promise<string | undefined> {\n if (process.platform === 'win32') return undefined;\n try {\n const { stdout } = await runCommand('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8' });\n return stdout.trim() || undefined;\n } catch {\n return undefined;\n }\n}\n\ntype ListenerRegistration = Readonly<{ key: string; listener: () => void }>;\n\n// Facet replacement can leave an in-flight tool and its publisher on different registry instances.\nconst listenersBySession = new Map<string, Set<() => void>>();\n\nexport class RunnerRegistry implements IRunnerRegistry {\n private readonly subscriptions = new Set<ListenerRegistration>();\n\n constructor(\n private readonly paths: IRunnerPaths,\n private readonly processControl: IProcessControl,\n private readonly registry: ProcessRegistryPort,\n private readonly environment: Readonly<Record<string, string | undefined>>,\n ) {}\n\n async register(input: RegisterRunnerInput): Promise<RunnerRecord> {\n const startedAt = new Date().toISOString();\n const rootSessionId = resolveRootSessionId(input.sessionId, this.environment);\n const identity = await processStartedAt(input.pid);\n const response = await this.registry.registerProcess({\n repositoryPath: this.repositoryPath(),\n serviceName: `${SERVICE_PREFIX}${input.id}`,\n serviceType: SERVICE_TYPE,\n pid: input.pid,\n command: input.command,\n ...(identity === undefined ? {} : { processStartedAt: identity }),\n tags: resolveProcessTags([RUNNER_TAG, `runner:${input.id}`, sessionTag(input.sessionId)]),\n metadata: {\n id: input.id,\n name: input.name,\n command: input.command,\n cwd: input.cwd,\n logPath: input.logPath,\n interactive: input.interactive,\n sessionId: input.sessionId,\n rootSessionId,\n backend: input.backend,\n ...(input.backendTarget ? { backendTarget: input.backendTarget } : {}),\n startedAt,\n hostPid: process.pid,\n },\n // The caller has already resolved name collisions against live records,\n // so anything still holding this name is stale.\n force: true,\n });\n if (!response.success) throw new Error(response.error ?? `Failed to register runner ${input.name}`);\n const record: RunnerRecord = {\n id: input.id,\n name: input.name,\n pid: input.pid,\n command: input.command,\n cwd: input.cwd,\n logPath: input.logPath,\n interactive: input.interactive,\n sessionId: input.sessionId,\n rootSessionId,\n startedAt,\n state: 'running',\n promoted: false,\n backend: input.backend,\n ...(input.backendTarget ? { backendTarget: input.backendTarget } : {}),\n hostPid: process.pid,\n };\n this.writeRecord(record);\n this.notify(record.sessionId);\n return record;\n }\n\n async list(): Promise<RunnerRecord[]> {\n const records = await this.registry.listProcesses({\n repositoryPath: this.repositoryPath(),\n serviceType: SERVICE_TYPE,\n tags: resolveProcessTags([RUNNER_TAG]),\n });\n return records.map(toRunnerRecord);\n }\n\n async listAcrossRepositories(): Promise<RunnerRecord[]> {\n const records = await this.registry.listProcesses({\n serviceType: SERVICE_TYPE,\n tags: resolveProcessTags([RUNNER_TAG]),\n });\n return records.map(toRunnerRecord);\n }\n\n async listBySession(sessionId: string): Promise<RunnerRecord[]> {\n const records = await this.list();\n return records.filter((record) => record.sessionId === sessionId);\n }\n\n async listByRootSession(rootSessionId: string): Promise<RunnerRecord[]> {\n const records = await this.list();\n return records.filter((record) => (record.rootSessionId ?? record.sessionId) === rootSessionId);\n }\n\n async listAll(sessionId?: string): Promise<RunnerRecord[]> {\n this.paths.ensureDirectories(sessionId);\n const records: RunnerRecord[] = [];\n const directory = this.paths.stateDirectory(sessionId);\n for (const entry of fs.readdirSync(directory)) {\n if (!isPrimaryMetadataEntry(entry)) continue;\n try {\n const parsed: unknown = JSON.parse(fs.readFileSync(`${directory}/${entry}`, 'utf8'));\n if (isRunnerRecord(parsed)) records.push(parsed);\n } catch (error) {\n process.emitWarning(`Could not read runner metadata ${entry}: ${String(error)}`);\n }\n }\n return records.sort((left, right) => right.startedAt.localeCompare(left.startedAt));\n }\n\n async get(id: string, sessionId?: string): Promise<RunnerRecord | undefined> {\n const target = this.paths.statePathFor(id, sessionId);\n try {\n const parsed: unknown = JSON.parse(fs.readFileSync(target, 'utf8'));\n if (!isRunnerRecord(parsed) || parsed.id !== id) {\n process.emitWarning(`Could not read runner metadata ${path.basename(target)}: invalid runner record`);\n return undefined;\n }\n return parsed;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === NOT_FOUND_ERROR_CODE) return undefined;\n process.emitWarning(`Could not read runner metadata ${path.basename(target)}: ${String(error)}`);\n return undefined;\n }\n }\n\n async markPromoted(id: string): Promise<RunnerRecord | undefined> {\n const record = await this.get(id);\n if (!record) return undefined;\n const promoted = { ...record, promoted: true };\n this.writeRecord(promoted);\n this.notify(promoted.sessionId);\n return promoted;\n }\n\n async requestTermination(\n id: string,\n intent: RunnerTerminationIntent | undefined,\n sessionId?: string,\n ): Promise<RunnerRecord | undefined> {\n const record = await this.get(id, sessionId);\n if (!record) return undefined;\n if (record.state === 'completed') return record;\n if (intent !== undefined && record.terminationIntent !== undefined) return record;\n const { terminationIntent: _previous, ...rest } = record;\n const updated: RunnerRecord = { ...rest, ...(intent ? { terminationIntent: intent } : {}) };\n this.writeRecord(updated);\n return updated;\n }\n\n async complete(id: string, outcome: CompleteRunnerInput, sessionId?: string): Promise<RunnerRecord | undefined> {\n const persisted = await this.get(id, sessionId);\n if (persisted?.state === 'completed') {\n await this.release(id, persisted.sessionId);\n return persisted;\n }\n const active = await this.list();\n const record =\n persisted ??\n active.find((candidate) => candidate.id === id && (sessionId === undefined || candidate.sessionId === sessionId));\n if (!record) {\n const belongsToAnotherSession =\n sessionId !== undefined && active.some((candidate) => candidate.id === id && candidate.sessionId !== sessionId);\n if (!belongsToAnotherSession) await this.release(id, sessionId);\n return undefined;\n }\n const completed: RunnerRecord = {\n ...record,\n state: 'completed',\n exit: {\n ...outcome,\n ...(outcome.signal !== null && outcome.terminationReason === undefined && record.terminationIntent === undefined\n ? { terminationReason: 'external_signal' as const }\n : {}),\n ...record.terminationIntent,\n finishedAt: new Date().toISOString(),\n },\n };\n this.writeRecord(completed);\n await this.release(id, completed.sessionId);\n return completed;\n }\n\n async release(id: string, sessionId?: string): Promise<void> {\n const activeSessionId = sessionId ?? (await this.list()).find((record) => record.id === id)?.sessionId;\n const ownedSessionId = activeSessionId ?? (await this.get(id))?.sessionId;\n const response = await this.registry.releaseProcess({\n repositoryPath: this.repositoryPath(),\n serviceName: `${SERVICE_PREFIX}${id}`,\n serviceType: SERVICE_TYPE,\n kill: false,\n releasePort: false,\n force: true,\n });\n if (!response.success && !response.error?.includes('No matching process entry')) {\n throw new Error(response.error ?? `Failed to release runner ${id}`);\n }\n if (ownedSessionId !== undefined) this.notify(ownedSessionId);\n }\n\n async pruneDead(): Promise<string[]> {\n const dead = (await this.list()).filter((record) => !this.processControl.isAlive(record.pid));\n for (const record of dead) {\n await this.complete(record.id, { reason: 'backend_lost', code: null, signal: null }, record.sessionId);\n }\n return dead.map((record) => record.id);\n }\n\n subscribe(listener: () => void, sessionId: string): () => void {\n const subscribed = (): void => listener();\n const registration = { key: this.subscriptionKey(sessionId), listener: subscribed };\n const listeners = listenersBySession.get(registration.key) ?? new Set<() => void>();\n listeners.add(subscribed);\n listenersBySession.set(registration.key, listeners);\n this.subscriptions.add(registration);\n return () => this.removeSubscription(registration);\n }\n\n close(): void {\n for (const registration of this.subscriptions) this.removeSubscription(registration);\n this.registry.close();\n }\n\n private notify(sessionId: string): void {\n for (const listener of listenersBySession.get(this.subscriptionKey(sessionId)) ?? []) {\n try {\n listener();\n } catch (error) {\n process.emitWarning(`Runner registry listener failed: ${String(error)}`);\n }\n }\n }\n\n private removeSubscription(registration: ListenerRegistration): void {\n const listeners = listenersBySession.get(registration.key);\n listeners?.delete(registration.listener);\n if (listeners?.size === 0) listenersBySession.delete(registration.key);\n this.subscriptions.delete(registration);\n }\n\n private subscriptionKey(sessionId: string): string {\n return JSON.stringify([path.resolve(this.paths.stateDirectory(sessionId)), sessionId]);\n }\n\n private repositoryPath(): string {\n return normalizeRepositoryPath(this.paths.repositoryPath());\n }\n\n private writeRecord(record: RunnerRecord): void {\n this.paths.ensureDirectories(record.sessionId);\n const target = this.paths.statePathFor(record.id, record.sessionId);\n const temporary = `${target}.${process.pid}.tmp`;\n fs.writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\\n`, 'utf8');\n fs.renameSync(temporary, target);\n }\n}\n\n/** Every field is read back from metadata this class wrote on registration. */\nfunction toRunnerRecord(record: ProcessRegistryRecord): RunnerRecord {\n return {\n id: readString(record, 'id'),\n name: readString(record, 'name'),\n pid: record.pid,\n command: readString(record, 'command'),\n cwd: readString(record, 'cwd'),\n logPath: readString(record, 'logPath'),\n interactive: record.metadata?.interactive === true,\n sessionId: readString(record, 'sessionId'),\n ...(readString(record, 'rootSessionId') ? { rootSessionId: readString(record, 'rootSessionId') } : {}),\n startedAt: readString(record, 'startedAt'),\n state: 'running',\n promoted: false,\n backend: readBackend(record.metadata?.backend),\n ...(readString(record, 'backendTarget') ? { backendTarget: readString(record, 'backendTarget') } : {}),\n hostPid: readNumber(record, 'hostPid'),\n };\n}\n\nfunction readBackend(value: unknown): RunnerRecord['backend'] {\n return value === 'rmux' || value === 'tmux' ? value : 'native';\n}\n\nfunction isPrimaryMetadataEntry(entry: string): boolean {\n return (\n entry.endsWith(JSON_EXTENSION) && !entry.endsWith(COMMAND_SIDECAR_SUFFIX) && !entry.endsWith(EXIT_SIDECAR_SUFFIX)\n );\n}\n","import type { DoomServerPluginContext } from '@agimon-ai/doompi-core/serverFacet';\nimport type { Context } from '@deepseek-ai/cordis';\n\nimport { createHeadlessBashTool, createHeadlessRunnersCommand } from '../../../../../services/headless';\nimport { summarizeLog } from '../../../../../services/logReader';\nimport { createRunnerDependencies } from '../../../../../services/runnerDependencies';\nimport { createRunnerServerRuntime } from '../../../../../services/runnerServerRuntime';\n\nexport const RUNNER_SERVER_SCOPE_SERVICE = 'doom/runner-server-scope';\n\ndeclare module '@deepseek-ai/cordis' {\n interface Context {\n 'doom/runner-server-scope': RunnerServerScope;\n }\n}\n\nexport const createRunnerServerRoot = ({ host, agent, signal }: DoomServerPluginContext) => {\n if (!host.context.directEvents) throw new Error('Runner headless facet requires the session direct event bus.');\n if (!host.context.cwd || !host.context.environment) {\n throw new Error('Runner headless facet requires session cwd and environment.');\n }\n const dependencies = createRunnerDependencies({\n cwd: host.context.cwd,\n environment: host.context.environment,\n ...(host.context.executionBudget === undefined ? {} : { executionBudget: host.context.executionBudget }),\n });\n const execution = agent?.context;\n const wakeAgent =\n execution &&\n (async (content: string, requestId: string): Promise<void> => {\n try {\n // 'steer' joins a running turn and starts one on an idle session.\n // Reuse completion identity across retries and acknowledge admission, not settlement.\n if (!execution.session.admitPrompt) throw new Error('The session cannot admit a runner notification.');\n await execution.session.admitPrompt(content, 'steer', undefined, requestId);\n } catch (error) {\n // Keep completion ownership with the watcher until admission succeeds.\n await Promise.resolve()\n .then(() => execution.client.notify({ body: String(error), level: 'warning' }))\n .catch(() => undefined);\n throw error;\n }\n });\n const runtime = createRunnerServerRuntime(dependencies, host.context.directEvents, wakeAgent);\n const bash = (wakeOnExit: boolean) =>\n createHeadlessBashTool(\n {\n run: async (request) => {\n await runtime.ensureSession(request.sessionId);\n const result = await dependencies.bashRunService.run({\n ...request,\n ...(signal ? { signal: request.signal ? AbortSignal.any([signal, request.signal]) : signal } : {}),\n });\n // Without this, nothing tells the agent the runner ended.\n if (wakeOnExit && result.kind === 'promoted') runtime.watchRunner(result.id);\n return result;\n },\n },\n summarizeLog,\n );\n const value = {\n tool: bash(true),\n // Remote MCP runners must not wake the local agent; the remote caller polls instead.\n remoteTool: bash(false),\n command: createHeadlessRunnersCommand(dependencies),\n };\n return {\n value,\n services: [\n runtime.backgroundWorkPlugin,\n (context: Context) => {\n context.plugin((providerContext) => providerContext.provide(RUNNER_SERVER_SCOPE_SERVICE, value));\n },\n ],\n activities: [runtime.activity],\n onStop: runtime.dispose,\n };\n};\n\nexport type RunnerServerScope = Awaited<ReturnType<typeof createRunnerServerRoot>>['value'];\n","import { defineMcpTool, type DoomMcpPluginContext } from '@agimon-ai/doompi-core/mcpFacet';\n\nimport { RUNNER_SERVER_SCOPE_SERVICE, type RunnerServerScope } from '../_lib/serverRoot';\n\nconst pollHint = (id: string): string =>\n `You will not be notified when this runner ends. Check it with \\`doom-runner status ${id}\\` and read output with \\`doom-runner logs ${id} --lines 100\\`, both through this tool, until State is completed.`;\n\nexport default defineMcpTool((context: DoomMcpPluginContext) => {\n const scope = context.services.get<RunnerServerScope>(RUNNER_SERVER_SCOPE_SERVICE);\n if (!scope) throw new Error('Runner session service is unavailable.');\n return {\n ...scope.remoteTool,\n description:\n \"Run a shell command (bash, terminal, CLI) in the DoomPi session's repository on the host machine, not on your local machine. Use it for tests, builds, linters, git, and package scripts. Short commands return bounded output. Long-running ones return a runner id. No completion notice reaches you, so check them with `doom-runner status <id>` and `doom-runner logs <id> --lines 100` instead of relaunching.\",\n async execute(toolCallId, parameters, signal, onUpdate, execution) {\n const result = await scope.remoteTool.execute(\n toolCallId,\n parameters,\n AbortSignal.any([context.signal, signal ?? context.signal]),\n onUpdate,\n execution,\n );\n const details = result.details as { promoted?: unknown; id?: unknown } | undefined;\n if (details?.promoted !== true || typeof details.id !== 'string') return result;\n return { ...result, content: [...result.content, { type: 'text', text: pollHint(details.id) }] };\n },\n };\n});\n","// Generated by @agimon-ai/doompi-build. Do not edit by hand.\nimport { defineMcpPlugin, type DoomMcpSessionPlugin } from '@agimon-ai/doompi-core/mcpFacet';\n\nimport skillDoompiUseRunner from '../src/extensions/workspaces/sessions/(backend)/skill/doompi-use-runner.mcp';\nimport toolBash from '../src/extensions/workspaces/sessions/(backend)/tool/bash.mcp';\n\ntype Factory<T, C> = (context: C) => T;\nconst at = <T, C>(value: T | Factory<T, C>, context: C): T =>\n typeof value === 'function' ? (value as Factory<T, C>)(context) : value;\nconst via = <T>(identity: Record<string, unknown>, value: unknown): T =>\n (value === undefined ? undefined : { ...identity, ...(value as object) }) as T;\n\nconst withWidget = <T>(key: string, value: T): T => value === undefined ? value : ({\n ...value, _meta: { ...(value as { _meta?: object })._meta, [\"doompi/widget\"]: key },\n});\n\nexport const mcp = defineMcpPlugin({\n name: '@agimon-ai/doompi-runner',\n session: (context) => ({\n get skills(): DoomMcpSessionPlugin['skills'] { return [at(skillDoompiUseRunner, context)]; },\n get tools(): DoomMcpSessionPlugin['tools'] { return [withWidget(\"@agimon-ai/doompi-runner/bash\", via({ name: 'bash' }, at(toolBash, context)))]; },\n }) satisfies DoomMcpSessionPlugin,\n});\n\nexport default mcp;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAGA,IAAA,iCAAA,GAAeA,gCAAAA,eAAAA,CAAe;CAC5B,MAAM;CACN,aACE;CACF,aAAA,GAAYC,mCAAAA,oBAAAA,CAAAA,QAAAA,KAAAA,CAAAA,CAAAA,cAAAA,UAAAA,CAAAA,CAAAA,MAAqC,wCAAwC;AAC3F,CAAC;ACE+BC,QAAAA,KAAK,OAAO;CAC1C,SAASA,QAAAA,KAAK,OAAO,EAAE,aAAa,0BAA0B,CAAC;CAC/D,SAASA,QAAAA,KAAK,SAASA,QAAAA,KAAK,OAAO,EAAE,aAAa,oDAAoD,CAAC,CAAC;CACxG,YAAYA,QAAAA,KAAK,SACfA,QAAAA,KAAK,QAAQ,EACX,aACE,yJACJ,CAAC,CACH;CACA,aAAaA,QAAAA,KAAK,SAChBA,QAAAA,KAAK,QAAQ,EACX,aACE,6LACJ,CAAC,CACH;CACA,MAAMA,QAAAA,KAAK,SACTA,QAAAA,KAAK,OAAO,EACV,aAAa,+FACf,CAAC,CACH;AACF,CAAC;ACjBoB,IAAI,OACvB;CACE;CACA;CACA;CACA;AACF,CAAC,CAAC,KAAK,GAAG,GACV,GACF;AAWoC,IAAI,OACtC;CACE;CACA;CACA;CACA;AACF,CAAC,CAAC,KAAK,GAAG,GACV,GACF;;;;ACdA,MAAM,EAAE,UAAU,qBAAqBC,gBAAAA;CCoCjC,GAAaC,UAAAA,UAAAA,CAAUC,mBAAAA,QAAQ;;;ACtDrC,MAAa,8BAA8B;;;ACJ3C,MAAM,YAAY,OAChB,sFAAsF,GAAG,6CAA6C,GAAG;AAE3I,IAAA,oBAAA,GAAeC,gCAAAA,cAAAA,EAAe,YAAkC;CAC9D,MAAM,QAAQ,QAAQ,SAAS,IAAuB,2BAA2B;CACjF,IAAI,CAAC,OAAO,MAAM,IAAI,MAAM,wCAAwC;CACpE,OAAO;EACL,GAAG,MAAM;EACT,aACE;EACF,MAAM,QAAQ,YAAY,YAAY,QAAQ,UAAU,WAAW;GACjE,MAAM,SAAS,MAAM,MAAM,WAAW,QACpC,YACA,YACA,YAAY,IAAI,CAAC,QAAQ,QAAQ,UAAU,QAAQ,MAAM,CAAC,GAC1D,UACA,SACF;GACA,MAAM,UAAU,OAAO;GACvB,IAAI,SAAS,aAAa,QAAQ,OAAO,QAAQ,OAAO,UAAU,OAAO;GACzE,OAAO;IAAE,GAAG;IAAQ,SAAS,CAAC,GAAG,OAAO,SAAS;KAAE,MAAM;KAAQ,MAAM,SAAS,QAAQ,EAAE;IAAE,CAAC;GAAE;EACjG;CACF;AACF,CAAC;;;ACpBD,MAAM,MAAY,OAA0B,YAC1C,OAAO,UAAU,aAAc,MAAwB,OAAO,IAAI;AACpE,MAAM,OAAU,UAAmC,UAChD,UAAU,KAAA,IAAY,KAAA,IAAY;CAAE,GAAG;CAAU,GAAI;AAAiB;AAEzE,MAAM,cAAiB,KAAa,UAAgB,UAAU,KAAA,IAAY,QAAS;CACjF,GAAG;CAAO,OAAO;EAAE,GAAI,MAA6B;GAAQ,kBAAkB;CAAI;AACpF;AAEA,MAAa,OAAA,GAAMC,gCAAAA,gBAAAA,CAAgB;CACjC,MAAM;CACN,UAAU,aAAa;EACrB,IAAI,SAAyC;GAAE,OAAO,CAAC,GAAGC,+BAAsB,OAAO,CAAC;EAAG;EAC3F,IAAI,QAAuC;GAAE,OAAO,CAAC,WAAW,iCAAiC,IAAI,EAAE,MAAM,OAAO,GAAG,GAAGC,kBAAU,OAAO,CAAC,CAAC,CAAC;EAAG;CACnJ;AACF,CAAC"}
|
package/dist/extensions/mcp.mjs
CHANGED
|
@@ -44,13 +44,24 @@ promisify(execFile);
|
|
|
44
44
|
const RUNNER_SERVER_SCOPE_SERVICE = "doom/runner-server-scope";
|
|
45
45
|
//#endregion
|
|
46
46
|
//#region src/extensions/workspaces/sessions/(backend)/tool/bash.mcp.ts
|
|
47
|
+
const pollHint = (id) => `You will not be notified when this runner ends. Check it with \`doom-runner status ${id}\` and read output with \`doom-runner logs ${id} --lines 100\`, both through this tool, until State is completed.`;
|
|
47
48
|
var bash_mcp_default = defineMcpTool((context) => {
|
|
48
49
|
const scope = context.services.get(RUNNER_SERVER_SCOPE_SERVICE);
|
|
49
50
|
if (!scope) throw new Error("Runner session service is unavailable.");
|
|
50
51
|
return {
|
|
51
|
-
...scope.
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
...scope.remoteTool,
|
|
53
|
+
description: "Run a shell command (bash, terminal, CLI) in the DoomPi session's repository on the host machine, not on your local machine. Use it for tests, builds, linters, git, and package scripts. Short commands return bounded output. Long-running ones return a runner id. No completion notice reaches you, so check them with `doom-runner status <id>` and `doom-runner logs <id> --lines 100` instead of relaunching.",
|
|
54
|
+
async execute(toolCallId, parameters, signal, onUpdate, execution) {
|
|
55
|
+
const result = await scope.remoteTool.execute(toolCallId, parameters, AbortSignal.any([context.signal, signal ?? context.signal]), onUpdate, execution);
|
|
56
|
+
const details = result.details;
|
|
57
|
+
if (details?.promoted !== true || typeof details.id !== "string") return result;
|
|
58
|
+
return {
|
|
59
|
+
...result,
|
|
60
|
+
content: [...result.content, {
|
|
61
|
+
type: "text",
|
|
62
|
+
text: pollHint(details.id)
|
|
63
|
+
}]
|
|
64
|
+
};
|
|
54
65
|
}
|
|
55
66
|
};
|
|
56
67
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mcp.mjs","names":["skillDoompiUseRunner","toolBash"],"sources":["../../src/extensions/workspaces/sessions/(backend)/skill/doompi-use-runner.mcp.ts","../../src/schemas/bashTool.ts","../../src/services/ansiScrub/index.ts","../../src/services/ptyHost/index.ts","../../src/services/runnerRegistry/index.ts","../../src/extensions/workspaces/sessions/(backend)/_lib/serverRoot.ts","../../src/extensions/workspaces/sessions/(backend)/tool/bash.mcp.ts","../../generated/mcp.ts"],"sourcesContent":["import { defineMcpSkill } from '@agimon-ai/doompi-core/mcpFacet';\nimport { readPackageResource } from '@agimon-ai/doompi-core/serverFacet';\n\nexport default defineMcpSkill({\n name: 'doompi-use-runner',\n description:\n 'Use Doom Pi Runner to supervise shell commands, inspect durable logs, provide interactive input, and stop background runs.',\n read: () => readPackageResource(import.meta.url, 'src/prompts/doompi-use-runner/SKILL.md'),\n});\n","import { type Static, Type } from 'typebox';\n\n/** Replaces pi's built-in tool of the same name, so hooks keyed on `bash` keep working. */\n\n/**\n * Built-in bash parameters plus the backgrounding controls.\n *\n * `command` and `timeout` keep their original names and wording so the tool is\n * a drop-in replacement. Every `description` is prompt copy the model reads.\n */\nexport const BashParamsSchema = Type.Object({\n command: Type.String({ description: 'Bash command to execute' }),\n timeout: Type.Optional(Type.Number({ description: 'Timeout in seconds (optional, no default timeout)' })),\n background: Type.Optional(\n Type.Boolean({\n description:\n 'Start the command in the background immediately instead of waiting. Use for dev servers, watchers and other commands you already know will not finish.',\n }),\n ),\n interactive: Type.Optional(\n Type.Boolean({\n description:\n 'Run under a pseudo terminal so the command can prompt. Use only when the command asks for confirmation or input; answer it with doom-runner input. Logs from interactive runs are noisier.',\n }),\n ),\n name: Type.Optional(\n Type.String({\n description: 'Preferred runner name if the command ends up in the background. A name in use gets a suffix.',\n }),\n ),\n});\n\nexport type BashParams = Static<typeof BashParamsSchema>;\n","import { CONTROL_PATTERN } from '../../constants/ansiScrub';\n/**\n * Turns terminal output into text worth grepping.\n *\n * PTY output is written for a screen, not a file: colours, cursor moves and\n * carriage-return redraws all end up in the byte stream. Without this a single\n * progress bar becomes thousands of near-identical log lines.\n */\n\n/**\n * CSI sequences (colours, cursor moves), OSC strings, the two-byte Fe escapes,\n * and the keypad-mode escapes shells emit around every prompt.\n */\nconst ANSI_PATTERN = new RegExp(\n [\n '\\\\u001B\\\\[[0-9;?]*[ -/]*[@-~]',\n '\\\\u001B\\\\][^\\\\u0007\\\\u001B]*(?:\\\\u0007|\\\\u001B\\\\\\\\)',\n '\\\\u001B[@-Z\\\\\\\\-_]',\n '\\\\u001B[=><]',\n ].join('|'),\n 'g',\n);\n\n/** Bell and backspace, which a terminal consumes rather than displays. */\n// oxlint-disable-next-line no-control-regex -- matching terminal control bytes is the whole job\n\n/**\n * The same set minus SGR, the sequence that only sets colour and weight.\n *\n * The final byte class omits `m`, so `ESC[32m` survives while cursor moves,\n * OSC strings and keypad escapes are still removed.\n */\nconst ANSI_PATTERN_KEEPING_COLOUR = new RegExp(\n [\n '\\\\u001B\\\\[[0-9;?]*[ -/]*[@-ln-~]',\n '\\\\u001B\\\\][^\\\\u0007\\\\u001B]*(?:\\\\u0007|\\\\u001B\\\\\\\\)',\n '\\\\u001B[@-Z\\\\\\\\-_]',\n '\\\\u001B[=><]',\n ].join('|'),\n 'g',\n);\n\n/** Removes escape sequences, leaving the characters a user would have seen. */\nexport function stripAnsi(text: string): string {\n return text.replace(ANSI_PATTERN, '').replace(CONTROL_PATTERN, '');\n}\n\n/**\n * Removes everything `stripAnsi` does except colour.\n *\n * Logs are read back by a terminal renderer as well as by grep, and a command\n * that colours its own output has already said how it should look.\n */\nexport function stripAnsiKeepingColour(text: string): string {\n return text.replace(ANSI_PATTERN_KEEPING_COLOUR, '').replace(CONTROL_PATTERN, '');\n}\n\n/**\n * Collapses carriage-return redraws.\n *\n * A bare `\\r` returns the cursor to the start of the current line, so whatever\n * follows overwrites it. Only the final state of each line is kept. The `\\r` of\n * a CRLF pair is a line terminator rather than a redraw, so it is normalised\n * away first: a pseudo terminal ends every line that way, and treating it as a\n * redraw would blank the whole log.\n */\nexport function collapseCarriageReturns(text: string): string {\n return text\n .replace(/\\r\\n/g, '\\n')\n .split('\\n')\n .map((line) => (line.includes('\\r') ? (line.split('\\r').at(-1) ?? '') : line))\n .join('\\n');\n}\n\n/** Full scrub applied to PTY output before it reaches the log file. */\nexport function scrubTerminalOutput(text: string): string {\n return collapseCarriageReturns(stripAnsiKeepingColour(text));\n}\n\n/** Plain-text scrub, for the copy the model reads. */\nexport function scrubTerminalOutputToPlainText(text: string): string {\n return collapseCarriageReturns(stripAnsi(text));\n}\n","import headless, { type Terminal } from '@xterm/headless';\n\nimport {\n DEFAULT_COLS,\n DEFAULT_ROWS,\n LIVENESS_POLL_MS,\n MEMORY_BUFFER_FACTOR,\n NEWLINE,\n SCROLLBACK,\n TERM_GRACE_MS,\n} from '../../constants/ptyHost';\nimport { PI_SESSION_ID_ENV } from '../../constants/session';\nimport type { IClock } from '../../types/clock';\nimport type { ILogFile } from '../../types/logFile';\nimport type { IProcessControl } from '../../types/processControl';\nimport type { IPtyHost, PtyLaunchRequest, PtyRun } from '../../types/ptyHost';\nimport type { IPtySpawner, PtyProcess } from '../../types/ptySpawner';\nimport type { ExitResult } from '../../types/spawner';\nimport { scrubTerminalOutput } from '../ansiScrub';\nimport { getResultMaxBytes } from '../runnerConfig';\nimport { NO_TERMINAL_INPUT_ENV } from '../runnerSupervisor';\n\n/** Enough history for the overlay to scroll without holding a whole build. */\n\n// Use the CommonJS default export: Node cannot detect this package's named exports.\n// A static import also lets server bundles include it without a live node_modules lookup.\nconst { Terminal: HeadlessTerminal } = headless;\n\nexport class PtyHost implements IPtyHost {\n private readonly runs = new Map<string, PtyRun>();\n\n constructor(\n private readonly spawner: IPtySpawner,\n private readonly logFile: ILogFile,\n private readonly processControl: IProcessControl,\n private readonly clock: IClock,\n private readonly environment: Readonly<Record<string, string | undefined>> = process.env,\n ) {}\n\n async launch(request: PtyLaunchRequest): Promise<PtyRun> {\n const cols = request.cols ?? DEFAULT_COLS;\n const rows = request.rows ?? DEFAULT_ROWS;\n const writer = this.logFile.open(request.id);\n const terminal = new HeadlessTerminal({ cols, rows, scrollback: SCROLLBACK, allowProposedApi: true });\n const memoryLimit = getResultMaxBytes(this.environment) * MEMORY_BUFFER_FACTOR;\n\n const child = await this.spawner.spawn({\n command: request.command,\n cwd: request.cwd,\n env: { ...this.environment, ...NO_TERMINAL_INPUT_ENV, [PI_SESSION_ID_ENV]: request.sessionId },\n cols,\n rows,\n });\n\n let buffer = '';\n let buffering = true;\n let settled = false;\n let resolveCompletion: (result: ExitResult) => void = () => undefined;\n const completion = new Promise<ExitResult>((resolve) => {\n resolveCompletion = resolve;\n });\n const dataHandlers = new Set<(data: string) => void>();\n\n child.onData((data) => {\n // The terminal keeps the screen; the log keeps text worth grepping.\n terminal.write(data);\n const scrubbed = scrubTerminalOutput(data);\n writer.append(scrubbed);\n if (buffering) {\n buffer += scrubbed;\n if (buffer.length > memoryLimit) buffer = buffer.slice(-memoryLimit);\n }\n for (const handler of dataHandlers) handler(data);\n });\n\n child.onExit((result) => {\n if (settled) return;\n settled = true;\n this.runs.delete(request.name);\n writer.close();\n resolveCompletion({ code: result.exitCode, signal: null });\n });\n\n const run: PtyRun = {\n id: request.id,\n name: request.name,\n pid: child.pid,\n logPath: writer.path,\n backend: 'native',\n output: () => buffer,\n completion: () => completion,\n detach: () => {\n buffering = false;\n buffer = '';\n },\n stop: () => this.stopChild(child, request.name),\n write: (text) => child.write(text),\n screen: () => renderScreen(terminal),\n onData: (handler) => {\n dataHandlers.add(handler);\n return () => dataHandlers.delete(handler);\n },\n resize: (nextCols, nextRows) => {\n child.resize(nextCols, nextRows);\n terminal.resize(nextCols, nextRows);\n },\n };\n\n this.runs.set(request.name, run);\n return run;\n }\n\n get(name: string): PtyRun | undefined {\n return this.runs.get(name);\n }\n\n write(name: string, text: string): boolean {\n const run = this.runs.get(name);\n if (!run) return false;\n run.write(text.endsWith(NEWLINE) ? text : `${text}${NEWLINE}`);\n return true;\n }\n\n list(): PtyRun[] {\n return [...this.runs.values()];\n }\n\n async disposeAll(): Promise<void> {\n const runs = [...this.runs.values()];\n this.runs.clear();\n for (const run of runs) await run.stop();\n }\n\n private async stopChild(child: PtyProcess, name: string): Promise<boolean> {\n this.runs.delete(name);\n if (!this.processControl.isAlive(child.pid)) return false;\n\n child.kill('SIGTERM');\n const deadline = this.clock.now() + TERM_GRACE_MS;\n while (this.clock.now() < deadline) {\n if (!this.processControl.isAlive(child.pid)) return true;\n await new Promise<void>((resolve) => {\n this.clock.after(LIVENESS_POLL_MS, resolve);\n });\n }\n child.kill('SIGKILL');\n return true;\n }\n}\n\n/** The visible rows, trimmed of trailing blanks, as plain text. */\nfunction renderScreen(terminal: Terminal): string {\n const buffer = terminal.buffer.active;\n const lines: string[] = [];\n for (let row = buffer.baseY; row < buffer.baseY + terminal.rows; row += 1) {\n lines.push(buffer.getLine(row)?.translateToString(true) ?? '');\n }\n while (lines.length > 0 && lines.at(-1) === '') lines.pop();\n return lines.join(NEWLINE);\n}\n","import { execFile } from 'node:child_process';\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport { promisify } from 'node:util';\n\nimport { resolveRootSessionId } from '@agimon-ai/doompi-core/childProcess';\nimport {\n createProcessRegistryService,\n normalizeRepositoryPath,\n type ProcessRegistryRecord,\n type ProcessRegistryService,\n resolveProcessTags,\n} from '@agimon-ai/foundation-process-registry';\n\nimport {\n COMMAND_SIDECAR_SUFFIX,\n EXIT_SIDECAR_SUFFIX,\n JSON_EXTENSION,\n NOT_FOUND_ERROR_CODE,\n REGISTRY_PATH_ENV,\n RUNNER_TAG,\n SERVICE_PREFIX,\n SERVICE_TYPE,\n} from '../../constants/runnerRegistry';\nimport type { IProcessControl } from '../../types/processControl';\nimport type {\n CompleteRunnerInput,\n IRunnerRegistry,\n RegisterRunnerInput,\n RunnerRecord,\n RunnerTerminationIntent,\n} from '../../types/runnerRegistry';\nimport type { IRunnerPaths } from '../runnerPaths/type';\nimport { isRunnerRecord } from '../runnerRecord';\n\n/** The slice of the shared registry service this wrapper depends on. */\nexport type ProcessRegistryPort = Pick<\n ProcessRegistryService,\n 'registerProcess' | 'releaseProcess' | 'listProcesses' | 'close'\n>;\n\n/** Opens the SQLite-backed registry the rest of the monorepo already shares. */\nexport function createDefaultProcessRegistry(\n env: Readonly<Record<string, string | undefined>> = process.env,\n): ProcessRegistryPort {\n return createProcessRegistryService(env[REGISTRY_PATH_ENV]);\n}\n\nfunction sessionTag(sessionId: string): string {\n return `session:${sessionId}`;\n}\n\nfunction readString(record: ProcessRegistryRecord, key: string): string {\n const value = record.metadata?.[key];\n return typeof value === 'string' ? value : '';\n}\n\nfunction readNumber(record: ProcessRegistryRecord, key: string): number {\n const value = record.metadata?.[key];\n return typeof value === 'number' ? value : 0;\n}\n\nconst runCommand = promisify(execFile);\n\n/**\n * The process start time the registry stores to prove a pid is still the\n * process it registered.\n *\n * The registry resolves it itself when the caller omits it, and it does so with\n * `execFileSync`, which stops this process for the length of a `ps` on every\n * runner that starts. Reading it here costs the same subprocess and blocks\n * nothing: the command is already running by the time a runner is registered.\n *\n * The value has to match what the registry would have read, so the command and\n * the trimming are deliberately identical to its own.\n */\nasync function processStartedAt(pid: number): Promise<string | undefined> {\n if (process.platform === 'win32') return undefined;\n try {\n const { stdout } = await runCommand('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8' });\n return stdout.trim() || undefined;\n } catch {\n return undefined;\n }\n}\n\ntype ListenerRegistration = Readonly<{ key: string; listener: () => void }>;\n\n// Facet replacement can leave an in-flight tool and its publisher on different registry instances.\nconst listenersBySession = new Map<string, Set<() => void>>();\n\nexport class RunnerRegistry implements IRunnerRegistry {\n private readonly subscriptions = new Set<ListenerRegistration>();\n\n constructor(\n private readonly paths: IRunnerPaths,\n private readonly processControl: IProcessControl,\n private readonly registry: ProcessRegistryPort,\n private readonly environment: Readonly<Record<string, string | undefined>>,\n ) {}\n\n async register(input: RegisterRunnerInput): Promise<RunnerRecord> {\n const startedAt = new Date().toISOString();\n const rootSessionId = resolveRootSessionId(input.sessionId, this.environment);\n const identity = await processStartedAt(input.pid);\n const response = await this.registry.registerProcess({\n repositoryPath: this.repositoryPath(),\n serviceName: `${SERVICE_PREFIX}${input.id}`,\n serviceType: SERVICE_TYPE,\n pid: input.pid,\n command: input.command,\n ...(identity === undefined ? {} : { processStartedAt: identity }),\n tags: resolveProcessTags([RUNNER_TAG, `runner:${input.id}`, sessionTag(input.sessionId)]),\n metadata: {\n id: input.id,\n name: input.name,\n command: input.command,\n cwd: input.cwd,\n logPath: input.logPath,\n interactive: input.interactive,\n sessionId: input.sessionId,\n rootSessionId,\n backend: input.backend,\n ...(input.backendTarget ? { backendTarget: input.backendTarget } : {}),\n startedAt,\n hostPid: process.pid,\n },\n // The caller has already resolved name collisions against live records,\n // so anything still holding this name is stale.\n force: true,\n });\n if (!response.success) throw new Error(response.error ?? `Failed to register runner ${input.name}`);\n const record: RunnerRecord = {\n id: input.id,\n name: input.name,\n pid: input.pid,\n command: input.command,\n cwd: input.cwd,\n logPath: input.logPath,\n interactive: input.interactive,\n sessionId: input.sessionId,\n rootSessionId,\n startedAt,\n state: 'running',\n promoted: false,\n backend: input.backend,\n ...(input.backendTarget ? { backendTarget: input.backendTarget } : {}),\n hostPid: process.pid,\n };\n this.writeRecord(record);\n this.notify(record.sessionId);\n return record;\n }\n\n async list(): Promise<RunnerRecord[]> {\n const records = await this.registry.listProcesses({\n repositoryPath: this.repositoryPath(),\n serviceType: SERVICE_TYPE,\n tags: resolveProcessTags([RUNNER_TAG]),\n });\n return records.map(toRunnerRecord);\n }\n\n async listAcrossRepositories(): Promise<RunnerRecord[]> {\n const records = await this.registry.listProcesses({\n serviceType: SERVICE_TYPE,\n tags: resolveProcessTags([RUNNER_TAG]),\n });\n return records.map(toRunnerRecord);\n }\n\n async listBySession(sessionId: string): Promise<RunnerRecord[]> {\n const records = await this.list();\n return records.filter((record) => record.sessionId === sessionId);\n }\n\n async listByRootSession(rootSessionId: string): Promise<RunnerRecord[]> {\n const records = await this.list();\n return records.filter((record) => (record.rootSessionId ?? record.sessionId) === rootSessionId);\n }\n\n async listAll(sessionId?: string): Promise<RunnerRecord[]> {\n this.paths.ensureDirectories(sessionId);\n const records: RunnerRecord[] = [];\n const directory = this.paths.stateDirectory(sessionId);\n for (const entry of fs.readdirSync(directory)) {\n if (!isPrimaryMetadataEntry(entry)) continue;\n try {\n const parsed: unknown = JSON.parse(fs.readFileSync(`${directory}/${entry}`, 'utf8'));\n if (isRunnerRecord(parsed)) records.push(parsed);\n } catch (error) {\n process.emitWarning(`Could not read runner metadata ${entry}: ${String(error)}`);\n }\n }\n return records.sort((left, right) => right.startedAt.localeCompare(left.startedAt));\n }\n\n async get(id: string, sessionId?: string): Promise<RunnerRecord | undefined> {\n const target = this.paths.statePathFor(id, sessionId);\n try {\n const parsed: unknown = JSON.parse(fs.readFileSync(target, 'utf8'));\n if (!isRunnerRecord(parsed) || parsed.id !== id) {\n process.emitWarning(`Could not read runner metadata ${path.basename(target)}: invalid runner record`);\n return undefined;\n }\n return parsed;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === NOT_FOUND_ERROR_CODE) return undefined;\n process.emitWarning(`Could not read runner metadata ${path.basename(target)}: ${String(error)}`);\n return undefined;\n }\n }\n\n async markPromoted(id: string): Promise<RunnerRecord | undefined> {\n const record = await this.get(id);\n if (!record) return undefined;\n const promoted = { ...record, promoted: true };\n this.writeRecord(promoted);\n this.notify(promoted.sessionId);\n return promoted;\n }\n\n async requestTermination(\n id: string,\n intent: RunnerTerminationIntent | undefined,\n sessionId?: string,\n ): Promise<RunnerRecord | undefined> {\n const record = await this.get(id, sessionId);\n if (!record) return undefined;\n if (record.state === 'completed') return record;\n if (intent !== undefined && record.terminationIntent !== undefined) return record;\n const { terminationIntent: _previous, ...rest } = record;\n const updated: RunnerRecord = { ...rest, ...(intent ? { terminationIntent: intent } : {}) };\n this.writeRecord(updated);\n return updated;\n }\n\n async complete(id: string, outcome: CompleteRunnerInput, sessionId?: string): Promise<RunnerRecord | undefined> {\n const persisted = await this.get(id, sessionId);\n if (persisted?.state === 'completed') {\n await this.release(id, persisted.sessionId);\n return persisted;\n }\n const active = await this.list();\n const record =\n persisted ??\n active.find((candidate) => candidate.id === id && (sessionId === undefined || candidate.sessionId === sessionId));\n if (!record) {\n const belongsToAnotherSession =\n sessionId !== undefined && active.some((candidate) => candidate.id === id && candidate.sessionId !== sessionId);\n if (!belongsToAnotherSession) await this.release(id, sessionId);\n return undefined;\n }\n const completed: RunnerRecord = {\n ...record,\n state: 'completed',\n exit: {\n ...outcome,\n ...(outcome.signal !== null && outcome.terminationReason === undefined && record.terminationIntent === undefined\n ? { terminationReason: 'external_signal' as const }\n : {}),\n ...record.terminationIntent,\n finishedAt: new Date().toISOString(),\n },\n };\n this.writeRecord(completed);\n await this.release(id, completed.sessionId);\n return completed;\n }\n\n async release(id: string, sessionId?: string): Promise<void> {\n const activeSessionId = sessionId ?? (await this.list()).find((record) => record.id === id)?.sessionId;\n const ownedSessionId = activeSessionId ?? (await this.get(id))?.sessionId;\n const response = await this.registry.releaseProcess({\n repositoryPath: this.repositoryPath(),\n serviceName: `${SERVICE_PREFIX}${id}`,\n serviceType: SERVICE_TYPE,\n kill: false,\n releasePort: false,\n force: true,\n });\n if (!response.success && !response.error?.includes('No matching process entry')) {\n throw new Error(response.error ?? `Failed to release runner ${id}`);\n }\n if (ownedSessionId !== undefined) this.notify(ownedSessionId);\n }\n\n async pruneDead(): Promise<string[]> {\n const dead = (await this.list()).filter((record) => !this.processControl.isAlive(record.pid));\n for (const record of dead) {\n await this.complete(record.id, { reason: 'backend_lost', code: null, signal: null }, record.sessionId);\n }\n return dead.map((record) => record.id);\n }\n\n subscribe(listener: () => void, sessionId: string): () => void {\n const subscribed = (): void => listener();\n const registration = { key: this.subscriptionKey(sessionId), listener: subscribed };\n const listeners = listenersBySession.get(registration.key) ?? new Set<() => void>();\n listeners.add(subscribed);\n listenersBySession.set(registration.key, listeners);\n this.subscriptions.add(registration);\n return () => this.removeSubscription(registration);\n }\n\n close(): void {\n for (const registration of this.subscriptions) this.removeSubscription(registration);\n this.registry.close();\n }\n\n private notify(sessionId: string): void {\n for (const listener of listenersBySession.get(this.subscriptionKey(sessionId)) ?? []) {\n try {\n listener();\n } catch (error) {\n process.emitWarning(`Runner registry listener failed: ${String(error)}`);\n }\n }\n }\n\n private removeSubscription(registration: ListenerRegistration): void {\n const listeners = listenersBySession.get(registration.key);\n listeners?.delete(registration.listener);\n if (listeners?.size === 0) listenersBySession.delete(registration.key);\n this.subscriptions.delete(registration);\n }\n\n private subscriptionKey(sessionId: string): string {\n return JSON.stringify([path.resolve(this.paths.stateDirectory(sessionId)), sessionId]);\n }\n\n private repositoryPath(): string {\n return normalizeRepositoryPath(this.paths.repositoryPath());\n }\n\n private writeRecord(record: RunnerRecord): void {\n this.paths.ensureDirectories(record.sessionId);\n const target = this.paths.statePathFor(record.id, record.sessionId);\n const temporary = `${target}.${process.pid}.tmp`;\n fs.writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\\n`, 'utf8');\n fs.renameSync(temporary, target);\n }\n}\n\n/** Every field is read back from metadata this class wrote on registration. */\nfunction toRunnerRecord(record: ProcessRegistryRecord): RunnerRecord {\n return {\n id: readString(record, 'id'),\n name: readString(record, 'name'),\n pid: record.pid,\n command: readString(record, 'command'),\n cwd: readString(record, 'cwd'),\n logPath: readString(record, 'logPath'),\n interactive: record.metadata?.interactive === true,\n sessionId: readString(record, 'sessionId'),\n ...(readString(record, 'rootSessionId') ? { rootSessionId: readString(record, 'rootSessionId') } : {}),\n startedAt: readString(record, 'startedAt'),\n state: 'running',\n promoted: false,\n backend: readBackend(record.metadata?.backend),\n ...(readString(record, 'backendTarget') ? { backendTarget: readString(record, 'backendTarget') } : {}),\n hostPid: readNumber(record, 'hostPid'),\n };\n}\n\nfunction readBackend(value: unknown): RunnerRecord['backend'] {\n return value === 'rmux' || value === 'tmux' ? value : 'native';\n}\n\nfunction isPrimaryMetadataEntry(entry: string): boolean {\n return (\n entry.endsWith(JSON_EXTENSION) && !entry.endsWith(COMMAND_SIDECAR_SUFFIX) && !entry.endsWith(EXIT_SIDECAR_SUFFIX)\n );\n}\n","import type { DoomServerPluginContext } from '@agimon-ai/doompi-core/serverFacet';\nimport type { Context } from '@deepseek-ai/cordis';\n\nimport { createHeadlessBashTool, createHeadlessRunnersCommand } from '../../../../../services/headless';\nimport { summarizeLog } from '../../../../../services/logReader';\nimport { createRunnerDependencies } from '../../../../../services/runnerDependencies';\nimport { createRunnerServerRuntime } from '../../../../../services/runnerServerRuntime';\n\nexport const RUNNER_SERVER_SCOPE_SERVICE = 'doom/runner-server-scope';\n\ndeclare module '@deepseek-ai/cordis' {\n interface Context {\n 'doom/runner-server-scope': RunnerServerScope;\n }\n}\n\nexport const createRunnerServerRoot = ({ host, agent, signal }: DoomServerPluginContext) => {\n if (!host.context.directEvents) throw new Error('Runner headless facet requires the session direct event bus.');\n if (!host.context.cwd || !host.context.environment) {\n throw new Error('Runner headless facet requires session cwd and environment.');\n }\n const dependencies = createRunnerDependencies({\n cwd: host.context.cwd,\n environment: host.context.environment,\n ...(host.context.executionBudget === undefined ? {} : { executionBudget: host.context.executionBudget }),\n });\n const execution = agent?.context;\n const wakeAgent =\n execution &&\n (async (content: string, requestId: string): Promise<void> => {\n try {\n // 'steer' joins a running turn and starts one on an idle session.\n // Reuse completion identity across retries and acknowledge admission, not settlement.\n if (!execution.session.admitPrompt) throw new Error('The session cannot admit a runner notification.');\n await execution.session.admitPrompt(content, 'steer', undefined, requestId);\n } catch (error) {\n // Keep completion ownership with the watcher until admission succeeds.\n await Promise.resolve()\n .then(() => execution.client.notify({ body: String(error), level: 'warning' }))\n .catch(() => undefined);\n throw error;\n }\n });\n const runtime = createRunnerServerRuntime(dependencies, host.context.directEvents, wakeAgent);\n const value = {\n tool: createHeadlessBashTool(\n {\n run: async (request) => {\n await runtime.ensureSession(request.sessionId);\n const result = await dependencies.bashRunService.run({\n ...request,\n ...(signal ? { signal: request.signal ? AbortSignal.any([signal, request.signal]) : signal } : {}),\n });\n // Without this, nothing tells the agent the runner ended.\n if (result.kind === 'promoted') runtime.watchRunner(result.id);\n return result;\n },\n },\n summarizeLog,\n ),\n command: createHeadlessRunnersCommand(dependencies),\n };\n return {\n value,\n services: [\n runtime.backgroundWorkPlugin,\n (context: Context) => {\n context.plugin((providerContext) => providerContext.provide(RUNNER_SERVER_SCOPE_SERVICE, value));\n },\n ],\n activities: [runtime.activity],\n onStop: runtime.dispose,\n };\n};\n\nexport type RunnerServerScope = Awaited<ReturnType<typeof createRunnerServerRoot>>['value'];\n","import { defineMcpTool, type DoomMcpPluginContext } from '@agimon-ai/doompi-core/mcpFacet';\n\nimport { RUNNER_SERVER_SCOPE_SERVICE, type RunnerServerScope } from '../_lib/serverRoot';\n\nexport default defineMcpTool((context: DoomMcpPluginContext) => {\n const scope = context.services.get<RunnerServerScope>(RUNNER_SERVER_SCOPE_SERVICE);\n if (!scope) throw new Error('Runner session service is unavailable.');\n return {\n ...scope.tool,\n execute(toolCallId, parameters, signal, onUpdate, execution) {\n return scope.tool.execute(\n toolCallId,\n parameters,\n AbortSignal.any([context.signal, signal ?? context.signal]),\n onUpdate,\n execution,\n );\n },\n };\n});\n","// Generated by @agimon-ai/doompi-build. Do not edit by hand.\nimport { defineMcpPlugin, type DoomMcpSessionPlugin } from '@agimon-ai/doompi-core/mcpFacet';\n\nimport skillDoompiUseRunner from '../src/extensions/workspaces/sessions/(backend)/skill/doompi-use-runner.mcp';\nimport toolBash from '../src/extensions/workspaces/sessions/(backend)/tool/bash.mcp';\n\ntype Factory<T, C> = (context: C) => T;\nconst at = <T, C>(value: T | Factory<T, C>, context: C): T =>\n typeof value === 'function' ? (value as Factory<T, C>)(context) : value;\nconst via = <T>(identity: Record<string, unknown>, value: unknown): T =>\n (value === undefined ? undefined : { ...identity, ...(value as object) }) as T;\n\nconst withWidget = <T>(key: string, value: T): T => value === undefined ? value : ({\n ...value, _meta: { ...(value as { _meta?: object })._meta, [\"doompi/widget\"]: key },\n});\n\nexport const mcp = defineMcpPlugin({\n name: '@agimon-ai/doompi-runner',\n session: (context) => ({\n get skills(): DoomMcpSessionPlugin['skills'] { return [at(skillDoompiUseRunner, context)]; },\n get tools(): DoomMcpSessionPlugin['tools'] { return [withWidget(\"@agimon-ai/doompi-runner/bash\", via({ name: 'bash' }, at(toolBash, context)))]; },\n }) satisfies DoomMcpSessionPlugin,\n});\n\nexport default mcp;\n"],"mappings":";;;;;;;;;;;;AAGA,IAAA,gCAAe,eAAe;CAC5B,MAAM;CACN,aACE;CACF,YAAY,oBAAoB,YAAY,KAAK,wCAAwC;AAC3F,CAAC;ACE+B,KAAK,OAAO;CAC1C,SAAS,KAAK,OAAO,EAAE,aAAa,0BAA0B,CAAC;CAC/D,SAAS,KAAK,SAAS,KAAK,OAAO,EAAE,aAAa,oDAAoD,CAAC,CAAC;CACxG,YAAY,KAAK,SACf,KAAK,QAAQ,EACX,aACE,yJACJ,CAAC,CACH;CACA,aAAa,KAAK,SAChB,KAAK,QAAQ,EACX,aACE,6LACJ,CAAC,CACH;CACA,MAAM,KAAK,SACT,KAAK,OAAO,EACV,aAAa,+FACf,CAAC,CACH;AACF,CAAC;ACjBoB,IAAI,OACvB;CACE;CACA;CACA;CACA;AACF,CAAC,CAAC,KAAK,GAAG,GACV,GACF;AAWoC,IAAI,OACtC;CACE;CACA;CACA;CACA;AACF,CAAC,CAAC,KAAK,GAAG,GACV,GACF;;;;ACdA,MAAM,EAAE,UAAU,qBAAqB;ACoCpB,UAAU,QAAQ;;;ACtDrC,MAAa,8BAA8B;;;ACJ3C,IAAA,mBAAe,eAAe,YAAkC;CAC9D,MAAM,QAAQ,QAAQ,SAAS,IAAuB,2BAA2B;CACjF,IAAI,CAAC,OAAO,MAAM,IAAI,MAAM,wCAAwC;CACpE,OAAO;EACL,GAAG,MAAM;EACT,QAAQ,YAAY,YAAY,QAAQ,UAAU,WAAW;GAC3D,OAAO,MAAM,KAAK,QAChB,YACA,YACA,YAAY,IAAI,CAAC,QAAQ,QAAQ,UAAU,QAAQ,MAAM,CAAC,GAC1D,UACA,SACF;EACF;CACF;AACF,CAAC;;;ACZD,MAAM,MAAY,OAA0B,YAC1C,OAAO,UAAU,aAAc,MAAwB,OAAO,IAAI;AACpE,MAAM,OAAU,UAAmC,UAChD,UAAU,KAAA,IAAY,KAAA,IAAY;CAAE,GAAG;CAAU,GAAI;AAAiB;AAEzE,MAAM,cAAiB,KAAa,UAAgB,UAAU,KAAA,IAAY,QAAS;CACjF,GAAG;CAAO,OAAO;EAAE,GAAI,MAA6B;GAAQ,kBAAkB;CAAI;AACpF;AAEA,MAAa,MAAM,gBAAgB;CACjC,MAAM;CACN,UAAU,aAAa;EACrB,IAAI,SAAyC;GAAE,OAAO,CAAC,GAAGA,+BAAsB,OAAO,CAAC;EAAG;EAC3F,IAAI,QAAuC;GAAE,OAAO,CAAC,WAAW,iCAAiC,IAAI,EAAE,MAAM,OAAO,GAAG,GAAGC,kBAAU,OAAO,CAAC,CAAC,CAAC;EAAG;CACnJ;AACF,CAAC"}
|
|
1
|
+
{"version":3,"file":"mcp.mjs","names":["skillDoompiUseRunner","toolBash"],"sources":["../../src/extensions/workspaces/sessions/(backend)/skill/doompi-use-runner.mcp.ts","../../src/schemas/bashTool.ts","../../src/services/ansiScrub/index.ts","../../src/services/ptyHost/index.ts","../../src/services/runnerRegistry/index.ts","../../src/extensions/workspaces/sessions/(backend)/_lib/serverRoot.ts","../../src/extensions/workspaces/sessions/(backend)/tool/bash.mcp.ts","../../generated/mcp.ts"],"sourcesContent":["import { defineMcpSkill } from '@agimon-ai/doompi-core/mcpFacet';\nimport { readPackageResource } from '@agimon-ai/doompi-core/serverFacet';\n\nexport default defineMcpSkill({\n name: 'doompi-use-runner',\n description:\n 'Use Doom Pi Runner to supervise shell commands, inspect durable logs, provide interactive input, and stop background runs.',\n read: () => readPackageResource(import.meta.url, 'src/prompts/doompi-use-runner/SKILL.md'),\n});\n","import { type Static, Type } from 'typebox';\n\n/** Replaces pi's built-in tool of the same name, so hooks keyed on `bash` keep working. */\n\n/**\n * Built-in bash parameters plus the backgrounding controls.\n *\n * `command` and `timeout` keep their original names and wording so the tool is\n * a drop-in replacement. Every `description` is prompt copy the model reads.\n */\nexport const BashParamsSchema = Type.Object({\n command: Type.String({ description: 'Bash command to execute' }),\n timeout: Type.Optional(Type.Number({ description: 'Timeout in seconds (optional, no default timeout)' })),\n background: Type.Optional(\n Type.Boolean({\n description:\n 'Start the command in the background immediately instead of waiting. Use for dev servers, watchers and other commands you already know will not finish.',\n }),\n ),\n interactive: Type.Optional(\n Type.Boolean({\n description:\n 'Run under a pseudo terminal so the command can prompt. Use only when the command asks for confirmation or input; answer it with doom-runner input. Logs from interactive runs are noisier.',\n }),\n ),\n name: Type.Optional(\n Type.String({\n description: 'Preferred runner name if the command ends up in the background. A name in use gets a suffix.',\n }),\n ),\n});\n\nexport type BashParams = Static<typeof BashParamsSchema>;\n","import { CONTROL_PATTERN } from '../../constants/ansiScrub';\n/**\n * Turns terminal output into text worth grepping.\n *\n * PTY output is written for a screen, not a file: colours, cursor moves and\n * carriage-return redraws all end up in the byte stream. Without this a single\n * progress bar becomes thousands of near-identical log lines.\n */\n\n/**\n * CSI sequences (colours, cursor moves), OSC strings, the two-byte Fe escapes,\n * and the keypad-mode escapes shells emit around every prompt.\n */\nconst ANSI_PATTERN = new RegExp(\n [\n '\\\\u001B\\\\[[0-9;?]*[ -/]*[@-~]',\n '\\\\u001B\\\\][^\\\\u0007\\\\u001B]*(?:\\\\u0007|\\\\u001B\\\\\\\\)',\n '\\\\u001B[@-Z\\\\\\\\-_]',\n '\\\\u001B[=><]',\n ].join('|'),\n 'g',\n);\n\n/** Bell and backspace, which a terminal consumes rather than displays. */\n// oxlint-disable-next-line no-control-regex -- matching terminal control bytes is the whole job\n\n/**\n * The same set minus SGR, the sequence that only sets colour and weight.\n *\n * The final byte class omits `m`, so `ESC[32m` survives while cursor moves,\n * OSC strings and keypad escapes are still removed.\n */\nconst ANSI_PATTERN_KEEPING_COLOUR = new RegExp(\n [\n '\\\\u001B\\\\[[0-9;?]*[ -/]*[@-ln-~]',\n '\\\\u001B\\\\][^\\\\u0007\\\\u001B]*(?:\\\\u0007|\\\\u001B\\\\\\\\)',\n '\\\\u001B[@-Z\\\\\\\\-_]',\n '\\\\u001B[=><]',\n ].join('|'),\n 'g',\n);\n\n/** Removes escape sequences, leaving the characters a user would have seen. */\nexport function stripAnsi(text: string): string {\n return text.replace(ANSI_PATTERN, '').replace(CONTROL_PATTERN, '');\n}\n\n/**\n * Removes everything `stripAnsi` does except colour.\n *\n * Logs are read back by a terminal renderer as well as by grep, and a command\n * that colours its own output has already said how it should look.\n */\nexport function stripAnsiKeepingColour(text: string): string {\n return text.replace(ANSI_PATTERN_KEEPING_COLOUR, '').replace(CONTROL_PATTERN, '');\n}\n\n/**\n * Collapses carriage-return redraws.\n *\n * A bare `\\r` returns the cursor to the start of the current line, so whatever\n * follows overwrites it. Only the final state of each line is kept. The `\\r` of\n * a CRLF pair is a line terminator rather than a redraw, so it is normalised\n * away first: a pseudo terminal ends every line that way, and treating it as a\n * redraw would blank the whole log.\n */\nexport function collapseCarriageReturns(text: string): string {\n return text\n .replace(/\\r\\n/g, '\\n')\n .split('\\n')\n .map((line) => (line.includes('\\r') ? (line.split('\\r').at(-1) ?? '') : line))\n .join('\\n');\n}\n\n/** Full scrub applied to PTY output before it reaches the log file. */\nexport function scrubTerminalOutput(text: string): string {\n return collapseCarriageReturns(stripAnsiKeepingColour(text));\n}\n\n/** Plain-text scrub, for the copy the model reads. */\nexport function scrubTerminalOutputToPlainText(text: string): string {\n return collapseCarriageReturns(stripAnsi(text));\n}\n","import headless, { type Terminal } from '@xterm/headless';\n\nimport {\n DEFAULT_COLS,\n DEFAULT_ROWS,\n LIVENESS_POLL_MS,\n MEMORY_BUFFER_FACTOR,\n NEWLINE,\n SCROLLBACK,\n TERM_GRACE_MS,\n} from '../../constants/ptyHost';\nimport { PI_SESSION_ID_ENV } from '../../constants/session';\nimport type { IClock } from '../../types/clock';\nimport type { ILogFile } from '../../types/logFile';\nimport type { IProcessControl } from '../../types/processControl';\nimport type { IPtyHost, PtyLaunchRequest, PtyRun } from '../../types/ptyHost';\nimport type { IPtySpawner, PtyProcess } from '../../types/ptySpawner';\nimport type { ExitResult } from '../../types/spawner';\nimport { scrubTerminalOutput } from '../ansiScrub';\nimport { getResultMaxBytes } from '../runnerConfig';\nimport { NO_TERMINAL_INPUT_ENV } from '../runnerSupervisor';\n\n/** Enough history for the overlay to scroll without holding a whole build. */\n\n// Use the CommonJS default export: Node cannot detect this package's named exports.\n// A static import also lets server bundles include it without a live node_modules lookup.\nconst { Terminal: HeadlessTerminal } = headless;\n\nexport class PtyHost implements IPtyHost {\n private readonly runs = new Map<string, PtyRun>();\n\n constructor(\n private readonly spawner: IPtySpawner,\n private readonly logFile: ILogFile,\n private readonly processControl: IProcessControl,\n private readonly clock: IClock,\n private readonly environment: Readonly<Record<string, string | undefined>> = process.env,\n ) {}\n\n async launch(request: PtyLaunchRequest): Promise<PtyRun> {\n const cols = request.cols ?? DEFAULT_COLS;\n const rows = request.rows ?? DEFAULT_ROWS;\n const writer = this.logFile.open(request.id);\n const terminal = new HeadlessTerminal({ cols, rows, scrollback: SCROLLBACK, allowProposedApi: true });\n const memoryLimit = getResultMaxBytes(this.environment) * MEMORY_BUFFER_FACTOR;\n\n const child = await this.spawner.spawn({\n command: request.command,\n cwd: request.cwd,\n env: { ...this.environment, ...NO_TERMINAL_INPUT_ENV, [PI_SESSION_ID_ENV]: request.sessionId },\n cols,\n rows,\n });\n\n let buffer = '';\n let buffering = true;\n let settled = false;\n let resolveCompletion: (result: ExitResult) => void = () => undefined;\n const completion = new Promise<ExitResult>((resolve) => {\n resolveCompletion = resolve;\n });\n const dataHandlers = new Set<(data: string) => void>();\n\n child.onData((data) => {\n // The terminal keeps the screen; the log keeps text worth grepping.\n terminal.write(data);\n const scrubbed = scrubTerminalOutput(data);\n writer.append(scrubbed);\n if (buffering) {\n buffer += scrubbed;\n if (buffer.length > memoryLimit) buffer = buffer.slice(-memoryLimit);\n }\n for (const handler of dataHandlers) handler(data);\n });\n\n child.onExit((result) => {\n if (settled) return;\n settled = true;\n this.runs.delete(request.name);\n writer.close();\n resolveCompletion({ code: result.exitCode, signal: null });\n });\n\n const run: PtyRun = {\n id: request.id,\n name: request.name,\n pid: child.pid,\n logPath: writer.path,\n backend: 'native',\n output: () => buffer,\n completion: () => completion,\n detach: () => {\n buffering = false;\n buffer = '';\n },\n stop: () => this.stopChild(child, request.name),\n write: (text) => child.write(text),\n screen: () => renderScreen(terminal),\n onData: (handler) => {\n dataHandlers.add(handler);\n return () => dataHandlers.delete(handler);\n },\n resize: (nextCols, nextRows) => {\n child.resize(nextCols, nextRows);\n terminal.resize(nextCols, nextRows);\n },\n };\n\n this.runs.set(request.name, run);\n return run;\n }\n\n get(name: string): PtyRun | undefined {\n return this.runs.get(name);\n }\n\n write(name: string, text: string): boolean {\n const run = this.runs.get(name);\n if (!run) return false;\n run.write(text.endsWith(NEWLINE) ? text : `${text}${NEWLINE}`);\n return true;\n }\n\n list(): PtyRun[] {\n return [...this.runs.values()];\n }\n\n async disposeAll(): Promise<void> {\n const runs = [...this.runs.values()];\n this.runs.clear();\n for (const run of runs) await run.stop();\n }\n\n private async stopChild(child: PtyProcess, name: string): Promise<boolean> {\n this.runs.delete(name);\n if (!this.processControl.isAlive(child.pid)) return false;\n\n child.kill('SIGTERM');\n const deadline = this.clock.now() + TERM_GRACE_MS;\n while (this.clock.now() < deadline) {\n if (!this.processControl.isAlive(child.pid)) return true;\n await new Promise<void>((resolve) => {\n this.clock.after(LIVENESS_POLL_MS, resolve);\n });\n }\n child.kill('SIGKILL');\n return true;\n }\n}\n\n/** The visible rows, trimmed of trailing blanks, as plain text. */\nfunction renderScreen(terminal: Terminal): string {\n const buffer = terminal.buffer.active;\n const lines: string[] = [];\n for (let row = buffer.baseY; row < buffer.baseY + terminal.rows; row += 1) {\n lines.push(buffer.getLine(row)?.translateToString(true) ?? '');\n }\n while (lines.length > 0 && lines.at(-1) === '') lines.pop();\n return lines.join(NEWLINE);\n}\n","import { execFile } from 'node:child_process';\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport { promisify } from 'node:util';\n\nimport { resolveRootSessionId } from '@agimon-ai/doompi-core/childProcess';\nimport {\n createProcessRegistryService,\n normalizeRepositoryPath,\n type ProcessRegistryRecord,\n type ProcessRegistryService,\n resolveProcessTags,\n} from '@agimon-ai/foundation-process-registry';\n\nimport {\n COMMAND_SIDECAR_SUFFIX,\n EXIT_SIDECAR_SUFFIX,\n JSON_EXTENSION,\n NOT_FOUND_ERROR_CODE,\n REGISTRY_PATH_ENV,\n RUNNER_TAG,\n SERVICE_PREFIX,\n SERVICE_TYPE,\n} from '../../constants/runnerRegistry';\nimport type { IProcessControl } from '../../types/processControl';\nimport type {\n CompleteRunnerInput,\n IRunnerRegistry,\n RegisterRunnerInput,\n RunnerRecord,\n RunnerTerminationIntent,\n} from '../../types/runnerRegistry';\nimport type { IRunnerPaths } from '../runnerPaths/type';\nimport { isRunnerRecord } from '../runnerRecord';\n\n/** The slice of the shared registry service this wrapper depends on. */\nexport type ProcessRegistryPort = Pick<\n ProcessRegistryService,\n 'registerProcess' | 'releaseProcess' | 'listProcesses' | 'close'\n>;\n\n/** Opens the SQLite-backed registry the rest of the monorepo already shares. */\nexport function createDefaultProcessRegistry(\n env: Readonly<Record<string, string | undefined>> = process.env,\n): ProcessRegistryPort {\n return createProcessRegistryService(env[REGISTRY_PATH_ENV]);\n}\n\nfunction sessionTag(sessionId: string): string {\n return `session:${sessionId}`;\n}\n\nfunction readString(record: ProcessRegistryRecord, key: string): string {\n const value = record.metadata?.[key];\n return typeof value === 'string' ? value : '';\n}\n\nfunction readNumber(record: ProcessRegistryRecord, key: string): number {\n const value = record.metadata?.[key];\n return typeof value === 'number' ? value : 0;\n}\n\nconst runCommand = promisify(execFile);\n\n/**\n * The process start time the registry stores to prove a pid is still the\n * process it registered.\n *\n * The registry resolves it itself when the caller omits it, and it does so with\n * `execFileSync`, which stops this process for the length of a `ps` on every\n * runner that starts. Reading it here costs the same subprocess and blocks\n * nothing: the command is already running by the time a runner is registered.\n *\n * The value has to match what the registry would have read, so the command and\n * the trimming are deliberately identical to its own.\n */\nasync function processStartedAt(pid: number): Promise<string | undefined> {\n if (process.platform === 'win32') return undefined;\n try {\n const { stdout } = await runCommand('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8' });\n return stdout.trim() || undefined;\n } catch {\n return undefined;\n }\n}\n\ntype ListenerRegistration = Readonly<{ key: string; listener: () => void }>;\n\n// Facet replacement can leave an in-flight tool and its publisher on different registry instances.\nconst listenersBySession = new Map<string, Set<() => void>>();\n\nexport class RunnerRegistry implements IRunnerRegistry {\n private readonly subscriptions = new Set<ListenerRegistration>();\n\n constructor(\n private readonly paths: IRunnerPaths,\n private readonly processControl: IProcessControl,\n private readonly registry: ProcessRegistryPort,\n private readonly environment: Readonly<Record<string, string | undefined>>,\n ) {}\n\n async register(input: RegisterRunnerInput): Promise<RunnerRecord> {\n const startedAt = new Date().toISOString();\n const rootSessionId = resolveRootSessionId(input.sessionId, this.environment);\n const identity = await processStartedAt(input.pid);\n const response = await this.registry.registerProcess({\n repositoryPath: this.repositoryPath(),\n serviceName: `${SERVICE_PREFIX}${input.id}`,\n serviceType: SERVICE_TYPE,\n pid: input.pid,\n command: input.command,\n ...(identity === undefined ? {} : { processStartedAt: identity }),\n tags: resolveProcessTags([RUNNER_TAG, `runner:${input.id}`, sessionTag(input.sessionId)]),\n metadata: {\n id: input.id,\n name: input.name,\n command: input.command,\n cwd: input.cwd,\n logPath: input.logPath,\n interactive: input.interactive,\n sessionId: input.sessionId,\n rootSessionId,\n backend: input.backend,\n ...(input.backendTarget ? { backendTarget: input.backendTarget } : {}),\n startedAt,\n hostPid: process.pid,\n },\n // The caller has already resolved name collisions against live records,\n // so anything still holding this name is stale.\n force: true,\n });\n if (!response.success) throw new Error(response.error ?? `Failed to register runner ${input.name}`);\n const record: RunnerRecord = {\n id: input.id,\n name: input.name,\n pid: input.pid,\n command: input.command,\n cwd: input.cwd,\n logPath: input.logPath,\n interactive: input.interactive,\n sessionId: input.sessionId,\n rootSessionId,\n startedAt,\n state: 'running',\n promoted: false,\n backend: input.backend,\n ...(input.backendTarget ? { backendTarget: input.backendTarget } : {}),\n hostPid: process.pid,\n };\n this.writeRecord(record);\n this.notify(record.sessionId);\n return record;\n }\n\n async list(): Promise<RunnerRecord[]> {\n const records = await this.registry.listProcesses({\n repositoryPath: this.repositoryPath(),\n serviceType: SERVICE_TYPE,\n tags: resolveProcessTags([RUNNER_TAG]),\n });\n return records.map(toRunnerRecord);\n }\n\n async listAcrossRepositories(): Promise<RunnerRecord[]> {\n const records = await this.registry.listProcesses({\n serviceType: SERVICE_TYPE,\n tags: resolveProcessTags([RUNNER_TAG]),\n });\n return records.map(toRunnerRecord);\n }\n\n async listBySession(sessionId: string): Promise<RunnerRecord[]> {\n const records = await this.list();\n return records.filter((record) => record.sessionId === sessionId);\n }\n\n async listByRootSession(rootSessionId: string): Promise<RunnerRecord[]> {\n const records = await this.list();\n return records.filter((record) => (record.rootSessionId ?? record.sessionId) === rootSessionId);\n }\n\n async listAll(sessionId?: string): Promise<RunnerRecord[]> {\n this.paths.ensureDirectories(sessionId);\n const records: RunnerRecord[] = [];\n const directory = this.paths.stateDirectory(sessionId);\n for (const entry of fs.readdirSync(directory)) {\n if (!isPrimaryMetadataEntry(entry)) continue;\n try {\n const parsed: unknown = JSON.parse(fs.readFileSync(`${directory}/${entry}`, 'utf8'));\n if (isRunnerRecord(parsed)) records.push(parsed);\n } catch (error) {\n process.emitWarning(`Could not read runner metadata ${entry}: ${String(error)}`);\n }\n }\n return records.sort((left, right) => right.startedAt.localeCompare(left.startedAt));\n }\n\n async get(id: string, sessionId?: string): Promise<RunnerRecord | undefined> {\n const target = this.paths.statePathFor(id, sessionId);\n try {\n const parsed: unknown = JSON.parse(fs.readFileSync(target, 'utf8'));\n if (!isRunnerRecord(parsed) || parsed.id !== id) {\n process.emitWarning(`Could not read runner metadata ${path.basename(target)}: invalid runner record`);\n return undefined;\n }\n return parsed;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === NOT_FOUND_ERROR_CODE) return undefined;\n process.emitWarning(`Could not read runner metadata ${path.basename(target)}: ${String(error)}`);\n return undefined;\n }\n }\n\n async markPromoted(id: string): Promise<RunnerRecord | undefined> {\n const record = await this.get(id);\n if (!record) return undefined;\n const promoted = { ...record, promoted: true };\n this.writeRecord(promoted);\n this.notify(promoted.sessionId);\n return promoted;\n }\n\n async requestTermination(\n id: string,\n intent: RunnerTerminationIntent | undefined,\n sessionId?: string,\n ): Promise<RunnerRecord | undefined> {\n const record = await this.get(id, sessionId);\n if (!record) return undefined;\n if (record.state === 'completed') return record;\n if (intent !== undefined && record.terminationIntent !== undefined) return record;\n const { terminationIntent: _previous, ...rest } = record;\n const updated: RunnerRecord = { ...rest, ...(intent ? { terminationIntent: intent } : {}) };\n this.writeRecord(updated);\n return updated;\n }\n\n async complete(id: string, outcome: CompleteRunnerInput, sessionId?: string): Promise<RunnerRecord | undefined> {\n const persisted = await this.get(id, sessionId);\n if (persisted?.state === 'completed') {\n await this.release(id, persisted.sessionId);\n return persisted;\n }\n const active = await this.list();\n const record =\n persisted ??\n active.find((candidate) => candidate.id === id && (sessionId === undefined || candidate.sessionId === sessionId));\n if (!record) {\n const belongsToAnotherSession =\n sessionId !== undefined && active.some((candidate) => candidate.id === id && candidate.sessionId !== sessionId);\n if (!belongsToAnotherSession) await this.release(id, sessionId);\n return undefined;\n }\n const completed: RunnerRecord = {\n ...record,\n state: 'completed',\n exit: {\n ...outcome,\n ...(outcome.signal !== null && outcome.terminationReason === undefined && record.terminationIntent === undefined\n ? { terminationReason: 'external_signal' as const }\n : {}),\n ...record.terminationIntent,\n finishedAt: new Date().toISOString(),\n },\n };\n this.writeRecord(completed);\n await this.release(id, completed.sessionId);\n return completed;\n }\n\n async release(id: string, sessionId?: string): Promise<void> {\n const activeSessionId = sessionId ?? (await this.list()).find((record) => record.id === id)?.sessionId;\n const ownedSessionId = activeSessionId ?? (await this.get(id))?.sessionId;\n const response = await this.registry.releaseProcess({\n repositoryPath: this.repositoryPath(),\n serviceName: `${SERVICE_PREFIX}${id}`,\n serviceType: SERVICE_TYPE,\n kill: false,\n releasePort: false,\n force: true,\n });\n if (!response.success && !response.error?.includes('No matching process entry')) {\n throw new Error(response.error ?? `Failed to release runner ${id}`);\n }\n if (ownedSessionId !== undefined) this.notify(ownedSessionId);\n }\n\n async pruneDead(): Promise<string[]> {\n const dead = (await this.list()).filter((record) => !this.processControl.isAlive(record.pid));\n for (const record of dead) {\n await this.complete(record.id, { reason: 'backend_lost', code: null, signal: null }, record.sessionId);\n }\n return dead.map((record) => record.id);\n }\n\n subscribe(listener: () => void, sessionId: string): () => void {\n const subscribed = (): void => listener();\n const registration = { key: this.subscriptionKey(sessionId), listener: subscribed };\n const listeners = listenersBySession.get(registration.key) ?? new Set<() => void>();\n listeners.add(subscribed);\n listenersBySession.set(registration.key, listeners);\n this.subscriptions.add(registration);\n return () => this.removeSubscription(registration);\n }\n\n close(): void {\n for (const registration of this.subscriptions) this.removeSubscription(registration);\n this.registry.close();\n }\n\n private notify(sessionId: string): void {\n for (const listener of listenersBySession.get(this.subscriptionKey(sessionId)) ?? []) {\n try {\n listener();\n } catch (error) {\n process.emitWarning(`Runner registry listener failed: ${String(error)}`);\n }\n }\n }\n\n private removeSubscription(registration: ListenerRegistration): void {\n const listeners = listenersBySession.get(registration.key);\n listeners?.delete(registration.listener);\n if (listeners?.size === 0) listenersBySession.delete(registration.key);\n this.subscriptions.delete(registration);\n }\n\n private subscriptionKey(sessionId: string): string {\n return JSON.stringify([path.resolve(this.paths.stateDirectory(sessionId)), sessionId]);\n }\n\n private repositoryPath(): string {\n return normalizeRepositoryPath(this.paths.repositoryPath());\n }\n\n private writeRecord(record: RunnerRecord): void {\n this.paths.ensureDirectories(record.sessionId);\n const target = this.paths.statePathFor(record.id, record.sessionId);\n const temporary = `${target}.${process.pid}.tmp`;\n fs.writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\\n`, 'utf8');\n fs.renameSync(temporary, target);\n }\n}\n\n/** Every field is read back from metadata this class wrote on registration. */\nfunction toRunnerRecord(record: ProcessRegistryRecord): RunnerRecord {\n return {\n id: readString(record, 'id'),\n name: readString(record, 'name'),\n pid: record.pid,\n command: readString(record, 'command'),\n cwd: readString(record, 'cwd'),\n logPath: readString(record, 'logPath'),\n interactive: record.metadata?.interactive === true,\n sessionId: readString(record, 'sessionId'),\n ...(readString(record, 'rootSessionId') ? { rootSessionId: readString(record, 'rootSessionId') } : {}),\n startedAt: readString(record, 'startedAt'),\n state: 'running',\n promoted: false,\n backend: readBackend(record.metadata?.backend),\n ...(readString(record, 'backendTarget') ? { backendTarget: readString(record, 'backendTarget') } : {}),\n hostPid: readNumber(record, 'hostPid'),\n };\n}\n\nfunction readBackend(value: unknown): RunnerRecord['backend'] {\n return value === 'rmux' || value === 'tmux' ? value : 'native';\n}\n\nfunction isPrimaryMetadataEntry(entry: string): boolean {\n return (\n entry.endsWith(JSON_EXTENSION) && !entry.endsWith(COMMAND_SIDECAR_SUFFIX) && !entry.endsWith(EXIT_SIDECAR_SUFFIX)\n );\n}\n","import type { DoomServerPluginContext } from '@agimon-ai/doompi-core/serverFacet';\nimport type { Context } from '@deepseek-ai/cordis';\n\nimport { createHeadlessBashTool, createHeadlessRunnersCommand } from '../../../../../services/headless';\nimport { summarizeLog } from '../../../../../services/logReader';\nimport { createRunnerDependencies } from '../../../../../services/runnerDependencies';\nimport { createRunnerServerRuntime } from '../../../../../services/runnerServerRuntime';\n\nexport const RUNNER_SERVER_SCOPE_SERVICE = 'doom/runner-server-scope';\n\ndeclare module '@deepseek-ai/cordis' {\n interface Context {\n 'doom/runner-server-scope': RunnerServerScope;\n }\n}\n\nexport const createRunnerServerRoot = ({ host, agent, signal }: DoomServerPluginContext) => {\n if (!host.context.directEvents) throw new Error('Runner headless facet requires the session direct event bus.');\n if (!host.context.cwd || !host.context.environment) {\n throw new Error('Runner headless facet requires session cwd and environment.');\n }\n const dependencies = createRunnerDependencies({\n cwd: host.context.cwd,\n environment: host.context.environment,\n ...(host.context.executionBudget === undefined ? {} : { executionBudget: host.context.executionBudget }),\n });\n const execution = agent?.context;\n const wakeAgent =\n execution &&\n (async (content: string, requestId: string): Promise<void> => {\n try {\n // 'steer' joins a running turn and starts one on an idle session.\n // Reuse completion identity across retries and acknowledge admission, not settlement.\n if (!execution.session.admitPrompt) throw new Error('The session cannot admit a runner notification.');\n await execution.session.admitPrompt(content, 'steer', undefined, requestId);\n } catch (error) {\n // Keep completion ownership with the watcher until admission succeeds.\n await Promise.resolve()\n .then(() => execution.client.notify({ body: String(error), level: 'warning' }))\n .catch(() => undefined);\n throw error;\n }\n });\n const runtime = createRunnerServerRuntime(dependencies, host.context.directEvents, wakeAgent);\n const bash = (wakeOnExit: boolean) =>\n createHeadlessBashTool(\n {\n run: async (request) => {\n await runtime.ensureSession(request.sessionId);\n const result = await dependencies.bashRunService.run({\n ...request,\n ...(signal ? { signal: request.signal ? AbortSignal.any([signal, request.signal]) : signal } : {}),\n });\n // Without this, nothing tells the agent the runner ended.\n if (wakeOnExit && result.kind === 'promoted') runtime.watchRunner(result.id);\n return result;\n },\n },\n summarizeLog,\n );\n const value = {\n tool: bash(true),\n // Remote MCP runners must not wake the local agent; the remote caller polls instead.\n remoteTool: bash(false),\n command: createHeadlessRunnersCommand(dependencies),\n };\n return {\n value,\n services: [\n runtime.backgroundWorkPlugin,\n (context: Context) => {\n context.plugin((providerContext) => providerContext.provide(RUNNER_SERVER_SCOPE_SERVICE, value));\n },\n ],\n activities: [runtime.activity],\n onStop: runtime.dispose,\n };\n};\n\nexport type RunnerServerScope = Awaited<ReturnType<typeof createRunnerServerRoot>>['value'];\n","import { defineMcpTool, type DoomMcpPluginContext } from '@agimon-ai/doompi-core/mcpFacet';\n\nimport { RUNNER_SERVER_SCOPE_SERVICE, type RunnerServerScope } from '../_lib/serverRoot';\n\nconst pollHint = (id: string): string =>\n `You will not be notified when this runner ends. Check it with \\`doom-runner status ${id}\\` and read output with \\`doom-runner logs ${id} --lines 100\\`, both through this tool, until State is completed.`;\n\nexport default defineMcpTool((context: DoomMcpPluginContext) => {\n const scope = context.services.get<RunnerServerScope>(RUNNER_SERVER_SCOPE_SERVICE);\n if (!scope) throw new Error('Runner session service is unavailable.');\n return {\n ...scope.remoteTool,\n description:\n \"Run a shell command (bash, terminal, CLI) in the DoomPi session's repository on the host machine, not on your local machine. Use it for tests, builds, linters, git, and package scripts. Short commands return bounded output. Long-running ones return a runner id. No completion notice reaches you, so check them with `doom-runner status <id>` and `doom-runner logs <id> --lines 100` instead of relaunching.\",\n async execute(toolCallId, parameters, signal, onUpdate, execution) {\n const result = await scope.remoteTool.execute(\n toolCallId,\n parameters,\n AbortSignal.any([context.signal, signal ?? context.signal]),\n onUpdate,\n execution,\n );\n const details = result.details as { promoted?: unknown; id?: unknown } | undefined;\n if (details?.promoted !== true || typeof details.id !== 'string') return result;\n return { ...result, content: [...result.content, { type: 'text', text: pollHint(details.id) }] };\n },\n };\n});\n","// Generated by @agimon-ai/doompi-build. Do not edit by hand.\nimport { defineMcpPlugin, type DoomMcpSessionPlugin } from '@agimon-ai/doompi-core/mcpFacet';\n\nimport skillDoompiUseRunner from '../src/extensions/workspaces/sessions/(backend)/skill/doompi-use-runner.mcp';\nimport toolBash from '../src/extensions/workspaces/sessions/(backend)/tool/bash.mcp';\n\ntype Factory<T, C> = (context: C) => T;\nconst at = <T, C>(value: T | Factory<T, C>, context: C): T =>\n typeof value === 'function' ? (value as Factory<T, C>)(context) : value;\nconst via = <T>(identity: Record<string, unknown>, value: unknown): T =>\n (value === undefined ? undefined : { ...identity, ...(value as object) }) as T;\n\nconst withWidget = <T>(key: string, value: T): T => value === undefined ? value : ({\n ...value, _meta: { ...(value as { _meta?: object })._meta, [\"doompi/widget\"]: key },\n});\n\nexport const mcp = defineMcpPlugin({\n name: '@agimon-ai/doompi-runner',\n session: (context) => ({\n get skills(): DoomMcpSessionPlugin['skills'] { return [at(skillDoompiUseRunner, context)]; },\n get tools(): DoomMcpSessionPlugin['tools'] { return [withWidget(\"@agimon-ai/doompi-runner/bash\", via({ name: 'bash' }, at(toolBash, context)))]; },\n }) satisfies DoomMcpSessionPlugin,\n});\n\nexport default mcp;\n"],"mappings":";;;;;;;;;;;;AAGA,IAAA,gCAAe,eAAe;CAC5B,MAAM;CACN,aACE;CACF,YAAY,oBAAoB,YAAY,KAAK,wCAAwC;AAC3F,CAAC;ACE+B,KAAK,OAAO;CAC1C,SAAS,KAAK,OAAO,EAAE,aAAa,0BAA0B,CAAC;CAC/D,SAAS,KAAK,SAAS,KAAK,OAAO,EAAE,aAAa,oDAAoD,CAAC,CAAC;CACxG,YAAY,KAAK,SACf,KAAK,QAAQ,EACX,aACE,yJACJ,CAAC,CACH;CACA,aAAa,KAAK,SAChB,KAAK,QAAQ,EACX,aACE,6LACJ,CAAC,CACH;CACA,MAAM,KAAK,SACT,KAAK,OAAO,EACV,aAAa,+FACf,CAAC,CACH;AACF,CAAC;ACjBoB,IAAI,OACvB;CACE;CACA;CACA;CACA;AACF,CAAC,CAAC,KAAK,GAAG,GACV,GACF;AAWoC,IAAI,OACtC;CACE;CACA;CACA;CACA;AACF,CAAC,CAAC,KAAK,GAAG,GACV,GACF;;;;ACdA,MAAM,EAAE,UAAU,qBAAqB;ACoCpB,UAAU,QAAQ;;;ACtDrC,MAAa,8BAA8B;;;ACJ3C,MAAM,YAAY,OAChB,sFAAsF,GAAG,6CAA6C,GAAG;AAE3I,IAAA,mBAAe,eAAe,YAAkC;CAC9D,MAAM,QAAQ,QAAQ,SAAS,IAAuB,2BAA2B;CACjF,IAAI,CAAC,OAAO,MAAM,IAAI,MAAM,wCAAwC;CACpE,OAAO;EACL,GAAG,MAAM;EACT,aACE;EACF,MAAM,QAAQ,YAAY,YAAY,QAAQ,UAAU,WAAW;GACjE,MAAM,SAAS,MAAM,MAAM,WAAW,QACpC,YACA,YACA,YAAY,IAAI,CAAC,QAAQ,QAAQ,UAAU,QAAQ,MAAM,CAAC,GAC1D,UACA,SACF;GACA,MAAM,UAAU,OAAO;GACvB,IAAI,SAAS,aAAa,QAAQ,OAAO,QAAQ,OAAO,UAAU,OAAO;GACzE,OAAO;IAAE,GAAG;IAAQ,SAAS,CAAC,GAAG,OAAO,SAAS;KAAE,MAAM;KAAQ,MAAM,SAAS,QAAQ,EAAE;IAAE,CAAC;GAAE;EACjG;CACF;AACF,CAAC;;;ACpBD,MAAM,MAAY,OAA0B,YAC1C,OAAO,UAAU,aAAc,MAAwB,OAAO,IAAI;AACpE,MAAM,OAAU,UAAmC,UAChD,UAAU,KAAA,IAAY,KAAA,IAAY;CAAE,GAAG;CAAU,GAAI;AAAiB;AAEzE,MAAM,cAAiB,KAAa,UAAgB,UAAU,KAAA,IAAY,QAAS;CACjF,GAAG;CAAO,OAAO;EAAE,GAAI,MAA6B;GAAQ,kBAAkB;CAAI;AACpF;AAEA,MAAa,MAAM,gBAAgB;CACjC,MAAM;CACN,UAAU,aAAa;EACrB,IAAI,SAAyC;GAAE,OAAO,CAAC,GAAGA,+BAAsB,OAAO,CAAC;EAAG;EAC3F,IAAI,QAAuC;GAAE,OAAO,CAAC,WAAW,iCAAiC,IAAI,EAAE,MAAM,OAAO,GAAG,GAAGC,kBAAU,OAAO,CAAC,CAAC,CAAC;EAAG;CACnJ;AACF,CAAC"}
|
|
@@ -299,16 +299,18 @@ const createRunnerServerRoot = ({ host, agent, signal }) => {
|
|
|
299
299
|
}
|
|
300
300
|
});
|
|
301
301
|
const runtime = createRunnerServerRuntime(dependencies, host.context.directEvents, wakeAgent);
|
|
302
|
+
const bash = (wakeOnExit) => require_headless.createHeadlessBashTool({ run: async (request) => {
|
|
303
|
+
await runtime.ensureSession(request.sessionId);
|
|
304
|
+
const result = await dependencies.bashRunService.run({
|
|
305
|
+
...request,
|
|
306
|
+
...signal ? { signal: request.signal ? AbortSignal.any([signal, request.signal]) : signal } : {}
|
|
307
|
+
});
|
|
308
|
+
if (wakeOnExit && result.kind === "promoted") runtime.watchRunner(result.id);
|
|
309
|
+
return result;
|
|
310
|
+
} }, require_logReader.summarizeLog);
|
|
302
311
|
const value = {
|
|
303
|
-
tool:
|
|
304
|
-
|
|
305
|
-
const result = await dependencies.bashRunService.run({
|
|
306
|
-
...request,
|
|
307
|
-
...signal ? { signal: request.signal ? AbortSignal.any([signal, request.signal]) : signal } : {}
|
|
308
|
-
});
|
|
309
|
-
if (result.kind === "promoted") runtime.watchRunner(result.id);
|
|
310
|
-
return result;
|
|
311
|
-
} }, require_logReader.summarizeLog),
|
|
312
|
+
tool: bash(true),
|
|
313
|
+
remoteTool: bash(false),
|
|
312
314
|
command: require_headless.createHeadlessRunnersCommand(dependencies)
|
|
313
315
|
};
|
|
314
316
|
return {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.cjs","names":["isRunnerRecord","reconcileActiveRunners","stopRunnerProcess","formatRunnerFinished","DOOM_BACKGROUND_WORK_SERVICE","readDoomBackgroundWorkService","BACKGROUND_WORK_PROVIDER","createRunnerDependencies","createHeadlessBashTool","summarizeLog","createHeadlessRunnersCommand","defineRoot","defineChannel","fs","defineApiRoutes","apiResponse","path","fs","LogReader","RunnerPaths","resolveRunnerStoreDirectory","runnerStateDirFor","Hono","PtyBackendChain","RmuxBackend","TmuxBackend","routes","streamSSE","defineRoute","defineServerCommand","defineServerTool","defineServerPlugin","channelRunnerRuns","rootSession","composeRootHooks","apiRoute","commandRunners","toolBash"],"sources":["../../src/constants/webRunners.ts","../../src/constants/webRunnerRuns.ts","../../src/services/webRunnerRuns/index.ts","../../src/services/runnerServerRuntime/index.ts","../../src/extensions/workspaces/sessions/(backend)/_lib/serverRoot.ts","../../src/extensions/workspaces/sessions/(backend)/root.server.ts","../../src/services/runnersChannel/index.ts","../../src/extensions/(backend)/channel/runnerRuns.server.ts","../../src/constants/runnerLogApi.ts","../../src/constants/webRunnerLog.ts","../../src/services/logTail/index.ts","../../src/types/webRunnerLog.ts","../../src/types/apiRoutes.ts","../../src/services/runnerLogApi/index.ts","../../src/extensions/workspaces/sessions/(backend)/api/runner/route.server.ts","../../src/extensions/workspaces/sessions/(backend)/command/runners.server.ts","../../src/extensions/workspaces/sessions/(backend)/tool/bash.server.ts","../../generated/server.ts"],"sourcesContent":["export const RUNNER_RUNS_TYPE = 'runner_runs';\n","export const FINISHED_RUNNER_RETENTION_MS = 600_000;\n","import { FINISHED_RUNNER_RETENTION_MS } from '../../constants/webRunnerRuns';\nimport type { RunnerRecord } from '../../types/runnerRegistry';\nimport type { RunnerRunView } from '../../types/webRunners';\nimport { isRunnerRecord } from '../runnerRecord';\n\n/** How long a finished runner stays in the cockpit's feed after it exited. */\n\n/** One on-disk metadata record, or undefined when the text is not a runner record. */\nexport function parseRunnerRecord(raw: string): RunnerRecord | undefined {\n try {\n const parsed: unknown = JSON.parse(raw);\n return isRunnerRecord(parsed) ? parsed : undefined;\n } catch {\n return undefined; // Mid-write or foreign file; the next scan settles it.\n }\n}\n\nexport function toRunnerRunView(record: RunnerRecord): RunnerRunView {\n return {\n id: record.id,\n name: record.name,\n pid: record.pid,\n command: record.command,\n cwd: record.cwd,\n interactive: record.interactive,\n backend: record.backend,\n state: record.state,\n promoted: record.promoted,\n startedAt: record.startedAt,\n logPath: record.logPath,\n ...(record.exit\n ? {\n exit: {\n reason: record.exit.reason,\n code: record.exit.code,\n signal: record.exit.signal,\n ...(record.exit.terminationReason === undefined\n ? {}\n : { terminationReason: record.exit.terminationReason }),\n ...(record.exit.stopReason === undefined ? {} : { stopReason: record.exit.stopReason }),\n finishedAt: record.exit.finishedAt,\n },\n }\n : {}),\n };\n}\n\nfunction finishedAt(record: RunnerRecord): number {\n const parsed = record.exit === undefined ? Number.NaN : Date.parse(record.exit.finishedAt);\n return Number.isFinite(parsed) ? parsed : Date.parse(record.startedAt);\n}\n\n/**\n * The feed the cockpit shows: running runners first, newest start first, then\n * the recently finished ones, newest exit first. Finished runners older than\n * the retention window are left out; the registry keeps them for the CLI.\n */\nexport function presentRunnerRuns(records: readonly RunnerRecord[], now: number): RunnerRunView[] {\n const running = records\n .filter((record) => record.state === 'running')\n .sort((left, right) => right.startedAt.localeCompare(left.startedAt));\n const finished = records\n .filter((record) => record.state === 'completed' && now - finishedAt(record) <= FINISHED_RUNNER_RETENTION_MS)\n .sort((left, right) => finishedAt(right) - finishedAt(left));\n return [...running, ...finished].map(toRunnerRunView);\n}\n","import {\n DOOM_BACKGROUND_WORK_SERVICE,\n readDoomBackgroundWorkService,\n type BackgroundProviderWorkItem,\n type BackgroundWorkProviderHandle,\n} from '@agimon-ai/doompi-core/backgroundWork';\nimport type { DoomHeadlessActivity, DoomHeadlessExecutionContext } from '@agimon-ai/doompi-core/headless';\nimport type { DoomServerHostService } from '@agimon-ai/doompi-core/serverFacet';\nimport type { Context } from '@deepseek-ai/cordis';\n\nimport { BACKGROUND_WORK_PROVIDER } from '../../constants/runnerRuntime';\nimport { RUNNER_RUNS_TYPE } from '../../constants/webRunners';\nimport { reconcileActiveRunners, stopRunnerProcess } from '../reconcile';\nimport type { RunnerDependencies } from '../runnerDependencies/type';\nimport { formatRunnerFinished } from '../runnerRecord';\nimport { presentRunnerRuns } from '../webRunnerRuns';\n\nconst COMPLETION_RETRY_MS = 1_000;\n\nexport interface RunnerServerRuntime {\n readonly container: RunnerDependencies;\n readonly activity: DoomHeadlessActivity;\n readonly backgroundWorkPlugin: (context: Context) => void;\n ensureSession(sessionId: string): Promise<void>;\n /** Wakes the agent once when this runner, which it sent to the background, exits. */\n watchRunner(id: string): void;\n dispose(this: void): Promise<void>;\n}\nexport function createRunnerServerRuntime(\n container: RunnerDependencies,\n directEvents: NonNullable<DoomServerHostService['context']['directEvents']>,\n /** Absent when no agent runs in this session, so there is nobody to wake. */\n wakeAgent?: (content: string, requestId: string) => Promise<void>,\n): RunnerServerRuntime {\n let sessionId: string | undefined;\n let supervision: Promise<void> | undefined;\n let runtimeDisposal: Promise<void> | undefined;\n let backgroundWorkItems: BackgroundProviderWorkItem[] = [];\n let backgroundWorkProvider: BackgroundWorkProviderHandle | undefined;\n // Nothing else tells the model a background runner ended, so without this an\n // agent that finished its turn waiting on one idles until a person prompts it.\n const watched = new Set<string>();\n let unsubscribeWatched: (() => void) | undefined;\n let wakes: Promise<void> = Promise.resolve();\n let closing = false;\n let retryTimer: ReturnType<typeof setTimeout> | undefined;\n\n // The activity is optional and source-gated. The retained facet owns the\n // session runtime, so disabling the activity must not tear down its jobs.\n const ensureSession = (requestedSessionId: string): Promise<void> => {\n if (closing) return Promise.reject(new Error('Runner runtime is closing.'));\n if (sessionId !== undefined && sessionId !== requestedSessionId) {\n throw new Error(`Runner runtime belongs to session ${sessionId}.`);\n }\n sessionId ??= requestedSessionId;\n supervision ??= (async () => {\n container.paths.setSessionId(requestedSessionId);\n if ((await container.lifeline.arm(requestedSessionId)) === undefined) {\n throw new Error('Could not arm runner supervision lifeline.');\n }\n const reconciled = await reconcileActiveRunners({\n registry: container.runnerRegistry,\n launcher: container.launcher,\n rmuxBackend: container.rmuxBackend,\n processControl: container.processControl,\n currentHostPid: process.pid,\n startup: false,\n active: await container.runnerRegistry.listBySession(requestedSessionId),\n });\n for (const error of reconciled.errors) process.emitWarning(error);\n if (!closing) {\n unsubscribeRunnerUpdates ??= container.runnerRegistry.subscribe(\n () => requestPublish(requestedSessionId),\n requestedSessionId,\n );\n requestPublish(requestedSessionId);\n }\n })().catch((error: unknown) => {\n supervision = undefined;\n throw error;\n });\n return supervision;\n };\n\n const disposeRuntime = (): Promise<void> => {\n runtimeDisposal ??= (async () => {\n await supervision?.catch((error: unknown) =>\n process.emitWarning(`Could not initialize headless runner supervision: ${String(error)}`),\n );\n const ownedSessionId = sessionId;\n if (ownedSessionId === undefined) return;\n const records = await container.runnerRegistry.listBySession(ownedSessionId).catch(() => []);\n await Promise.all(\n records.map(async (record) => {\n try {\n const stopped = await stopRunnerProcess(record, container.launcher, container.rmuxBackend, {\n registry: container.runnerRegistry,\n intent: {\n reason: 'stopped',\n terminationReason: 'parent_session_cleanup',\n stopReason: 'session ended',\n },\n });\n if (!stopped && container.processControl.isAlive(record.pid)) {\n process.emitWarning(`Could not stop runner ${record.id} during session shutdown`);\n return;\n }\n await container.runnerRegistry.complete(\n record.id,\n { reason: 'stopped', code: null, signal: null, stopReason: 'session ended' },\n ownedSessionId,\n );\n } catch (error) {\n process.emitWarning(`Could not clean up runner ${record.id}: ${String(error)}`);\n }\n }),\n );\n try {\n await container.ptyHost.disposeAll();\n } finally {\n container.lifeline.dispose();\n container.runnerRegistry.close();\n }\n })();\n return runtimeDisposal;\n };\n\n const publishRuns = async (ownedSessionId: string): Promise<void> => {\n try {\n const records = await container.runnerRegistry.listAll(ownedSessionId);\n const nextBackgroundWork = records\n .filter((record) => record.state === 'running')\n .map((record) => ({\n id: record.id,\n sessionId: record.sessionId,\n label: record.name,\n status: record.state,\n }));\n if (JSON.stringify(nextBackgroundWork) !== JSON.stringify(backgroundWorkItems)) {\n backgroundWorkItems = nextBackgroundWork;\n backgroundWorkProvider?.update();\n }\n directEvents.publish(RUNNER_RUNS_TYPE, ownedSessionId, {\n runs: presentRunnerRuns(records, Date.now()),\n });\n } catch (error) {\n process.emitWarning(`Could not publish runner state: ${String(error)}`);\n }\n };\n let publishInFlight: Promise<void> | undefined;\n let publishAgain = false;\n let unsubscribeRunnerUpdates: (() => void) | undefined;\n const requestPublish = (ownedSessionId: string): void => {\n if (closing) return;\n publishAgain = true;\n if (publishInFlight !== undefined) return;\n publishInFlight = (async () => {\n do {\n publishAgain = false;\n await publishRuns(ownedSessionId);\n } while (publishAgain && !closing);\n })()\n .catch((error: unknown) => {\n process.emitWarning(`Could not schedule runner state publication: ${String(error)}`);\n })\n .finally(() => {\n publishInFlight = undefined;\n if (publishAgain) requestPublish(ownedSessionId);\n });\n };\n\n // Sequenced so two runners exiting in the same tick each wake the agent once.\n const wakeForFinished = (ownedSessionId: string): void => {\n if (closing) return;\n wakes = wakes\n .then(async () => {\n if (closing) return;\n let retry = false;\n // Deleting the entry being visited is safe for a Set iterator.\n for (const id of watched) {\n if (closing) return;\n try {\n const record = await container.runnerRegistry.get(id, ownedSessionId);\n if (closing) return;\n if (record?.state === 'running') continue;\n // A record swept before it was seen leaves nothing to report.\n if (record)\n await wakeAgent?.(\n formatRunnerFinished(record),\n `runner-finished:${JSON.stringify([ownedSessionId, id])}`,\n );\n watched.delete(id);\n } catch (error) {\n retry = true;\n process.emitWarning(`Could not report finished runner ${id}: ${String(error)}`);\n }\n }\n if (retry && !closing && retryTimer === undefined) {\n retryTimer = setTimeout(() => {\n retryTimer = undefined;\n wakeForFinished(ownedSessionId);\n }, COMPLETION_RETRY_MS);\n retryTimer.unref?.();\n }\n if (watched.size === 0) {\n clearTimeout(retryTimer);\n retryTimer = undefined;\n unsubscribeWatched?.();\n unsubscribeWatched = undefined;\n }\n })\n .catch((error: unknown) => {\n process.emitWarning(`Could not report a finished runner: ${String(error)}`);\n });\n };\n\n const watchRunner = (id: string): void => {\n const ownedSessionId = sessionId;\n if (!wakeAgent || closing || ownedSessionId === undefined) return;\n watched.add(id);\n // Independent of the optional activity: the wake-up must not depend on a\n // browser watching the runner list.\n unsubscribeWatched ??= container.runnerRegistry.subscribe(() => wakeForFinished(ownedSessionId), ownedSessionId);\n // It may already have exited between promotion and subscription.\n wakeForFinished(ownedSessionId);\n };\n\n const activity: DoomHeadlessActivity = {\n name: 'runner',\n start: async (executionContext: DoomHeadlessExecutionContext) => {\n await ensureSession(executionContext.sessionId);\n requestPublish(executionContext.sessionId);\n return () => {\n // Let the next optional activation reconcile again, while keeping\n // the retained runtime and all child job supervisors alive.\n supervision = undefined;\n };\n },\n };\n return {\n container,\n activity,\n ensureSession,\n watchRunner,\n backgroundWorkPlugin: (context) => {\n context.inject([DOOM_BACKGROUND_WORK_SERVICE], (serviceContext) => {\n const service = readDoomBackgroundWorkService(serviceContext);\n if (!service) return undefined;\n const registration = service.register({\n provider: BACKGROUND_WORK_PROVIDER,\n listActiveWork: () => backgroundWorkItems,\n });\n backgroundWorkProvider = registration;\n return () => {\n registration.dispose();\n if (backgroundWorkProvider === registration) backgroundWorkProvider = undefined;\n };\n });\n },\n async dispose() {\n // Session cleanup completes every runner it stops, and none of those may\n // start a turn in a session that is going away.\n closing = true;\n clearTimeout(retryTimer);\n retryTimer = undefined;\n watched.clear();\n unsubscribeWatched?.();\n unsubscribeWatched = undefined;\n unsubscribeRunnerUpdates?.();\n unsubscribeRunnerUpdates = undefined;\n await wakes;\n await publishInFlight;\n await disposeRuntime();\n },\n };\n}\n","import type { DoomServerPluginContext } from '@agimon-ai/doompi-core/serverFacet';\nimport type { Context } from '@deepseek-ai/cordis';\n\nimport { createHeadlessBashTool, createHeadlessRunnersCommand } from '../../../../../services/headless';\nimport { summarizeLog } from '../../../../../services/logReader';\nimport { createRunnerDependencies } from '../../../../../services/runnerDependencies';\nimport { createRunnerServerRuntime } from '../../../../../services/runnerServerRuntime';\n\nexport const RUNNER_SERVER_SCOPE_SERVICE = 'doom/runner-server-scope';\n\ndeclare module '@deepseek-ai/cordis' {\n interface Context {\n 'doom/runner-server-scope': RunnerServerScope;\n }\n}\n\nexport const createRunnerServerRoot = ({ host, agent, signal }: DoomServerPluginContext) => {\n if (!host.context.directEvents) throw new Error('Runner headless facet requires the session direct event bus.');\n if (!host.context.cwd || !host.context.environment) {\n throw new Error('Runner headless facet requires session cwd and environment.');\n }\n const dependencies = createRunnerDependencies({\n cwd: host.context.cwd,\n environment: host.context.environment,\n ...(host.context.executionBudget === undefined ? {} : { executionBudget: host.context.executionBudget }),\n });\n const execution = agent?.context;\n const wakeAgent =\n execution &&\n (async (content: string, requestId: string): Promise<void> => {\n try {\n // 'steer' joins a running turn and starts one on an idle session.\n // Reuse completion identity across retries and acknowledge admission, not settlement.\n if (!execution.session.admitPrompt) throw new Error('The session cannot admit a runner notification.');\n await execution.session.admitPrompt(content, 'steer', undefined, requestId);\n } catch (error) {\n // Keep completion ownership with the watcher until admission succeeds.\n await Promise.resolve()\n .then(() => execution.client.notify({ body: String(error), level: 'warning' }))\n .catch(() => undefined);\n throw error;\n }\n });\n const runtime = createRunnerServerRuntime(dependencies, host.context.directEvents, wakeAgent);\n const value = {\n tool: createHeadlessBashTool(\n {\n run: async (request) => {\n await runtime.ensureSession(request.sessionId);\n const result = await dependencies.bashRunService.run({\n ...request,\n ...(signal ? { signal: request.signal ? AbortSignal.any([signal, request.signal]) : signal } : {}),\n });\n // Without this, nothing tells the agent the runner ended.\n if (result.kind === 'promoted') runtime.watchRunner(result.id);\n return result;\n },\n },\n summarizeLog,\n ),\n command: createHeadlessRunnersCommand(dependencies),\n };\n return {\n value,\n services: [\n runtime.backgroundWorkPlugin,\n (context: Context) => {\n context.plugin((providerContext) => providerContext.provide(RUNNER_SERVER_SCOPE_SERVICE, value));\n },\n ],\n activities: [runtime.activity],\n onStop: runtime.dispose,\n };\n};\n\nexport type RunnerServerScope = Awaited<ReturnType<typeof createRunnerServerRoot>>['value'];\n","import { defineRoot } from '@agimon-ai/doompi-core/extensionFile';\n\nimport { createRunnerServerRoot } from './_lib/serverRoot';\n\nexport default defineRoot(createRunnerServerRoot);\n","import type { DoomHubChannel, DoomHubChannelSource } from '@agimon-ai/doompi-core/hubChannel';\n\nimport { RUNNER_RUNS_TYPE } from '../../constants/webRunners';\nimport { type RunnerRunView } from '../../types/webRunners';\n\nfunction runnerPayload(value: unknown): value is { runs: RunnerRunView[] } {\n return typeof value === 'object' && value !== null && Array.isArray((value as { runs?: unknown }).runs);\n}\n\n/**\n * The runners data channel consumes lifecycle-owned snapshots from the session\n * server. Durable registry files are read by the owning session only, never by\n * this hub channel.\n */\nexport function createRunnersChannel(): DoomHubChannel {\n return {\n frameType: RUNNER_RUNS_TYPE,\n start(host) {\n const latest = new Map<string, RunnerRunView[]>();\n const subscriptions = new Map<string, () => void>();\n const source: DoomHubChannelSource = {\n payloadFor(scope) {\n const runs = latest.get(scope.sessionId);\n return runs === undefined ? undefined : { runs };\n },\n sessionAdded(scope) {\n subscriptions.get(scope.sessionId)?.();\n latest.delete(scope.sessionId);\n const unsubscribe = host.directEvents.subscribe(\n RUNNER_RUNS_TYPE,\n scope.sessionId,\n (payload) => {\n if (!runnerPayload(payload)) return;\n latest.set(scope.sessionId, payload.runs);\n host.publish(scope.sessionId, payload);\n },\n { replayLatest: true },\n );\n subscriptions.set(scope.sessionId, unsubscribe);\n },\n sessionRemoved(sessionId) {\n subscriptions.get(sessionId)?.();\n subscriptions.delete(sessionId);\n latest.delete(sessionId);\n },\n close() {\n for (const unsubscribe of subscriptions.values()) unsubscribe();\n subscriptions.clear();\n latest.clear();\n },\n };\n return source;\n },\n };\n}\n","import { defineChannel } from '@agimon-ai/doompi-core/extensionFile';\nimport type { DoomHubChannel } from '@agimon-ai/doompi-core/hubChannel';\n\nimport { createRunnersChannel } from '../../../services/runnersChannel';\nexport default defineChannel(function runnerRunsChannel(): DoomHubChannel {\n return createRunnersChannel();\n});\n","export const LOG_DIR_NAME = 'logs';\nexport const STATE_EXTENSION = '.json';\nexport const RUNNING_STATE = 'running';\nexport const STATE_POLL_MS = 1000;\nexport const HEARTBEAT_MS = 15_000;\nexport const DEFAULT_LINES = 200;\nexport const MAX_LINES = 5000;\nexport const SAFE_SEGMENT = /^[\\w.@-]+$/;\nexport const SCREEN_POLL_MS = 100;\nexport const RAW_LOG_SUFFIX = '.raw';\nexport const MAX_INPUT_CHARS = 4096;\nexport const NOT_ATTACHABLE = 'No attachable runner by that name in this session.';\n","export const RUNNER_API_BASE_PATH = 'runner';\nexport const RUN_ID_PARAM = 'runId';\nexport const RUNNER_SCREEN_EVENT = 'screen';\nexport const RUNNER_LOG_STREAM_EVENT = 'append';\nexport const RUNNER_LOG_PING_EVENT = 'ping';\n","import fs from 'node:fs';\n\nimport { POLL_MS } from '../../constants/logTail';\nimport type { ILogTail, LogTailHandle, LogTailOptions } from '../../types/logTail';\n\n/** How often the tail restats the file; the same cadence the CLI's --follow uses. */\n\n/**\n * Polling tail: read the delta between the last offset and the file's current\n * size, and hand back only complete lines.\n *\n * Same reliability posture as this package's runner watcher: the poll is the\n * mechanism, not an accelerator for a change notification. fs.watchFile only\n * fires when it observes a difference from a baseline it took itself, so a\n * write that lands before its first stat is a change it never reports; a\n * restat every tick has no such window.\n *\n * A write that lands mid-line is common, so the trailing fragment is held\n * until its newline arrives rather than emitted as a line that will change.\n */\nexport class LogTail implements ILogTail {\n follow(logPath: string, options: LogTailOptions): LogTailHandle {\n let offset = options.from;\n let pending = '';\n let closed = false;\n\n const drain = (size: number): void => {\n // A shrunken file was rotated or truncated; start over rather than\n // reading from an offset that now points into different content.\n if (size < offset) {\n offset = 0;\n pending = '';\n }\n if (size === offset) return;\n const length = size - offset;\n const buffer = Buffer.alloc(length);\n const descriptor = fs.openSync(logPath, 'r');\n let bytesRead: number;\n try {\n bytesRead = fs.readSync(descriptor, buffer, 0, length, offset);\n } finally {\n fs.closeSync(descriptor);\n }\n offset = size;\n if (bytesRead <= 0) return;\n pending += buffer.subarray(0, bytesRead).toString('utf8');\n const lines = pending.split('\\n');\n pending = lines.pop() ?? '';\n if (lines.length > 0) options.onLines(lines, offset - Buffer.byteLength(pending, 'utf8'));\n };\n\n const tick = (): void => {\n if (closed) return;\n try {\n drain(fs.statSync(logPath).size);\n } catch (error) {\n // The runner has not opened its log yet; the next tick settles it.\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;\n options.onError(error instanceof Error ? error : new Error(String(error)));\n }\n };\n\n const timer = setInterval(tick, POLL_MS);\n // Following a log must never be the reason a process stays alive.\n timer.unref?.();\n return {\n close() {\n if (closed) return;\n closed = true;\n clearInterval(timer);\n },\n };\n }\n}\n","import type { LogSlice } from './logReader';\n\n/**\n * The runner log API, shared by this package's session-scoped routes and its\n * cockpit plugin. The two halves run in different processes, so the wire\n * vocabulary is declared here: the browser half may reach `src/types` and\n * nothing else on the server side.\n *\n * Where the routes live is not here. That is `src/types/apiRoutes`, which both\n * halves read, and the generated client turns into the URLs the page addresses.\n * The file a run writes to is still read from its metadata record by the server\n * alone.\n */\n\n/**\n * One chunk of an attached pane's output.\n *\n * Base64 because these are terminal bytes, escape sequences and all, and a\n * server-sent event is a line-oriented text frame that would mangle them.\n */\nexport interface RunnerScreenEvent {\n chunk: string;\n /** Byte offset just past this chunk, so a reconnect resumes rather than replays. */\n offset?: number;\n /** Set once the runner exits, so the page can stop watching. */\n ended?: boolean;\n}\n\n/** The body of a keystroke POST. Text, not key codes: the pane wants bytes. */\nexport interface RunnerInputRequest {\n text: string;\n}\n\n/** The named SSE event carrying a RunnerScreenEvent payload. */\n\n/**\n * A log request as query parameters. `grep` is a literal substring, not a\n * regular expression, because that is what the reader matches; `lines` bounds\n * the answer to the last N lines of whatever survived the filter.\n */\nexport interface RunnerLogQueryParams {\n lines?: number;\n grep?: string;\n ignoreCase?: boolean;\n contextLines?: number;\n}\n\n/** The slice the log route answers with, plus which runner it came from. */\nexport interface RunnerLogResponse extends LogSlice {\n runId: string;\n /** True while the runner is still writing, so the page knows following is worth offering. */\n running: boolean;\n}\n\n/**\n * One server-sent event on the follow stream: the lines appended since the last\n * one. The stream never filters: a page that sets a query stops following and\n * reads a filtered slice instead, because grep with context is a whole-file\n * answer and cannot be assembled from a chunk at a time.\n */\nexport interface RunnerLogStreamEvent {\n /** Appended lines, in order; empty when the event only reports the runner ending. */\n lines: string[];\n /**\n * Byte offset just past the last line in this event.\n *\n * A follower that loses the stream resumes here. Without it the page could\n * only fall back to the offset its last slice reported, which every line\n * delivered since has already moved past, so a reconnect would replay them.\n */\n offset?: number;\n /** Set once the runner exits, so the page can stop following without polling the run list. */\n ended?: boolean;\n}\n\n/** The named SSE event carrying a RunnerLogStreamEvent payload. */\n\n/**\n * The named SSE event that carries nothing.\n *\n * A runner can be alive and silent for minutes, which on the wire is\n * indistinguishable from a connection that died. This is sent on a timer so\n * the socket keeps proving itself. Readers that only listen for `append`\n * ignore it, which is what makes it safe to add.\n */\n\n/** Query parameter names, shared so the page and the route cannot drift apart. */\nexport const RUNNER_LOG_PARAMS = {\n lines: 'lines',\n grep: 'grep',\n ignoreCase: 'ignoreCase',\n contextLines: 'contextLines',\n from: 'from',\n} as const;\n","import { apiResponse, defineApiRoutes } from '@agimon-ai/doompi-core/web';\n\nimport { RUN_ID_PARAM } from '../constants/webRunnerLog';\nimport { RUNNER_LOG_PARAMS, type RunnerLogResponse } from './webRunnerLog';\n\n/**\n * This package's routes, as data.\n *\n * No scope and no base path: the build reads both off\n * `src/extensions/workspaces/sessions/(backend)/api/runner/`, so the mount is\n * stated once, by the folder that creates it. This package stated it four times\n * before, once per URL builder, each of them re-spelling `/plugins/` and the\n * session prefix by hand.\n *\n * The Hono app registers these paths verbatim and the browser addresses them,\n * so a path cannot move on one side alone. `:runId` is Hono's own parameter\n * syntax for that reason; a caller supplies the value as `params`, and the\n * client encodes the segment.\n */\nexport default defineApiRoutes({\n log: {\n method: 'GET',\n path: `/runners/:${RUN_ID_PARAM}/log`,\n query: [\n RUNNER_LOG_PARAMS.lines,\n RUNNER_LOG_PARAMS.grep,\n RUNNER_LOG_PARAMS.ignoreCase,\n RUNNER_LOG_PARAMS.contextLines,\n ],\n response: apiResponse<RunnerLogResponse>(),\n },\n /**\n * The live tail of that same log.\n *\n * `stream: true`, so the client exposes a URL and no call method: the\n * consumer is the browser's own `EventSource`, and a relayed call would\n * buffer a stream that never ends.\n */\n logStream: {\n method: 'GET',\n path: `/runners/:${RUN_ID_PARAM}/log/stream`,\n query: [RUNNER_LOG_PARAMS.from],\n stream: true,\n },\n /** The unscrubbed pane bytes, for a terminal emulator rather than a reader. */\n screenStream: {\n method: 'GET',\n path: `/runners/:${RUN_ID_PARAM}/screen/stream`,\n query: [RUNNER_LOG_PARAMS.from],\n stream: true,\n },\n input: {\n method: 'POST',\n path: `/runners/:${RUN_ID_PARAM}/screen/input`,\n },\n});\n","import fs from 'node:fs';\nimport path from 'node:path';\n\nimport type { DoomApi, DoomApiContext, DoomApiHandler } from '@agimon-ai/doompi-core/packageApi';\nimport { Hono } from 'hono';\nimport { streamSSE } from 'hono/streaming';\n\nimport {\n DEFAULT_LINES,\n HEARTBEAT_MS,\n LOG_DIR_NAME,\n MAX_INPUT_CHARS,\n MAX_LINES,\n NOT_ATTACHABLE,\n RAW_LOG_SUFFIX,\n RUNNING_STATE,\n SAFE_SEGMENT,\n SCREEN_POLL_MS,\n STATE_EXTENSION,\n STATE_POLL_MS,\n} from '../../constants/runnerLogApi';\nimport {\n RUN_ID_PARAM,\n RUNNER_API_BASE_PATH,\n RUNNER_LOG_PING_EVENT,\n RUNNER_LOG_STREAM_EVENT,\n RUNNER_SCREEN_EVENT,\n} from '../../constants/webRunnerLog';\nimport { LogReader } from '../../services/logReader';\nimport { LogTail } from '../../services/logTail';\nimport { PtyBackendChain } from '../../services/ptyBackendChain';\nimport { RmuxBackend } from '../../services/rmuxBackend';\nimport { RunnerPaths, resolveRunnerStoreDirectory, runnerStateDirFor } from '../../services/runnerPaths';\nimport { TmuxBackend } from '../../services/tmuxBackend';\nimport { parseRunnerRecord } from '../../services/webRunnerRuns';\nimport routes from '../../types/apiRoutes';\nimport type { ILogReader, LogQuery } from '../../types/logReader';\nimport type { ILogTail } from '../../types/logTail';\nimport type { IRmuxBackend } from '../../types/rmuxBackend';\nimport type { RunnerRecord } from '../../types/runnerRegistry';\nimport {\n RUNNER_LOG_PARAMS,\n type RunnerLogResponse,\n type RunnerLogStreamEvent,\n type RunnerScreenEvent,\n} from '../../types/webRunnerLog';\n\n/** How often a follow re-reads the record to notice the runner exiting. */\n/**\n * How often a silent but living stream proves it is still there. Short enough\n * that an idle proxy does not reap the socket, long enough to cost nothing.\n */\n/** Kept in step with LogQuery's own default, so an unasked-for tail is the same size everywhere. */\n/** A page that asks for more than this gets this; the whole point of the route is a bounded read. */\n/** Path segments the client supplies are names, never paths. */\n/** Backends that expose a pane another process can read and write. */\nconst MULTIPLEXER_BACKENDS: ReadonlySet<string> = new Set(['rmux', 'tmux']);\n/** How often an attached pane's new bytes are picked up. */\n/** The unscrubbed copy the runner's sink writes beside the log, for interactive runs. */\n/** One keystroke batch; a paste is not a file transfer. */\n/** Said the same way for every reason, so the route never reports which check failed. */\n\nexport interface RunnerLogApiOptions {\n /** The session these routes answer for; the host owns exactly one. */\n sessionId: string;\n /** The store root; defaults to this environment's agent directory. */\n storeDir?: string;\n environment?: Readonly<Record<string, string | undefined>>;\n cwd?: string;\n logReader?: ILogReader;\n logTail?: ILogTail;\n /**\n * Reaches a live pane to read its screen and send it input.\n *\n * A session API is handed nothing but its session id, so it cannot touch the\n * in-process PTY handles the extension holds. This talks to the multiplexer\n * over its own socket instead, the same way the CLI's `input` verb does,\n * which is what makes an attached pane reachable across processes at all.\n */\n pane?: IRmuxBackend;\n}\n\n/** Rejects anything that could climb out of the session's own directory before a path is built. */\nfunction isSafeSegment(value: string): boolean {\n return value !== '' && value !== '.' && value !== '..' && SAFE_SEGMENT.test(value);\n}\n\n/**\n * The log file a run wrote, or undefined when there is no such run or its\n * record points somewhere it has no business pointing.\n *\n * The record names its own log path, and that record is written by another\n * process, so the path is checked rather than trusted: it must sit inside this\n * session's own logs directory. Without that check a doctored record would\n * turn this route into an arbitrary file read.\n */\nfunction resolveLogFile(stateDir: string, logsDir: string, sessionId: string, runId: string): RunnerRecord | undefined {\n const statePath = path.join(stateDir, `${runId}${STATE_EXTENSION}`);\n let raw: string;\n try {\n raw = fs.readFileSync(statePath, 'utf8');\n } catch {\n return undefined; // No such run in this session; the route answers 404.\n }\n const record = parseRunnerRecord(raw);\n if (record === undefined || record.id !== runId || record.sessionId !== sessionId) return undefined;\n const resolved = path.resolve(record.logPath);\n if (resolved !== logsDir && !resolved.startsWith(`${logsDir}${path.sep}`)) return undefined;\n return record;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nfunction numberParam(value: string | undefined, fallback: number, max: number): number {\n const parsed = value === undefined ? Number.NaN : Number.parseInt(value, 10);\n if (!Number.isFinite(parsed) || parsed < 0) return fallback;\n return Math.min(parsed, max);\n}\n\nfunction logQueryOf(url: URL): LogQuery {\n const grep = url.searchParams.get(RUNNER_LOG_PARAMS.grep) ?? undefined;\n return {\n lines: numberParam(url.searchParams.get(RUNNER_LOG_PARAMS.lines) ?? undefined, DEFAULT_LINES, MAX_LINES),\n ...(grep === undefined || grep === '' ? {} : { grep }),\n ignoreCase: url.searchParams.get(RUNNER_LOG_PARAMS.ignoreCase) === 'true',\n contextLines: numberParam(url.searchParams.get(RUNNER_LOG_PARAMS.contextLines) ?? undefined, 0, MAX_LINES),\n };\n}\n\n/**\n * This package's HTTP surface: one runner's log, as a bounded slice and as a\n * live tail.\n *\n * The routes are mounted inside one session's own server, so they name a runner\n * and nothing else; the session is the host, not a parameter. The reader only\n * ever returns the last N lines of what matched, so both routes stay bounded no\n * matter how large the file on disk is.\n *\n * Paths come from src/types/apiRoutes, the one table the browser's generated\n * client reads too, so this app and the cockpit cannot disagree about where a\n * route lives. They stay relative to the mount the host strips.\n */\nexport function createRunnerLogApi(options: RunnerLogApiOptions): Hono {\n const logReader = options.logReader ?? new LogReader();\n const logTail = options.logTail ?? new LogTail();\n const sessionId = options.sessionId;\n const paths = new RunnerPaths(options.cwd, options.environment);\n const storeDir = options.storeDir ?? resolveRunnerStoreDirectory(options.environment ?? process.env, options.cwd);\n const stateDir =\n options.storeDir === undefined ? paths.stateDirectory(sessionId) : runnerStateDirFor(storeDir, sessionId);\n const logsDir =\n options.storeDir === undefined ? paths.logDirectory(sessionId) : path.join(storeDir, sessionId, LOG_DIR_NAME);\n const app = new Hono();\n\n // Built on first use, not on mount: most sessions never attach to a pane,\n // and constructing this probes the filesystem for a multiplexer binary.\n let paneBackend: IRmuxBackend | undefined = options.pane;\n const pane = (): IRmuxBackend => {\n paneBackend ??= (() => {\n const paths = new RunnerPaths(options.cwd, options.environment);\n return new PtyBackendChain(new RmuxBackend(paths), new TmuxBackend(paths));\n })();\n return paneBackend;\n };\n\n /** The record behind a request, or undefined when this session has no such run. */\n const runnerOf = (runId: string): RunnerRecord | undefined =>\n isSafeSegment(runId) ? resolveLogFile(stateDir, logsDir, sessionId, runId) : undefined;\n\n /**\n * The pane a request may attach to.\n *\n * Same posture as the log routes: the record is written by another process,\n * so nothing on it is trusted until it is checked. The target must be one\n * this package owns, the run must still be up, and it must have been started\n * interactive. A non-interactive run has no terminal to type into, and a\n * subprocess-backed run has no pane at all.\n */\n const attachableOf = (runId: string): { record: RunnerRecord; target: string } | undefined => {\n const record = runnerOf(runId);\n if (record === undefined || record.state !== RUNNING_STATE || record.interactive !== true) return undefined;\n const target = record.backendTarget;\n if (target === undefined || !MULTIPLEXER_BACKENDS.has(record.backend) || !isSafeSegment(target)) return undefined;\n return { record, target };\n };\n app.get(routes.log.path, (context) => {\n const runId = context.req.param(RUN_ID_PARAM);\n const record = runnerOf(runId);\n if (record === undefined) return context.json({ error: `No runner '${runId}' in this session.` }, 404);\n const slice = logReader.read(record.logPath, logQueryOf(new URL(context.req.url)));\n const body: RunnerLogResponse = { ...slice, runId, running: record.state === RUNNING_STATE };\n return context.json(body);\n });\n\n app.get(routes.logStream.path, (context) => {\n const runId = context.req.param(RUN_ID_PARAM);\n const record = runnerOf(runId);\n if (record === undefined) return context.json({ error: `No runner '${runId}' in this session.` }, 404);\n const from = numberParam(\n new URL(context.req.url).searchParams.get(RUNNER_LOG_PARAMS.from) ?? undefined,\n 0,\n Number.MAX_SAFE_INTEGER,\n );\n\n return streamSSE(context, async (stream) => {\n const send = (event: RunnerLogStreamEvent): Promise<void> =>\n stream.writeSSE({ event: RUNNER_LOG_STREAM_EVENT, data: JSON.stringify(event) });\n\n // The tail and the state poll both push onto one queue, so the stream\n // writes in the order things happened rather than interleaving two\n // writers onto the same socket.\n let pump: Promise<void> = Promise.resolve();\n const queue = (event: RunnerLogStreamEvent): void => {\n pump = pump.then(() => send(event)).catch(() => undefined);\n };\n\n let finish = (): void => undefined;\n const done = new Promise<void>((resolve) => {\n finish = resolve;\n });\n const handle = logTail.follow(record.logPath, {\n from,\n onLines: (lines, completeThrough) => queue({ lines, offset: completeThrough }),\n onError: () => finish(),\n });\n // A finished runner writes nothing more, so the page is told once and\n // the stream ends rather than holding a socket open forever.\n const poll = setInterval(() => {\n const current = runnerOf(runId);\n if (current !== undefined && current.state === RUNNING_STATE) return;\n queue({ lines: [], ended: true });\n finish();\n }, STATE_POLL_MS);\n // A runner can be alive and produce nothing for minutes. Without this the\n // page cannot tell that from a stream that quietly died.\n const heartbeat = setInterval(() => {\n pump = pump.then(() => stream.writeSSE({ event: RUNNER_LOG_PING_EVENT, data: '' })).catch(() => undefined);\n }, HEARTBEAT_MS);\n\n stream.onAbort(() => finish());\n await done;\n clearInterval(poll);\n clearInterval(heartbeat);\n handle.close();\n await pump;\n });\n });\n\n app.get(routes.screenStream.path, (context) => {\n const runId = context.req.param(RUN_ID_PARAM);\n const attachable = attachableOf(runId);\n if (attachable === undefined) return context.json({ error: NOT_ATTACHABLE }, 404);\n // The record's log path is already proven to sit inside this session's own\n // logs directory, and the raw copy is that same path with a suffix, so it\n // cannot point anywhere new.\n const rawPath = `${attachable.record.logPath}${RAW_LOG_SUFFIX}`;\n const from = numberParam(\n new URL(context.req.url).searchParams.get(RUNNER_LOG_PARAMS.from) ?? undefined,\n 0,\n Number.MAX_SAFE_INTEGER,\n );\n\n return streamSSE(context, async (stream) => {\n let pump: Promise<void> = Promise.resolve();\n const queue = (event: RunnerScreenEvent): void => {\n pump = pump\n .then(() => stream.writeSSE({ event: RUNNER_SCREEN_EVENT, data: JSON.stringify(event) }))\n .catch(() => undefined);\n };\n\n let finish = (): void => undefined;\n const done = new Promise<void>((resolve) => {\n finish = resolve;\n });\n\n let offset = from;\n const drain = (): void => {\n let size: number;\n try {\n size = fs.statSync(rawPath).size;\n } catch {\n return; // The sink has not opened the file yet; the next tick settles it.\n }\n // The sink starts the file over when it passes its ceiling, so a\n // smaller file is a fresh one rather than a bad offset.\n if (size < offset) offset = 0;\n if (size === offset) return;\n const length = size - offset;\n const buffer = Buffer.alloc(length);\n const descriptor = fs.openSync(rawPath, 'r');\n let read: number;\n try {\n read = fs.readSync(descriptor, buffer, 0, length, offset);\n } finally {\n fs.closeSync(descriptor);\n }\n if (read <= 0) return;\n offset += read;\n // Bytes, not lines: a terminal's escape sequences straddle newlines and\n // mean nothing once split on them.\n queue({ chunk: buffer.subarray(0, read).toString('base64'), offset });\n };\n\n const poll = setInterval(() => {\n const current = runnerOf(runId);\n drain();\n if (current === undefined || current.state !== RUNNING_STATE) {\n queue({ chunk: '', offset, ended: true });\n finish();\n }\n }, SCREEN_POLL_MS);\n\n stream.onAbort(() => finish());\n await done;\n clearInterval(poll);\n await pump;\n });\n });\n\n /**\n * Types into a live pane.\n *\n * This is the one route in the package that writes rather than reads, so it\n * is deliberately narrow: an owned target, still running, started\n * interactive, and a body that is nothing but text.\n */\n app.post(routes.input.path, async (context) => {\n const runId = context.req.param(RUN_ID_PARAM);\n const attachable = attachableOf(runId);\n if (attachable === undefined) return context.json({ error: NOT_ATTACHABLE }, 404);\n\n let body: unknown;\n try {\n body = await context.req.json();\n } catch {\n return context.json({ error: 'Expected a JSON body carrying text.' }, 400);\n }\n const text = isRecord(body) ? body.text : undefined;\n if (typeof text !== 'string' || text === '') return context.json({ error: 'Expected a non-empty text.' }, 400);\n if (text.length > MAX_INPUT_CHARS) return context.json({ error: 'That is more input than a pane takes.' }, 413);\n\n const delivered = await pane().input(attachable.target, text);\n if (!delivered) return context.json({ error: 'The runner did not take the input.' }, 502);\n return context.json({ delivered: true });\n });\n\n return app;\n}\n\n/** The named export a host imports from this package's built session entry. */\nexport const api: DoomApi = {\n basePath: RUNNER_API_BASE_PATH,\n start(context: DoomApiContext): DoomApiHandler {\n // The host is one session's server, so its id is the whole scope; a hub\n // would have handed no session at all, and there is nothing to answer.\n const app = createRunnerLogApi({\n sessionId: context.sessionId ?? '',\n cwd: context.cwd,\n environment: context.environment,\n });\n return {\n fetch: (request) => app.fetch(request),\n // Nothing outlives a request: a follow's watch and its poll are both\n // torn down when its own stream ends or the socket aborts.\n close: () => undefined,\n };\n },\n};\n","import { defineRoute } from '@agimon-ai/doompi-core/extensionFile';\n\nimport { api } from '../../../../../../services/runnerLogApi';\n\n/**\n * Mounts this package's HTTP API at `runner`, which the folder names.\n *\n * The folder is the declaration: the build reads the base path off it and hands\n * the browser a client bound to the same segment, so the mount is stated once\n * rather than in a constant and in four URL builders that were each free to\n * drift.\n */\nexport default defineRoute(api);\n","import { defineServerCommand, type WithRoot } from '@agimon-ai/doompi-core/extensionFile';\nimport type { DoomServerPluginContext } from '@agimon-ai/doompi-core/serverFacet';\n\nimport type { RunnerServerScope } from '../_lib/serverRoot';\nexport default defineServerCommand(\n (context: WithRoot<DoomServerPluginContext, RunnerServerScope>) => context.root.command,\n);\n","import { defineServerTool, type WithRoot } from '@agimon-ai/doompi-core/extensionFile';\nimport type { DoomServerPluginContext } from '@agimon-ai/doompi-core/serverFacet';\n\nimport type { RunnerServerScope } from '../_lib/serverRoot';\nexport default defineServerTool((context: WithRoot<DoomServerPluginContext, RunnerServerScope>) => context.root.tool);\n","// Generated by @agimon-ai/doompi-build. Do not edit by hand.\nimport { defineServerPlugin, type DoomServerSessionPlugin } from '@agimon-ai/doompi-core/serverFacet';\nimport { composeRootHooks } from '@agimon-ai/doompi-core/extensionFile';\n\nimport rootSession from '../src/extensions/workspaces/sessions/(backend)/root.server';\nimport channelRunnerRuns from '../src/extensions/(backend)/channel/runnerRuns.server';\nimport apiRoute from '../src/extensions/workspaces/sessions/(backend)/api/runner/route.server';\nimport commandRunners from '../src/extensions/workspaces/sessions/(backend)/command/runners.server';\nimport toolBash from '../src/extensions/workspaces/sessions/(backend)/tool/bash.server';\n\ntype Factory<T, C> = (context: C) => T;\nconst at = <T, C>(value: T | Factory<T, C>, context: C): T =>\n typeof value === 'function' ? (value as Factory<T, C>)(context) : value;\nconst via = <T>(identity: Record<string, unknown>, value: unknown): T =>\n (value === undefined ? undefined : { ...identity, ...(value as object) }) as T;\n\nexport const facet = defineServerPlugin({\n name: '@agimon-ai/doompi-runner',\n global: () => ({\n get channels(): DoomServerSessionPlugin['channels'] { return [() => via({ frameType: 'runner_runs' }, channelRunnerRuns())]; },\n }),\n workspace: () => ({\n get channels(): DoomServerSessionPlugin['channels'] { return [() => via({ frameType: 'runner_runs' }, channelRunnerRuns())]; },\n }),\n session: async (context) => {\n const scopeSession = await rootSession(context);\n const contextSession = { ...context, root: scopeSession.value };\n return {\n ...composeRootHooks(scopeSession),\n get api(): DoomServerSessionPlugin['api'] { return [at(apiRoute, contextSession)]; },\n get channels(): DoomServerSessionPlugin['channels'] { return [() => via({ frameType: 'runner_runs' }, channelRunnerRuns())]; },\n get commands(): DoomServerSessionPlugin['commands'] { return [via({ name: 'runners' }, at(commandRunners, contextSession))]; },\n get tools(): DoomServerSessionPlugin['tools'] { return [via({ name: 'bash' }, at(toolBash, contextSession))]; },\n services: [...(scopeSession.services ?? [])],\n get activities(): DoomServerSessionPlugin['activities'] { return [...(scopeSession.activities ?? [])]; },\n };\n },\n});\n\nexport default facet;\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"server.cjs","names":["isRunnerRecord","reconcileActiveRunners","stopRunnerProcess","formatRunnerFinished","DOOM_BACKGROUND_WORK_SERVICE","readDoomBackgroundWorkService","BACKGROUND_WORK_PROVIDER","createRunnerDependencies","createHeadlessBashTool","summarizeLog","createHeadlessRunnersCommand","defineRoot","defineChannel","fs","defineApiRoutes","apiResponse","path","fs","LogReader","RunnerPaths","resolveRunnerStoreDirectory","runnerStateDirFor","Hono","PtyBackendChain","RmuxBackend","TmuxBackend","routes","streamSSE","defineRoute","defineServerCommand","defineServerTool","defineServerPlugin","channelRunnerRuns","rootSession","composeRootHooks","apiRoute","commandRunners","toolBash"],"sources":["../../src/constants/webRunners.ts","../../src/constants/webRunnerRuns.ts","../../src/services/webRunnerRuns/index.ts","../../src/services/runnerServerRuntime/index.ts","../../src/extensions/workspaces/sessions/(backend)/_lib/serverRoot.ts","../../src/extensions/workspaces/sessions/(backend)/root.server.ts","../../src/services/runnersChannel/index.ts","../../src/extensions/(backend)/channel/runnerRuns.server.ts","../../src/constants/runnerLogApi.ts","../../src/constants/webRunnerLog.ts","../../src/services/logTail/index.ts","../../src/types/webRunnerLog.ts","../../src/types/apiRoutes.ts","../../src/services/runnerLogApi/index.ts","../../src/extensions/workspaces/sessions/(backend)/api/runner/route.server.ts","../../src/extensions/workspaces/sessions/(backend)/command/runners.server.ts","../../src/extensions/workspaces/sessions/(backend)/tool/bash.server.ts","../../generated/server.ts"],"sourcesContent":["export const RUNNER_RUNS_TYPE = 'runner_runs';\n","export const FINISHED_RUNNER_RETENTION_MS = 600_000;\n","import { FINISHED_RUNNER_RETENTION_MS } from '../../constants/webRunnerRuns';\nimport type { RunnerRecord } from '../../types/runnerRegistry';\nimport type { RunnerRunView } from '../../types/webRunners';\nimport { isRunnerRecord } from '../runnerRecord';\n\n/** How long a finished runner stays in the cockpit's feed after it exited. */\n\n/** One on-disk metadata record, or undefined when the text is not a runner record. */\nexport function parseRunnerRecord(raw: string): RunnerRecord | undefined {\n try {\n const parsed: unknown = JSON.parse(raw);\n return isRunnerRecord(parsed) ? parsed : undefined;\n } catch {\n return undefined; // Mid-write or foreign file; the next scan settles it.\n }\n}\n\nexport function toRunnerRunView(record: RunnerRecord): RunnerRunView {\n return {\n id: record.id,\n name: record.name,\n pid: record.pid,\n command: record.command,\n cwd: record.cwd,\n interactive: record.interactive,\n backend: record.backend,\n state: record.state,\n promoted: record.promoted,\n startedAt: record.startedAt,\n logPath: record.logPath,\n ...(record.exit\n ? {\n exit: {\n reason: record.exit.reason,\n code: record.exit.code,\n signal: record.exit.signal,\n ...(record.exit.terminationReason === undefined\n ? {}\n : { terminationReason: record.exit.terminationReason }),\n ...(record.exit.stopReason === undefined ? {} : { stopReason: record.exit.stopReason }),\n finishedAt: record.exit.finishedAt,\n },\n }\n : {}),\n };\n}\n\nfunction finishedAt(record: RunnerRecord): number {\n const parsed = record.exit === undefined ? Number.NaN : Date.parse(record.exit.finishedAt);\n return Number.isFinite(parsed) ? parsed : Date.parse(record.startedAt);\n}\n\n/**\n * The feed the cockpit shows: running runners first, newest start first, then\n * the recently finished ones, newest exit first. Finished runners older than\n * the retention window are left out; the registry keeps them for the CLI.\n */\nexport function presentRunnerRuns(records: readonly RunnerRecord[], now: number): RunnerRunView[] {\n const running = records\n .filter((record) => record.state === 'running')\n .sort((left, right) => right.startedAt.localeCompare(left.startedAt));\n const finished = records\n .filter((record) => record.state === 'completed' && now - finishedAt(record) <= FINISHED_RUNNER_RETENTION_MS)\n .sort((left, right) => finishedAt(right) - finishedAt(left));\n return [...running, ...finished].map(toRunnerRunView);\n}\n","import {\n DOOM_BACKGROUND_WORK_SERVICE,\n readDoomBackgroundWorkService,\n type BackgroundProviderWorkItem,\n type BackgroundWorkProviderHandle,\n} from '@agimon-ai/doompi-core/backgroundWork';\nimport type { DoomHeadlessActivity, DoomHeadlessExecutionContext } from '@agimon-ai/doompi-core/headless';\nimport type { DoomServerHostService } from '@agimon-ai/doompi-core/serverFacet';\nimport type { Context } from '@deepseek-ai/cordis';\n\nimport { BACKGROUND_WORK_PROVIDER } from '../../constants/runnerRuntime';\nimport { RUNNER_RUNS_TYPE } from '../../constants/webRunners';\nimport { reconcileActiveRunners, stopRunnerProcess } from '../reconcile';\nimport type { RunnerDependencies } from '../runnerDependencies/type';\nimport { formatRunnerFinished } from '../runnerRecord';\nimport { presentRunnerRuns } from '../webRunnerRuns';\n\nconst COMPLETION_RETRY_MS = 1_000;\n\nexport interface RunnerServerRuntime {\n readonly container: RunnerDependencies;\n readonly activity: DoomHeadlessActivity;\n readonly backgroundWorkPlugin: (context: Context) => void;\n ensureSession(sessionId: string): Promise<void>;\n /** Wakes the agent once when this runner, which it sent to the background, exits. */\n watchRunner(id: string): void;\n dispose(this: void): Promise<void>;\n}\nexport function createRunnerServerRuntime(\n container: RunnerDependencies,\n directEvents: NonNullable<DoomServerHostService['context']['directEvents']>,\n /** Absent when no agent runs in this session, so there is nobody to wake. */\n wakeAgent?: (content: string, requestId: string) => Promise<void>,\n): RunnerServerRuntime {\n let sessionId: string | undefined;\n let supervision: Promise<void> | undefined;\n let runtimeDisposal: Promise<void> | undefined;\n let backgroundWorkItems: BackgroundProviderWorkItem[] = [];\n let backgroundWorkProvider: BackgroundWorkProviderHandle | undefined;\n // Nothing else tells the model a background runner ended, so without this an\n // agent that finished its turn waiting on one idles until a person prompts it.\n const watched = new Set<string>();\n let unsubscribeWatched: (() => void) | undefined;\n let wakes: Promise<void> = Promise.resolve();\n let closing = false;\n let retryTimer: ReturnType<typeof setTimeout> | undefined;\n\n // The activity is optional and source-gated. The retained facet owns the\n // session runtime, so disabling the activity must not tear down its jobs.\n const ensureSession = (requestedSessionId: string): Promise<void> => {\n if (closing) return Promise.reject(new Error('Runner runtime is closing.'));\n if (sessionId !== undefined && sessionId !== requestedSessionId) {\n throw new Error(`Runner runtime belongs to session ${sessionId}.`);\n }\n sessionId ??= requestedSessionId;\n supervision ??= (async () => {\n container.paths.setSessionId(requestedSessionId);\n if ((await container.lifeline.arm(requestedSessionId)) === undefined) {\n throw new Error('Could not arm runner supervision lifeline.');\n }\n const reconciled = await reconcileActiveRunners({\n registry: container.runnerRegistry,\n launcher: container.launcher,\n rmuxBackend: container.rmuxBackend,\n processControl: container.processControl,\n currentHostPid: process.pid,\n startup: false,\n active: await container.runnerRegistry.listBySession(requestedSessionId),\n });\n for (const error of reconciled.errors) process.emitWarning(error);\n if (!closing) {\n unsubscribeRunnerUpdates ??= container.runnerRegistry.subscribe(\n () => requestPublish(requestedSessionId),\n requestedSessionId,\n );\n requestPublish(requestedSessionId);\n }\n })().catch((error: unknown) => {\n supervision = undefined;\n throw error;\n });\n return supervision;\n };\n\n const disposeRuntime = (): Promise<void> => {\n runtimeDisposal ??= (async () => {\n await supervision?.catch((error: unknown) =>\n process.emitWarning(`Could not initialize headless runner supervision: ${String(error)}`),\n );\n const ownedSessionId = sessionId;\n if (ownedSessionId === undefined) return;\n const records = await container.runnerRegistry.listBySession(ownedSessionId).catch(() => []);\n await Promise.all(\n records.map(async (record) => {\n try {\n const stopped = await stopRunnerProcess(record, container.launcher, container.rmuxBackend, {\n registry: container.runnerRegistry,\n intent: {\n reason: 'stopped',\n terminationReason: 'parent_session_cleanup',\n stopReason: 'session ended',\n },\n });\n if (!stopped && container.processControl.isAlive(record.pid)) {\n process.emitWarning(`Could not stop runner ${record.id} during session shutdown`);\n return;\n }\n await container.runnerRegistry.complete(\n record.id,\n { reason: 'stopped', code: null, signal: null, stopReason: 'session ended' },\n ownedSessionId,\n );\n } catch (error) {\n process.emitWarning(`Could not clean up runner ${record.id}: ${String(error)}`);\n }\n }),\n );\n try {\n await container.ptyHost.disposeAll();\n } finally {\n container.lifeline.dispose();\n container.runnerRegistry.close();\n }\n })();\n return runtimeDisposal;\n };\n\n const publishRuns = async (ownedSessionId: string): Promise<void> => {\n try {\n const records = await container.runnerRegistry.listAll(ownedSessionId);\n const nextBackgroundWork = records\n .filter((record) => record.state === 'running')\n .map((record) => ({\n id: record.id,\n sessionId: record.sessionId,\n label: record.name,\n status: record.state,\n }));\n if (JSON.stringify(nextBackgroundWork) !== JSON.stringify(backgroundWorkItems)) {\n backgroundWorkItems = nextBackgroundWork;\n backgroundWorkProvider?.update();\n }\n directEvents.publish(RUNNER_RUNS_TYPE, ownedSessionId, {\n runs: presentRunnerRuns(records, Date.now()),\n });\n } catch (error) {\n process.emitWarning(`Could not publish runner state: ${String(error)}`);\n }\n };\n let publishInFlight: Promise<void> | undefined;\n let publishAgain = false;\n let unsubscribeRunnerUpdates: (() => void) | undefined;\n const requestPublish = (ownedSessionId: string): void => {\n if (closing) return;\n publishAgain = true;\n if (publishInFlight !== undefined) return;\n publishInFlight = (async () => {\n do {\n publishAgain = false;\n await publishRuns(ownedSessionId);\n } while (publishAgain && !closing);\n })()\n .catch((error: unknown) => {\n process.emitWarning(`Could not schedule runner state publication: ${String(error)}`);\n })\n .finally(() => {\n publishInFlight = undefined;\n if (publishAgain) requestPublish(ownedSessionId);\n });\n };\n\n // Sequenced so two runners exiting in the same tick each wake the agent once.\n const wakeForFinished = (ownedSessionId: string): void => {\n if (closing) return;\n wakes = wakes\n .then(async () => {\n if (closing) return;\n let retry = false;\n // Deleting the entry being visited is safe for a Set iterator.\n for (const id of watched) {\n if (closing) return;\n try {\n const record = await container.runnerRegistry.get(id, ownedSessionId);\n if (closing) return;\n if (record?.state === 'running') continue;\n // A record swept before it was seen leaves nothing to report.\n if (record)\n await wakeAgent?.(\n formatRunnerFinished(record),\n `runner-finished:${JSON.stringify([ownedSessionId, id])}`,\n );\n watched.delete(id);\n } catch (error) {\n retry = true;\n process.emitWarning(`Could not report finished runner ${id}: ${String(error)}`);\n }\n }\n if (retry && !closing && retryTimer === undefined) {\n retryTimer = setTimeout(() => {\n retryTimer = undefined;\n wakeForFinished(ownedSessionId);\n }, COMPLETION_RETRY_MS);\n retryTimer.unref?.();\n }\n if (watched.size === 0) {\n clearTimeout(retryTimer);\n retryTimer = undefined;\n unsubscribeWatched?.();\n unsubscribeWatched = undefined;\n }\n })\n .catch((error: unknown) => {\n process.emitWarning(`Could not report a finished runner: ${String(error)}`);\n });\n };\n\n const watchRunner = (id: string): void => {\n const ownedSessionId = sessionId;\n if (!wakeAgent || closing || ownedSessionId === undefined) return;\n watched.add(id);\n // Independent of the optional activity: the wake-up must not depend on a\n // browser watching the runner list.\n unsubscribeWatched ??= container.runnerRegistry.subscribe(() => wakeForFinished(ownedSessionId), ownedSessionId);\n // It may already have exited between promotion and subscription.\n wakeForFinished(ownedSessionId);\n };\n\n const activity: DoomHeadlessActivity = {\n name: 'runner',\n start: async (executionContext: DoomHeadlessExecutionContext) => {\n await ensureSession(executionContext.sessionId);\n requestPublish(executionContext.sessionId);\n return () => {\n // Let the next optional activation reconcile again, while keeping\n // the retained runtime and all child job supervisors alive.\n supervision = undefined;\n };\n },\n };\n return {\n container,\n activity,\n ensureSession,\n watchRunner,\n backgroundWorkPlugin: (context) => {\n context.inject([DOOM_BACKGROUND_WORK_SERVICE], (serviceContext) => {\n const service = readDoomBackgroundWorkService(serviceContext);\n if (!service) return undefined;\n const registration = service.register({\n provider: BACKGROUND_WORK_PROVIDER,\n listActiveWork: () => backgroundWorkItems,\n });\n backgroundWorkProvider = registration;\n return () => {\n registration.dispose();\n if (backgroundWorkProvider === registration) backgroundWorkProvider = undefined;\n };\n });\n },\n async dispose() {\n // Session cleanup completes every runner it stops, and none of those may\n // start a turn in a session that is going away.\n closing = true;\n clearTimeout(retryTimer);\n retryTimer = undefined;\n watched.clear();\n unsubscribeWatched?.();\n unsubscribeWatched = undefined;\n unsubscribeRunnerUpdates?.();\n unsubscribeRunnerUpdates = undefined;\n await wakes;\n await publishInFlight;\n await disposeRuntime();\n },\n };\n}\n","import type { DoomServerPluginContext } from '@agimon-ai/doompi-core/serverFacet';\nimport type { Context } from '@deepseek-ai/cordis';\n\nimport { createHeadlessBashTool, createHeadlessRunnersCommand } from '../../../../../services/headless';\nimport { summarizeLog } from '../../../../../services/logReader';\nimport { createRunnerDependencies } from '../../../../../services/runnerDependencies';\nimport { createRunnerServerRuntime } from '../../../../../services/runnerServerRuntime';\n\nexport const RUNNER_SERVER_SCOPE_SERVICE = 'doom/runner-server-scope';\n\ndeclare module '@deepseek-ai/cordis' {\n interface Context {\n 'doom/runner-server-scope': RunnerServerScope;\n }\n}\n\nexport const createRunnerServerRoot = ({ host, agent, signal }: DoomServerPluginContext) => {\n if (!host.context.directEvents) throw new Error('Runner headless facet requires the session direct event bus.');\n if (!host.context.cwd || !host.context.environment) {\n throw new Error('Runner headless facet requires session cwd and environment.');\n }\n const dependencies = createRunnerDependencies({\n cwd: host.context.cwd,\n environment: host.context.environment,\n ...(host.context.executionBudget === undefined ? {} : { executionBudget: host.context.executionBudget }),\n });\n const execution = agent?.context;\n const wakeAgent =\n execution &&\n (async (content: string, requestId: string): Promise<void> => {\n try {\n // 'steer' joins a running turn and starts one on an idle session.\n // Reuse completion identity across retries and acknowledge admission, not settlement.\n if (!execution.session.admitPrompt) throw new Error('The session cannot admit a runner notification.');\n await execution.session.admitPrompt(content, 'steer', undefined, requestId);\n } catch (error) {\n // Keep completion ownership with the watcher until admission succeeds.\n await Promise.resolve()\n .then(() => execution.client.notify({ body: String(error), level: 'warning' }))\n .catch(() => undefined);\n throw error;\n }\n });\n const runtime = createRunnerServerRuntime(dependencies, host.context.directEvents, wakeAgent);\n const bash = (wakeOnExit: boolean) =>\n createHeadlessBashTool(\n {\n run: async (request) => {\n await runtime.ensureSession(request.sessionId);\n const result = await dependencies.bashRunService.run({\n ...request,\n ...(signal ? { signal: request.signal ? AbortSignal.any([signal, request.signal]) : signal } : {}),\n });\n // Without this, nothing tells the agent the runner ended.\n if (wakeOnExit && result.kind === 'promoted') runtime.watchRunner(result.id);\n return result;\n },\n },\n summarizeLog,\n );\n const value = {\n tool: bash(true),\n // Remote MCP runners must not wake the local agent; the remote caller polls instead.\n remoteTool: bash(false),\n command: createHeadlessRunnersCommand(dependencies),\n };\n return {\n value,\n services: [\n runtime.backgroundWorkPlugin,\n (context: Context) => {\n context.plugin((providerContext) => providerContext.provide(RUNNER_SERVER_SCOPE_SERVICE, value));\n },\n ],\n activities: [runtime.activity],\n onStop: runtime.dispose,\n };\n};\n\nexport type RunnerServerScope = Awaited<ReturnType<typeof createRunnerServerRoot>>['value'];\n","import { defineRoot } from '@agimon-ai/doompi-core/extensionFile';\n\nimport { createRunnerServerRoot } from './_lib/serverRoot';\n\nexport default defineRoot(createRunnerServerRoot);\n","import type { DoomHubChannel, DoomHubChannelSource } from '@agimon-ai/doompi-core/hubChannel';\n\nimport { RUNNER_RUNS_TYPE } from '../../constants/webRunners';\nimport { type RunnerRunView } from '../../types/webRunners';\n\nfunction runnerPayload(value: unknown): value is { runs: RunnerRunView[] } {\n return typeof value === 'object' && value !== null && Array.isArray((value as { runs?: unknown }).runs);\n}\n\n/**\n * The runners data channel consumes lifecycle-owned snapshots from the session\n * server. Durable registry files are read by the owning session only, never by\n * this hub channel.\n */\nexport function createRunnersChannel(): DoomHubChannel {\n return {\n frameType: RUNNER_RUNS_TYPE,\n start(host) {\n const latest = new Map<string, RunnerRunView[]>();\n const subscriptions = new Map<string, () => void>();\n const source: DoomHubChannelSource = {\n payloadFor(scope) {\n const runs = latest.get(scope.sessionId);\n return runs === undefined ? undefined : { runs };\n },\n sessionAdded(scope) {\n subscriptions.get(scope.sessionId)?.();\n latest.delete(scope.sessionId);\n const unsubscribe = host.directEvents.subscribe(\n RUNNER_RUNS_TYPE,\n scope.sessionId,\n (payload) => {\n if (!runnerPayload(payload)) return;\n latest.set(scope.sessionId, payload.runs);\n host.publish(scope.sessionId, payload);\n },\n { replayLatest: true },\n );\n subscriptions.set(scope.sessionId, unsubscribe);\n },\n sessionRemoved(sessionId) {\n subscriptions.get(sessionId)?.();\n subscriptions.delete(sessionId);\n latest.delete(sessionId);\n },\n close() {\n for (const unsubscribe of subscriptions.values()) unsubscribe();\n subscriptions.clear();\n latest.clear();\n },\n };\n return source;\n },\n };\n}\n","import { defineChannel } from '@agimon-ai/doompi-core/extensionFile';\nimport type { DoomHubChannel } from '@agimon-ai/doompi-core/hubChannel';\n\nimport { createRunnersChannel } from '../../../services/runnersChannel';\nexport default defineChannel(function runnerRunsChannel(): DoomHubChannel {\n return createRunnersChannel();\n});\n","export const LOG_DIR_NAME = 'logs';\nexport const STATE_EXTENSION = '.json';\nexport const RUNNING_STATE = 'running';\nexport const STATE_POLL_MS = 1000;\nexport const HEARTBEAT_MS = 15_000;\nexport const DEFAULT_LINES = 200;\nexport const MAX_LINES = 5000;\nexport const SAFE_SEGMENT = /^[\\w.@-]+$/;\nexport const SCREEN_POLL_MS = 100;\nexport const RAW_LOG_SUFFIX = '.raw';\nexport const MAX_INPUT_CHARS = 4096;\nexport const NOT_ATTACHABLE = 'No attachable runner by that name in this session.';\n","export const RUNNER_API_BASE_PATH = 'runner';\nexport const RUN_ID_PARAM = 'runId';\nexport const RUNNER_SCREEN_EVENT = 'screen';\nexport const RUNNER_LOG_STREAM_EVENT = 'append';\nexport const RUNNER_LOG_PING_EVENT = 'ping';\n","import fs from 'node:fs';\n\nimport { POLL_MS } from '../../constants/logTail';\nimport type { ILogTail, LogTailHandle, LogTailOptions } from '../../types/logTail';\n\n/** How often the tail restats the file; the same cadence the CLI's --follow uses. */\n\n/**\n * Polling tail: read the delta between the last offset and the file's current\n * size, and hand back only complete lines.\n *\n * Same reliability posture as this package's runner watcher: the poll is the\n * mechanism, not an accelerator for a change notification. fs.watchFile only\n * fires when it observes a difference from a baseline it took itself, so a\n * write that lands before its first stat is a change it never reports; a\n * restat every tick has no such window.\n *\n * A write that lands mid-line is common, so the trailing fragment is held\n * until its newline arrives rather than emitted as a line that will change.\n */\nexport class LogTail implements ILogTail {\n follow(logPath: string, options: LogTailOptions): LogTailHandle {\n let offset = options.from;\n let pending = '';\n let closed = false;\n\n const drain = (size: number): void => {\n // A shrunken file was rotated or truncated; start over rather than\n // reading from an offset that now points into different content.\n if (size < offset) {\n offset = 0;\n pending = '';\n }\n if (size === offset) return;\n const length = size - offset;\n const buffer = Buffer.alloc(length);\n const descriptor = fs.openSync(logPath, 'r');\n let bytesRead: number;\n try {\n bytesRead = fs.readSync(descriptor, buffer, 0, length, offset);\n } finally {\n fs.closeSync(descriptor);\n }\n offset = size;\n if (bytesRead <= 0) return;\n pending += buffer.subarray(0, bytesRead).toString('utf8');\n const lines = pending.split('\\n');\n pending = lines.pop() ?? '';\n if (lines.length > 0) options.onLines(lines, offset - Buffer.byteLength(pending, 'utf8'));\n };\n\n const tick = (): void => {\n if (closed) return;\n try {\n drain(fs.statSync(logPath).size);\n } catch (error) {\n // The runner has not opened its log yet; the next tick settles it.\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;\n options.onError(error instanceof Error ? error : new Error(String(error)));\n }\n };\n\n const timer = setInterval(tick, POLL_MS);\n // Following a log must never be the reason a process stays alive.\n timer.unref?.();\n return {\n close() {\n if (closed) return;\n closed = true;\n clearInterval(timer);\n },\n };\n }\n}\n","import type { LogSlice } from './logReader';\n\n/**\n * The runner log API, shared by this package's session-scoped routes and its\n * cockpit plugin. The two halves run in different processes, so the wire\n * vocabulary is declared here: the browser half may reach `src/types` and\n * nothing else on the server side.\n *\n * Where the routes live is not here. That is `src/types/apiRoutes`, which both\n * halves read, and the generated client turns into the URLs the page addresses.\n * The file a run writes to is still read from its metadata record by the server\n * alone.\n */\n\n/**\n * One chunk of an attached pane's output.\n *\n * Base64 because these are terminal bytes, escape sequences and all, and a\n * server-sent event is a line-oriented text frame that would mangle them.\n */\nexport interface RunnerScreenEvent {\n chunk: string;\n /** Byte offset just past this chunk, so a reconnect resumes rather than replays. */\n offset?: number;\n /** Set once the runner exits, so the page can stop watching. */\n ended?: boolean;\n}\n\n/** The body of a keystroke POST. Text, not key codes: the pane wants bytes. */\nexport interface RunnerInputRequest {\n text: string;\n}\n\n/** The named SSE event carrying a RunnerScreenEvent payload. */\n\n/**\n * A log request as query parameters. `grep` is a literal substring, not a\n * regular expression, because that is what the reader matches; `lines` bounds\n * the answer to the last N lines of whatever survived the filter.\n */\nexport interface RunnerLogQueryParams {\n lines?: number;\n grep?: string;\n ignoreCase?: boolean;\n contextLines?: number;\n}\n\n/** The slice the log route answers with, plus which runner it came from. */\nexport interface RunnerLogResponse extends LogSlice {\n runId: string;\n /** True while the runner is still writing, so the page knows following is worth offering. */\n running: boolean;\n}\n\n/**\n * One server-sent event on the follow stream: the lines appended since the last\n * one. The stream never filters: a page that sets a query stops following and\n * reads a filtered slice instead, because grep with context is a whole-file\n * answer and cannot be assembled from a chunk at a time.\n */\nexport interface RunnerLogStreamEvent {\n /** Appended lines, in order; empty when the event only reports the runner ending. */\n lines: string[];\n /**\n * Byte offset just past the last line in this event.\n *\n * A follower that loses the stream resumes here. Without it the page could\n * only fall back to the offset its last slice reported, which every line\n * delivered since has already moved past, so a reconnect would replay them.\n */\n offset?: number;\n /** Set once the runner exits, so the page can stop following without polling the run list. */\n ended?: boolean;\n}\n\n/** The named SSE event carrying a RunnerLogStreamEvent payload. */\n\n/**\n * The named SSE event that carries nothing.\n *\n * A runner can be alive and silent for minutes, which on the wire is\n * indistinguishable from a connection that died. This is sent on a timer so\n * the socket keeps proving itself. Readers that only listen for `append`\n * ignore it, which is what makes it safe to add.\n */\n\n/** Query parameter names, shared so the page and the route cannot drift apart. */\nexport const RUNNER_LOG_PARAMS = {\n lines: 'lines',\n grep: 'grep',\n ignoreCase: 'ignoreCase',\n contextLines: 'contextLines',\n from: 'from',\n} as const;\n","import { apiResponse, defineApiRoutes } from '@agimon-ai/doompi-core/web';\n\nimport { RUN_ID_PARAM } from '../constants/webRunnerLog';\nimport { RUNNER_LOG_PARAMS, type RunnerLogResponse } from './webRunnerLog';\n\n/**\n * This package's routes, as data.\n *\n * No scope and no base path: the build reads both off\n * `src/extensions/workspaces/sessions/(backend)/api/runner/`, so the mount is\n * stated once, by the folder that creates it. This package stated it four times\n * before, once per URL builder, each of them re-spelling `/plugins/` and the\n * session prefix by hand.\n *\n * The Hono app registers these paths verbatim and the browser addresses them,\n * so a path cannot move on one side alone. `:runId` is Hono's own parameter\n * syntax for that reason; a caller supplies the value as `params`, and the\n * client encodes the segment.\n */\nexport default defineApiRoutes({\n log: {\n method: 'GET',\n path: `/runners/:${RUN_ID_PARAM}/log`,\n query: [\n RUNNER_LOG_PARAMS.lines,\n RUNNER_LOG_PARAMS.grep,\n RUNNER_LOG_PARAMS.ignoreCase,\n RUNNER_LOG_PARAMS.contextLines,\n ],\n response: apiResponse<RunnerLogResponse>(),\n },\n /**\n * The live tail of that same log.\n *\n * `stream: true`, so the client exposes a URL and no call method: the\n * consumer is the browser's own `EventSource`, and a relayed call would\n * buffer a stream that never ends.\n */\n logStream: {\n method: 'GET',\n path: `/runners/:${RUN_ID_PARAM}/log/stream`,\n query: [RUNNER_LOG_PARAMS.from],\n stream: true,\n },\n /** The unscrubbed pane bytes, for a terminal emulator rather than a reader. */\n screenStream: {\n method: 'GET',\n path: `/runners/:${RUN_ID_PARAM}/screen/stream`,\n query: [RUNNER_LOG_PARAMS.from],\n stream: true,\n },\n input: {\n method: 'POST',\n path: `/runners/:${RUN_ID_PARAM}/screen/input`,\n },\n});\n","import fs from 'node:fs';\nimport path from 'node:path';\n\nimport type { DoomApi, DoomApiContext, DoomApiHandler } from '@agimon-ai/doompi-core/packageApi';\nimport { Hono } from 'hono';\nimport { streamSSE } from 'hono/streaming';\n\nimport {\n DEFAULT_LINES,\n HEARTBEAT_MS,\n LOG_DIR_NAME,\n MAX_INPUT_CHARS,\n MAX_LINES,\n NOT_ATTACHABLE,\n RAW_LOG_SUFFIX,\n RUNNING_STATE,\n SAFE_SEGMENT,\n SCREEN_POLL_MS,\n STATE_EXTENSION,\n STATE_POLL_MS,\n} from '../../constants/runnerLogApi';\nimport {\n RUN_ID_PARAM,\n RUNNER_API_BASE_PATH,\n RUNNER_LOG_PING_EVENT,\n RUNNER_LOG_STREAM_EVENT,\n RUNNER_SCREEN_EVENT,\n} from '../../constants/webRunnerLog';\nimport { LogReader } from '../../services/logReader';\nimport { LogTail } from '../../services/logTail';\nimport { PtyBackendChain } from '../../services/ptyBackendChain';\nimport { RmuxBackend } from '../../services/rmuxBackend';\nimport { RunnerPaths, resolveRunnerStoreDirectory, runnerStateDirFor } from '../../services/runnerPaths';\nimport { TmuxBackend } from '../../services/tmuxBackend';\nimport { parseRunnerRecord } from '../../services/webRunnerRuns';\nimport routes from '../../types/apiRoutes';\nimport type { ILogReader, LogQuery } from '../../types/logReader';\nimport type { ILogTail } from '../../types/logTail';\nimport type { IRmuxBackend } from '../../types/rmuxBackend';\nimport type { RunnerRecord } from '../../types/runnerRegistry';\nimport {\n RUNNER_LOG_PARAMS,\n type RunnerLogResponse,\n type RunnerLogStreamEvent,\n type RunnerScreenEvent,\n} from '../../types/webRunnerLog';\n\n/** How often a follow re-reads the record to notice the runner exiting. */\n/**\n * How often a silent but living stream proves it is still there. Short enough\n * that an idle proxy does not reap the socket, long enough to cost nothing.\n */\n/** Kept in step with LogQuery's own default, so an unasked-for tail is the same size everywhere. */\n/** A page that asks for more than this gets this; the whole point of the route is a bounded read. */\n/** Path segments the client supplies are names, never paths. */\n/** Backends that expose a pane another process can read and write. */\nconst MULTIPLEXER_BACKENDS: ReadonlySet<string> = new Set(['rmux', 'tmux']);\n/** How often an attached pane's new bytes are picked up. */\n/** The unscrubbed copy the runner's sink writes beside the log, for interactive runs. */\n/** One keystroke batch; a paste is not a file transfer. */\n/** Said the same way for every reason, so the route never reports which check failed. */\n\nexport interface RunnerLogApiOptions {\n /** The session these routes answer for; the host owns exactly one. */\n sessionId: string;\n /** The store root; defaults to this environment's agent directory. */\n storeDir?: string;\n environment?: Readonly<Record<string, string | undefined>>;\n cwd?: string;\n logReader?: ILogReader;\n logTail?: ILogTail;\n /**\n * Reaches a live pane to read its screen and send it input.\n *\n * A session API is handed nothing but its session id, so it cannot touch the\n * in-process PTY handles the extension holds. This talks to the multiplexer\n * over its own socket instead, the same way the CLI's `input` verb does,\n * which is what makes an attached pane reachable across processes at all.\n */\n pane?: IRmuxBackend;\n}\n\n/** Rejects anything that could climb out of the session's own directory before a path is built. */\nfunction isSafeSegment(value: string): boolean {\n return value !== '' && value !== '.' && value !== '..' && SAFE_SEGMENT.test(value);\n}\n\n/**\n * The log file a run wrote, or undefined when there is no such run or its\n * record points somewhere it has no business pointing.\n *\n * The record names its own log path, and that record is written by another\n * process, so the path is checked rather than trusted: it must sit inside this\n * session's own logs directory. Without that check a doctored record would\n * turn this route into an arbitrary file read.\n */\nfunction resolveLogFile(stateDir: string, logsDir: string, sessionId: string, runId: string): RunnerRecord | undefined {\n const statePath = path.join(stateDir, `${runId}${STATE_EXTENSION}`);\n let raw: string;\n try {\n raw = fs.readFileSync(statePath, 'utf8');\n } catch {\n return undefined; // No such run in this session; the route answers 404.\n }\n const record = parseRunnerRecord(raw);\n if (record === undefined || record.id !== runId || record.sessionId !== sessionId) return undefined;\n const resolved = path.resolve(record.logPath);\n if (resolved !== logsDir && !resolved.startsWith(`${logsDir}${path.sep}`)) return undefined;\n return record;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nfunction numberParam(value: string | undefined, fallback: number, max: number): number {\n const parsed = value === undefined ? Number.NaN : Number.parseInt(value, 10);\n if (!Number.isFinite(parsed) || parsed < 0) return fallback;\n return Math.min(parsed, max);\n}\n\nfunction logQueryOf(url: URL): LogQuery {\n const grep = url.searchParams.get(RUNNER_LOG_PARAMS.grep) ?? undefined;\n return {\n lines: numberParam(url.searchParams.get(RUNNER_LOG_PARAMS.lines) ?? undefined, DEFAULT_LINES, MAX_LINES),\n ...(grep === undefined || grep === '' ? {} : { grep }),\n ignoreCase: url.searchParams.get(RUNNER_LOG_PARAMS.ignoreCase) === 'true',\n contextLines: numberParam(url.searchParams.get(RUNNER_LOG_PARAMS.contextLines) ?? undefined, 0, MAX_LINES),\n };\n}\n\n/**\n * This package's HTTP surface: one runner's log, as a bounded slice and as a\n * live tail.\n *\n * The routes are mounted inside one session's own server, so they name a runner\n * and nothing else; the session is the host, not a parameter. The reader only\n * ever returns the last N lines of what matched, so both routes stay bounded no\n * matter how large the file on disk is.\n *\n * Paths come from src/types/apiRoutes, the one table the browser's generated\n * client reads too, so this app and the cockpit cannot disagree about where a\n * route lives. They stay relative to the mount the host strips.\n */\nexport function createRunnerLogApi(options: RunnerLogApiOptions): Hono {\n const logReader = options.logReader ?? new LogReader();\n const logTail = options.logTail ?? new LogTail();\n const sessionId = options.sessionId;\n const paths = new RunnerPaths(options.cwd, options.environment);\n const storeDir = options.storeDir ?? resolveRunnerStoreDirectory(options.environment ?? process.env, options.cwd);\n const stateDir =\n options.storeDir === undefined ? paths.stateDirectory(sessionId) : runnerStateDirFor(storeDir, sessionId);\n const logsDir =\n options.storeDir === undefined ? paths.logDirectory(sessionId) : path.join(storeDir, sessionId, LOG_DIR_NAME);\n const app = new Hono();\n\n // Built on first use, not on mount: most sessions never attach to a pane,\n // and constructing this probes the filesystem for a multiplexer binary.\n let paneBackend: IRmuxBackend | undefined = options.pane;\n const pane = (): IRmuxBackend => {\n paneBackend ??= (() => {\n const paths = new RunnerPaths(options.cwd, options.environment);\n return new PtyBackendChain(new RmuxBackend(paths), new TmuxBackend(paths));\n })();\n return paneBackend;\n };\n\n /** The record behind a request, or undefined when this session has no such run. */\n const runnerOf = (runId: string): RunnerRecord | undefined =>\n isSafeSegment(runId) ? resolveLogFile(stateDir, logsDir, sessionId, runId) : undefined;\n\n /**\n * The pane a request may attach to.\n *\n * Same posture as the log routes: the record is written by another process,\n * so nothing on it is trusted until it is checked. The target must be one\n * this package owns, the run must still be up, and it must have been started\n * interactive. A non-interactive run has no terminal to type into, and a\n * subprocess-backed run has no pane at all.\n */\n const attachableOf = (runId: string): { record: RunnerRecord; target: string } | undefined => {\n const record = runnerOf(runId);\n if (record === undefined || record.state !== RUNNING_STATE || record.interactive !== true) return undefined;\n const target = record.backendTarget;\n if (target === undefined || !MULTIPLEXER_BACKENDS.has(record.backend) || !isSafeSegment(target)) return undefined;\n return { record, target };\n };\n app.get(routes.log.path, (context) => {\n const runId = context.req.param(RUN_ID_PARAM);\n const record = runnerOf(runId);\n if (record === undefined) return context.json({ error: `No runner '${runId}' in this session.` }, 404);\n const slice = logReader.read(record.logPath, logQueryOf(new URL(context.req.url)));\n const body: RunnerLogResponse = { ...slice, runId, running: record.state === RUNNING_STATE };\n return context.json(body);\n });\n\n app.get(routes.logStream.path, (context) => {\n const runId = context.req.param(RUN_ID_PARAM);\n const record = runnerOf(runId);\n if (record === undefined) return context.json({ error: `No runner '${runId}' in this session.` }, 404);\n const from = numberParam(\n new URL(context.req.url).searchParams.get(RUNNER_LOG_PARAMS.from) ?? undefined,\n 0,\n Number.MAX_SAFE_INTEGER,\n );\n\n return streamSSE(context, async (stream) => {\n const send = (event: RunnerLogStreamEvent): Promise<void> =>\n stream.writeSSE({ event: RUNNER_LOG_STREAM_EVENT, data: JSON.stringify(event) });\n\n // The tail and the state poll both push onto one queue, so the stream\n // writes in the order things happened rather than interleaving two\n // writers onto the same socket.\n let pump: Promise<void> = Promise.resolve();\n const queue = (event: RunnerLogStreamEvent): void => {\n pump = pump.then(() => send(event)).catch(() => undefined);\n };\n\n let finish = (): void => undefined;\n const done = new Promise<void>((resolve) => {\n finish = resolve;\n });\n const handle = logTail.follow(record.logPath, {\n from,\n onLines: (lines, completeThrough) => queue({ lines, offset: completeThrough }),\n onError: () => finish(),\n });\n // A finished runner writes nothing more, so the page is told once and\n // the stream ends rather than holding a socket open forever.\n const poll = setInterval(() => {\n const current = runnerOf(runId);\n if (current !== undefined && current.state === RUNNING_STATE) return;\n queue({ lines: [], ended: true });\n finish();\n }, STATE_POLL_MS);\n // A runner can be alive and produce nothing for minutes. Without this the\n // page cannot tell that from a stream that quietly died.\n const heartbeat = setInterval(() => {\n pump = pump.then(() => stream.writeSSE({ event: RUNNER_LOG_PING_EVENT, data: '' })).catch(() => undefined);\n }, HEARTBEAT_MS);\n\n stream.onAbort(() => finish());\n await done;\n clearInterval(poll);\n clearInterval(heartbeat);\n handle.close();\n await pump;\n });\n });\n\n app.get(routes.screenStream.path, (context) => {\n const runId = context.req.param(RUN_ID_PARAM);\n const attachable = attachableOf(runId);\n if (attachable === undefined) return context.json({ error: NOT_ATTACHABLE }, 404);\n // The record's log path is already proven to sit inside this session's own\n // logs directory, and the raw copy is that same path with a suffix, so it\n // cannot point anywhere new.\n const rawPath = `${attachable.record.logPath}${RAW_LOG_SUFFIX}`;\n const from = numberParam(\n new URL(context.req.url).searchParams.get(RUNNER_LOG_PARAMS.from) ?? undefined,\n 0,\n Number.MAX_SAFE_INTEGER,\n );\n\n return streamSSE(context, async (stream) => {\n let pump: Promise<void> = Promise.resolve();\n const queue = (event: RunnerScreenEvent): void => {\n pump = pump\n .then(() => stream.writeSSE({ event: RUNNER_SCREEN_EVENT, data: JSON.stringify(event) }))\n .catch(() => undefined);\n };\n\n let finish = (): void => undefined;\n const done = new Promise<void>((resolve) => {\n finish = resolve;\n });\n\n let offset = from;\n const drain = (): void => {\n let size: number;\n try {\n size = fs.statSync(rawPath).size;\n } catch {\n return; // The sink has not opened the file yet; the next tick settles it.\n }\n // The sink starts the file over when it passes its ceiling, so a\n // smaller file is a fresh one rather than a bad offset.\n if (size < offset) offset = 0;\n if (size === offset) return;\n const length = size - offset;\n const buffer = Buffer.alloc(length);\n const descriptor = fs.openSync(rawPath, 'r');\n let read: number;\n try {\n read = fs.readSync(descriptor, buffer, 0, length, offset);\n } finally {\n fs.closeSync(descriptor);\n }\n if (read <= 0) return;\n offset += read;\n // Bytes, not lines: a terminal's escape sequences straddle newlines and\n // mean nothing once split on them.\n queue({ chunk: buffer.subarray(0, read).toString('base64'), offset });\n };\n\n const poll = setInterval(() => {\n const current = runnerOf(runId);\n drain();\n if (current === undefined || current.state !== RUNNING_STATE) {\n queue({ chunk: '', offset, ended: true });\n finish();\n }\n }, SCREEN_POLL_MS);\n\n stream.onAbort(() => finish());\n await done;\n clearInterval(poll);\n await pump;\n });\n });\n\n /**\n * Types into a live pane.\n *\n * This is the one route in the package that writes rather than reads, so it\n * is deliberately narrow: an owned target, still running, started\n * interactive, and a body that is nothing but text.\n */\n app.post(routes.input.path, async (context) => {\n const runId = context.req.param(RUN_ID_PARAM);\n const attachable = attachableOf(runId);\n if (attachable === undefined) return context.json({ error: NOT_ATTACHABLE }, 404);\n\n let body: unknown;\n try {\n body = await context.req.json();\n } catch {\n return context.json({ error: 'Expected a JSON body carrying text.' }, 400);\n }\n const text = isRecord(body) ? body.text : undefined;\n if (typeof text !== 'string' || text === '') return context.json({ error: 'Expected a non-empty text.' }, 400);\n if (text.length > MAX_INPUT_CHARS) return context.json({ error: 'That is more input than a pane takes.' }, 413);\n\n const delivered = await pane().input(attachable.target, text);\n if (!delivered) return context.json({ error: 'The runner did not take the input.' }, 502);\n return context.json({ delivered: true });\n });\n\n return app;\n}\n\n/** The named export a host imports from this package's built session entry. */\nexport const api: DoomApi = {\n basePath: RUNNER_API_BASE_PATH,\n start(context: DoomApiContext): DoomApiHandler {\n // The host is one session's server, so its id is the whole scope; a hub\n // would have handed no session at all, and there is nothing to answer.\n const app = createRunnerLogApi({\n sessionId: context.sessionId ?? '',\n cwd: context.cwd,\n environment: context.environment,\n });\n return {\n fetch: (request) => app.fetch(request),\n // Nothing outlives a request: a follow's watch and its poll are both\n // torn down when its own stream ends or the socket aborts.\n close: () => undefined,\n };\n },\n};\n","import { defineRoute } from '@agimon-ai/doompi-core/extensionFile';\n\nimport { api } from '../../../../../../services/runnerLogApi';\n\n/**\n * Mounts this package's HTTP API at `runner`, which the folder names.\n *\n * The folder is the declaration: the build reads the base path off it and hands\n * the browser a client bound to the same segment, so the mount is stated once\n * rather than in a constant and in four URL builders that were each free to\n * drift.\n */\nexport default defineRoute(api);\n","import { defineServerCommand, type WithRoot } from '@agimon-ai/doompi-core/extensionFile';\nimport type { DoomServerPluginContext } from '@agimon-ai/doompi-core/serverFacet';\n\nimport type { RunnerServerScope } from '../_lib/serverRoot';\nexport default defineServerCommand(\n (context: WithRoot<DoomServerPluginContext, RunnerServerScope>) => context.root.command,\n);\n","import { defineServerTool, type WithRoot } from '@agimon-ai/doompi-core/extensionFile';\nimport type { DoomServerPluginContext } from '@agimon-ai/doompi-core/serverFacet';\n\nimport type { RunnerServerScope } from '../_lib/serverRoot';\nexport default defineServerTool((context: WithRoot<DoomServerPluginContext, RunnerServerScope>) => context.root.tool);\n","// Generated by @agimon-ai/doompi-build. Do not edit by hand.\nimport { defineServerPlugin, type DoomServerSessionPlugin } from '@agimon-ai/doompi-core/serverFacet';\nimport { composeRootHooks } from '@agimon-ai/doompi-core/extensionFile';\n\nimport rootSession from '../src/extensions/workspaces/sessions/(backend)/root.server';\nimport channelRunnerRuns from '../src/extensions/(backend)/channel/runnerRuns.server';\nimport apiRoute from '../src/extensions/workspaces/sessions/(backend)/api/runner/route.server';\nimport commandRunners from '../src/extensions/workspaces/sessions/(backend)/command/runners.server';\nimport toolBash from '../src/extensions/workspaces/sessions/(backend)/tool/bash.server';\n\ntype Factory<T, C> = (context: C) => T;\nconst at = <T, C>(value: T | Factory<T, C>, context: C): T =>\n typeof value === 'function' ? (value as Factory<T, C>)(context) : value;\nconst via = <T>(identity: Record<string, unknown>, value: unknown): T =>\n (value === undefined ? undefined : { ...identity, ...(value as object) }) as T;\n\nexport const facet = defineServerPlugin({\n name: '@agimon-ai/doompi-runner',\n global: () => ({\n get channels(): DoomServerSessionPlugin['channels'] { return [() => via({ frameType: 'runner_runs' }, channelRunnerRuns())]; },\n }),\n workspace: () => ({\n get channels(): DoomServerSessionPlugin['channels'] { return [() => via({ frameType: 'runner_runs' }, channelRunnerRuns())]; },\n }),\n session: async (context) => {\n const scopeSession = await rootSession(context);\n const contextSession = { ...context, root: scopeSession.value };\n return {\n ...composeRootHooks(scopeSession),\n get api(): DoomServerSessionPlugin['api'] { return [at(apiRoute, contextSession)]; },\n get channels(): DoomServerSessionPlugin['channels'] { return [() => via({ frameType: 'runner_runs' }, channelRunnerRuns())]; },\n get commands(): DoomServerSessionPlugin['commands'] { return [via({ name: 'runners' }, at(commandRunners, contextSession))]; },\n get tools(): DoomServerSessionPlugin['tools'] { return [via({ name: 'bash' }, at(toolBash, contextSession))]; },\n services: [...(scopeSession.services ?? [])],\n get activities(): DoomServerSessionPlugin['activities'] { return [...(scopeSession.activities ?? [])]; },\n };\n },\n});\n\nexport default facet;\n"],"mappings":"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"}
|
|
@@ -292,16 +292,18 @@ const createRunnerServerRoot = ({ host, agent, signal }) => {
|
|
|
292
292
|
}
|
|
293
293
|
});
|
|
294
294
|
const runtime = createRunnerServerRuntime(dependencies, host.context.directEvents, wakeAgent);
|
|
295
|
+
const bash = (wakeOnExit) => createHeadlessBashTool({ run: async (request) => {
|
|
296
|
+
await runtime.ensureSession(request.sessionId);
|
|
297
|
+
const result = await dependencies.bashRunService.run({
|
|
298
|
+
...request,
|
|
299
|
+
...signal ? { signal: request.signal ? AbortSignal.any([signal, request.signal]) : signal } : {}
|
|
300
|
+
});
|
|
301
|
+
if (wakeOnExit && result.kind === "promoted") runtime.watchRunner(result.id);
|
|
302
|
+
return result;
|
|
303
|
+
} }, summarizeLog);
|
|
295
304
|
const value = {
|
|
296
|
-
tool:
|
|
297
|
-
|
|
298
|
-
const result = await dependencies.bashRunService.run({
|
|
299
|
-
...request,
|
|
300
|
-
...signal ? { signal: request.signal ? AbortSignal.any([signal, request.signal]) : signal } : {}
|
|
301
|
-
});
|
|
302
|
-
if (result.kind === "promoted") runtime.watchRunner(result.id);
|
|
303
|
-
return result;
|
|
304
|
-
} }, summarizeLog),
|
|
305
|
+
tool: bash(true),
|
|
306
|
+
remoteTool: bash(false),
|
|
305
307
|
command: createHeadlessRunnersCommand(dependencies)
|
|
306
308
|
};
|
|
307
309
|
return {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.mjs","names":["routes","channelRunnerRuns","rootSession","apiRoute","commandRunners","toolBash"],"sources":["../../src/constants/webRunners.ts","../../src/constants/webRunnerRuns.ts","../../src/services/webRunnerRuns/index.ts","../../src/services/runnerServerRuntime/index.ts","../../src/extensions/workspaces/sessions/(backend)/_lib/serverRoot.ts","../../src/extensions/workspaces/sessions/(backend)/root.server.ts","../../src/services/runnersChannel/index.ts","../../src/extensions/(backend)/channel/runnerRuns.server.ts","../../src/constants/runnerLogApi.ts","../../src/constants/webRunnerLog.ts","../../src/services/logTail/index.ts","../../src/types/webRunnerLog.ts","../../src/types/apiRoutes.ts","../../src/services/runnerLogApi/index.ts","../../src/extensions/workspaces/sessions/(backend)/api/runner/route.server.ts","../../src/extensions/workspaces/sessions/(backend)/command/runners.server.ts","../../src/extensions/workspaces/sessions/(backend)/tool/bash.server.ts","../../generated/server.ts"],"sourcesContent":["export const RUNNER_RUNS_TYPE = 'runner_runs';\n","export const FINISHED_RUNNER_RETENTION_MS = 600_000;\n","import { FINISHED_RUNNER_RETENTION_MS } from '../../constants/webRunnerRuns';\nimport type { RunnerRecord } from '../../types/runnerRegistry';\nimport type { RunnerRunView } from '../../types/webRunners';\nimport { isRunnerRecord } from '../runnerRecord';\n\n/** How long a finished runner stays in the cockpit's feed after it exited. */\n\n/** One on-disk metadata record, or undefined when the text is not a runner record. */\nexport function parseRunnerRecord(raw: string): RunnerRecord | undefined {\n try {\n const parsed: unknown = JSON.parse(raw);\n return isRunnerRecord(parsed) ? parsed : undefined;\n } catch {\n return undefined; // Mid-write or foreign file; the next scan settles it.\n }\n}\n\nexport function toRunnerRunView(record: RunnerRecord): RunnerRunView {\n return {\n id: record.id,\n name: record.name,\n pid: record.pid,\n command: record.command,\n cwd: record.cwd,\n interactive: record.interactive,\n backend: record.backend,\n state: record.state,\n promoted: record.promoted,\n startedAt: record.startedAt,\n logPath: record.logPath,\n ...(record.exit\n ? {\n exit: {\n reason: record.exit.reason,\n code: record.exit.code,\n signal: record.exit.signal,\n ...(record.exit.terminationReason === undefined\n ? {}\n : { terminationReason: record.exit.terminationReason }),\n ...(record.exit.stopReason === undefined ? {} : { stopReason: record.exit.stopReason }),\n finishedAt: record.exit.finishedAt,\n },\n }\n : {}),\n };\n}\n\nfunction finishedAt(record: RunnerRecord): number {\n const parsed = record.exit === undefined ? Number.NaN : Date.parse(record.exit.finishedAt);\n return Number.isFinite(parsed) ? parsed : Date.parse(record.startedAt);\n}\n\n/**\n * The feed the cockpit shows: running runners first, newest start first, then\n * the recently finished ones, newest exit first. Finished runners older than\n * the retention window are left out; the registry keeps them for the CLI.\n */\nexport function presentRunnerRuns(records: readonly RunnerRecord[], now: number): RunnerRunView[] {\n const running = records\n .filter((record) => record.state === 'running')\n .sort((left, right) => right.startedAt.localeCompare(left.startedAt));\n const finished = records\n .filter((record) => record.state === 'completed' && now - finishedAt(record) <= FINISHED_RUNNER_RETENTION_MS)\n .sort((left, right) => finishedAt(right) - finishedAt(left));\n return [...running, ...finished].map(toRunnerRunView);\n}\n","import {\n DOOM_BACKGROUND_WORK_SERVICE,\n readDoomBackgroundWorkService,\n type BackgroundProviderWorkItem,\n type BackgroundWorkProviderHandle,\n} from '@agimon-ai/doompi-core/backgroundWork';\nimport type { DoomHeadlessActivity, DoomHeadlessExecutionContext } from '@agimon-ai/doompi-core/headless';\nimport type { DoomServerHostService } from '@agimon-ai/doompi-core/serverFacet';\nimport type { Context } from '@deepseek-ai/cordis';\n\nimport { BACKGROUND_WORK_PROVIDER } from '../../constants/runnerRuntime';\nimport { RUNNER_RUNS_TYPE } from '../../constants/webRunners';\nimport { reconcileActiveRunners, stopRunnerProcess } from '../reconcile';\nimport type { RunnerDependencies } from '../runnerDependencies/type';\nimport { formatRunnerFinished } from '../runnerRecord';\nimport { presentRunnerRuns } from '../webRunnerRuns';\n\nconst COMPLETION_RETRY_MS = 1_000;\n\nexport interface RunnerServerRuntime {\n readonly container: RunnerDependencies;\n readonly activity: DoomHeadlessActivity;\n readonly backgroundWorkPlugin: (context: Context) => void;\n ensureSession(sessionId: string): Promise<void>;\n /** Wakes the agent once when this runner, which it sent to the background, exits. */\n watchRunner(id: string): void;\n dispose(this: void): Promise<void>;\n}\nexport function createRunnerServerRuntime(\n container: RunnerDependencies,\n directEvents: NonNullable<DoomServerHostService['context']['directEvents']>,\n /** Absent when no agent runs in this session, so there is nobody to wake. */\n wakeAgent?: (content: string, requestId: string) => Promise<void>,\n): RunnerServerRuntime {\n let sessionId: string | undefined;\n let supervision: Promise<void> | undefined;\n let runtimeDisposal: Promise<void> | undefined;\n let backgroundWorkItems: BackgroundProviderWorkItem[] = [];\n let backgroundWorkProvider: BackgroundWorkProviderHandle | undefined;\n // Nothing else tells the model a background runner ended, so without this an\n // agent that finished its turn waiting on one idles until a person prompts it.\n const watched = new Set<string>();\n let unsubscribeWatched: (() => void) | undefined;\n let wakes: Promise<void> = Promise.resolve();\n let closing = false;\n let retryTimer: ReturnType<typeof setTimeout> | undefined;\n\n // The activity is optional and source-gated. The retained facet owns the\n // session runtime, so disabling the activity must not tear down its jobs.\n const ensureSession = (requestedSessionId: string): Promise<void> => {\n if (closing) return Promise.reject(new Error('Runner runtime is closing.'));\n if (sessionId !== undefined && sessionId !== requestedSessionId) {\n throw new Error(`Runner runtime belongs to session ${sessionId}.`);\n }\n sessionId ??= requestedSessionId;\n supervision ??= (async () => {\n container.paths.setSessionId(requestedSessionId);\n if ((await container.lifeline.arm(requestedSessionId)) === undefined) {\n throw new Error('Could not arm runner supervision lifeline.');\n }\n const reconciled = await reconcileActiveRunners({\n registry: container.runnerRegistry,\n launcher: container.launcher,\n rmuxBackend: container.rmuxBackend,\n processControl: container.processControl,\n currentHostPid: process.pid,\n startup: false,\n active: await container.runnerRegistry.listBySession(requestedSessionId),\n });\n for (const error of reconciled.errors) process.emitWarning(error);\n if (!closing) {\n unsubscribeRunnerUpdates ??= container.runnerRegistry.subscribe(\n () => requestPublish(requestedSessionId),\n requestedSessionId,\n );\n requestPublish(requestedSessionId);\n }\n })().catch((error: unknown) => {\n supervision = undefined;\n throw error;\n });\n return supervision;\n };\n\n const disposeRuntime = (): Promise<void> => {\n runtimeDisposal ??= (async () => {\n await supervision?.catch((error: unknown) =>\n process.emitWarning(`Could not initialize headless runner supervision: ${String(error)}`),\n );\n const ownedSessionId = sessionId;\n if (ownedSessionId === undefined) return;\n const records = await container.runnerRegistry.listBySession(ownedSessionId).catch(() => []);\n await Promise.all(\n records.map(async (record) => {\n try {\n const stopped = await stopRunnerProcess(record, container.launcher, container.rmuxBackend, {\n registry: container.runnerRegistry,\n intent: {\n reason: 'stopped',\n terminationReason: 'parent_session_cleanup',\n stopReason: 'session ended',\n },\n });\n if (!stopped && container.processControl.isAlive(record.pid)) {\n process.emitWarning(`Could not stop runner ${record.id} during session shutdown`);\n return;\n }\n await container.runnerRegistry.complete(\n record.id,\n { reason: 'stopped', code: null, signal: null, stopReason: 'session ended' },\n ownedSessionId,\n );\n } catch (error) {\n process.emitWarning(`Could not clean up runner ${record.id}: ${String(error)}`);\n }\n }),\n );\n try {\n await container.ptyHost.disposeAll();\n } finally {\n container.lifeline.dispose();\n container.runnerRegistry.close();\n }\n })();\n return runtimeDisposal;\n };\n\n const publishRuns = async (ownedSessionId: string): Promise<void> => {\n try {\n const records = await container.runnerRegistry.listAll(ownedSessionId);\n const nextBackgroundWork = records\n .filter((record) => record.state === 'running')\n .map((record) => ({\n id: record.id,\n sessionId: record.sessionId,\n label: record.name,\n status: record.state,\n }));\n if (JSON.stringify(nextBackgroundWork) !== JSON.stringify(backgroundWorkItems)) {\n backgroundWorkItems = nextBackgroundWork;\n backgroundWorkProvider?.update();\n }\n directEvents.publish(RUNNER_RUNS_TYPE, ownedSessionId, {\n runs: presentRunnerRuns(records, Date.now()),\n });\n } catch (error) {\n process.emitWarning(`Could not publish runner state: ${String(error)}`);\n }\n };\n let publishInFlight: Promise<void> | undefined;\n let publishAgain = false;\n let unsubscribeRunnerUpdates: (() => void) | undefined;\n const requestPublish = (ownedSessionId: string): void => {\n if (closing) return;\n publishAgain = true;\n if (publishInFlight !== undefined) return;\n publishInFlight = (async () => {\n do {\n publishAgain = false;\n await publishRuns(ownedSessionId);\n } while (publishAgain && !closing);\n })()\n .catch((error: unknown) => {\n process.emitWarning(`Could not schedule runner state publication: ${String(error)}`);\n })\n .finally(() => {\n publishInFlight = undefined;\n if (publishAgain) requestPublish(ownedSessionId);\n });\n };\n\n // Sequenced so two runners exiting in the same tick each wake the agent once.\n const wakeForFinished = (ownedSessionId: string): void => {\n if (closing) return;\n wakes = wakes\n .then(async () => {\n if (closing) return;\n let retry = false;\n // Deleting the entry being visited is safe for a Set iterator.\n for (const id of watched) {\n if (closing) return;\n try {\n const record = await container.runnerRegistry.get(id, ownedSessionId);\n if (closing) return;\n if (record?.state === 'running') continue;\n // A record swept before it was seen leaves nothing to report.\n if (record)\n await wakeAgent?.(\n formatRunnerFinished(record),\n `runner-finished:${JSON.stringify([ownedSessionId, id])}`,\n );\n watched.delete(id);\n } catch (error) {\n retry = true;\n process.emitWarning(`Could not report finished runner ${id}: ${String(error)}`);\n }\n }\n if (retry && !closing && retryTimer === undefined) {\n retryTimer = setTimeout(() => {\n retryTimer = undefined;\n wakeForFinished(ownedSessionId);\n }, COMPLETION_RETRY_MS);\n retryTimer.unref?.();\n }\n if (watched.size === 0) {\n clearTimeout(retryTimer);\n retryTimer = undefined;\n unsubscribeWatched?.();\n unsubscribeWatched = undefined;\n }\n })\n .catch((error: unknown) => {\n process.emitWarning(`Could not report a finished runner: ${String(error)}`);\n });\n };\n\n const watchRunner = (id: string): void => {\n const ownedSessionId = sessionId;\n if (!wakeAgent || closing || ownedSessionId === undefined) return;\n watched.add(id);\n // Independent of the optional activity: the wake-up must not depend on a\n // browser watching the runner list.\n unsubscribeWatched ??= container.runnerRegistry.subscribe(() => wakeForFinished(ownedSessionId), ownedSessionId);\n // It may already have exited between promotion and subscription.\n wakeForFinished(ownedSessionId);\n };\n\n const activity: DoomHeadlessActivity = {\n name: 'runner',\n start: async (executionContext: DoomHeadlessExecutionContext) => {\n await ensureSession(executionContext.sessionId);\n requestPublish(executionContext.sessionId);\n return () => {\n // Let the next optional activation reconcile again, while keeping\n // the retained runtime and all child job supervisors alive.\n supervision = undefined;\n };\n },\n };\n return {\n container,\n activity,\n ensureSession,\n watchRunner,\n backgroundWorkPlugin: (context) => {\n context.inject([DOOM_BACKGROUND_WORK_SERVICE], (serviceContext) => {\n const service = readDoomBackgroundWorkService(serviceContext);\n if (!service) return undefined;\n const registration = service.register({\n provider: BACKGROUND_WORK_PROVIDER,\n listActiveWork: () => backgroundWorkItems,\n });\n backgroundWorkProvider = registration;\n return () => {\n registration.dispose();\n if (backgroundWorkProvider === registration) backgroundWorkProvider = undefined;\n };\n });\n },\n async dispose() {\n // Session cleanup completes every runner it stops, and none of those may\n // start a turn in a session that is going away.\n closing = true;\n clearTimeout(retryTimer);\n retryTimer = undefined;\n watched.clear();\n unsubscribeWatched?.();\n unsubscribeWatched = undefined;\n unsubscribeRunnerUpdates?.();\n unsubscribeRunnerUpdates = undefined;\n await wakes;\n await publishInFlight;\n await disposeRuntime();\n },\n };\n}\n","import type { DoomServerPluginContext } from '@agimon-ai/doompi-core/serverFacet';\nimport type { Context } from '@deepseek-ai/cordis';\n\nimport { createHeadlessBashTool, createHeadlessRunnersCommand } from '../../../../../services/headless';\nimport { summarizeLog } from '../../../../../services/logReader';\nimport { createRunnerDependencies } from '../../../../../services/runnerDependencies';\nimport { createRunnerServerRuntime } from '../../../../../services/runnerServerRuntime';\n\nexport const RUNNER_SERVER_SCOPE_SERVICE = 'doom/runner-server-scope';\n\ndeclare module '@deepseek-ai/cordis' {\n interface Context {\n 'doom/runner-server-scope': RunnerServerScope;\n }\n}\n\nexport const createRunnerServerRoot = ({ host, agent, signal }: DoomServerPluginContext) => {\n if (!host.context.directEvents) throw new Error('Runner headless facet requires the session direct event bus.');\n if (!host.context.cwd || !host.context.environment) {\n throw new Error('Runner headless facet requires session cwd and environment.');\n }\n const dependencies = createRunnerDependencies({\n cwd: host.context.cwd,\n environment: host.context.environment,\n ...(host.context.executionBudget === undefined ? {} : { executionBudget: host.context.executionBudget }),\n });\n const execution = agent?.context;\n const wakeAgent =\n execution &&\n (async (content: string, requestId: string): Promise<void> => {\n try {\n // 'steer' joins a running turn and starts one on an idle session.\n // Reuse completion identity across retries and acknowledge admission, not settlement.\n if (!execution.session.admitPrompt) throw new Error('The session cannot admit a runner notification.');\n await execution.session.admitPrompt(content, 'steer', undefined, requestId);\n } catch (error) {\n // Keep completion ownership with the watcher until admission succeeds.\n await Promise.resolve()\n .then(() => execution.client.notify({ body: String(error), level: 'warning' }))\n .catch(() => undefined);\n throw error;\n }\n });\n const runtime = createRunnerServerRuntime(dependencies, host.context.directEvents, wakeAgent);\n const value = {\n tool: createHeadlessBashTool(\n {\n run: async (request) => {\n await runtime.ensureSession(request.sessionId);\n const result = await dependencies.bashRunService.run({\n ...request,\n ...(signal ? { signal: request.signal ? AbortSignal.any([signal, request.signal]) : signal } : {}),\n });\n // Without this, nothing tells the agent the runner ended.\n if (result.kind === 'promoted') runtime.watchRunner(result.id);\n return result;\n },\n },\n summarizeLog,\n ),\n command: createHeadlessRunnersCommand(dependencies),\n };\n return {\n value,\n services: [\n runtime.backgroundWorkPlugin,\n (context: Context) => {\n context.plugin((providerContext) => providerContext.provide(RUNNER_SERVER_SCOPE_SERVICE, value));\n },\n ],\n activities: [runtime.activity],\n onStop: runtime.dispose,\n };\n};\n\nexport type RunnerServerScope = Awaited<ReturnType<typeof createRunnerServerRoot>>['value'];\n","import { defineRoot } from '@agimon-ai/doompi-core/extensionFile';\n\nimport { createRunnerServerRoot } from './_lib/serverRoot';\n\nexport default defineRoot(createRunnerServerRoot);\n","import type { DoomHubChannel, DoomHubChannelSource } from '@agimon-ai/doompi-core/hubChannel';\n\nimport { RUNNER_RUNS_TYPE } from '../../constants/webRunners';\nimport { type RunnerRunView } from '../../types/webRunners';\n\nfunction runnerPayload(value: unknown): value is { runs: RunnerRunView[] } {\n return typeof value === 'object' && value !== null && Array.isArray((value as { runs?: unknown }).runs);\n}\n\n/**\n * The runners data channel consumes lifecycle-owned snapshots from the session\n * server. Durable registry files are read by the owning session only, never by\n * this hub channel.\n */\nexport function createRunnersChannel(): DoomHubChannel {\n return {\n frameType: RUNNER_RUNS_TYPE,\n start(host) {\n const latest = new Map<string, RunnerRunView[]>();\n const subscriptions = new Map<string, () => void>();\n const source: DoomHubChannelSource = {\n payloadFor(scope) {\n const runs = latest.get(scope.sessionId);\n return runs === undefined ? undefined : { runs };\n },\n sessionAdded(scope) {\n subscriptions.get(scope.sessionId)?.();\n latest.delete(scope.sessionId);\n const unsubscribe = host.directEvents.subscribe(\n RUNNER_RUNS_TYPE,\n scope.sessionId,\n (payload) => {\n if (!runnerPayload(payload)) return;\n latest.set(scope.sessionId, payload.runs);\n host.publish(scope.sessionId, payload);\n },\n { replayLatest: true },\n );\n subscriptions.set(scope.sessionId, unsubscribe);\n },\n sessionRemoved(sessionId) {\n subscriptions.get(sessionId)?.();\n subscriptions.delete(sessionId);\n latest.delete(sessionId);\n },\n close() {\n for (const unsubscribe of subscriptions.values()) unsubscribe();\n subscriptions.clear();\n latest.clear();\n },\n };\n return source;\n },\n };\n}\n","import { defineChannel } from '@agimon-ai/doompi-core/extensionFile';\nimport type { DoomHubChannel } from '@agimon-ai/doompi-core/hubChannel';\n\nimport { createRunnersChannel } from '../../../services/runnersChannel';\nexport default defineChannel(function runnerRunsChannel(): DoomHubChannel {\n return createRunnersChannel();\n});\n","export const LOG_DIR_NAME = 'logs';\nexport const STATE_EXTENSION = '.json';\nexport const RUNNING_STATE = 'running';\nexport const STATE_POLL_MS = 1000;\nexport const HEARTBEAT_MS = 15_000;\nexport const DEFAULT_LINES = 200;\nexport const MAX_LINES = 5000;\nexport const SAFE_SEGMENT = /^[\\w.@-]+$/;\nexport const SCREEN_POLL_MS = 100;\nexport const RAW_LOG_SUFFIX = '.raw';\nexport const MAX_INPUT_CHARS = 4096;\nexport const NOT_ATTACHABLE = 'No attachable runner by that name in this session.';\n","export const RUNNER_API_BASE_PATH = 'runner';\nexport const RUN_ID_PARAM = 'runId';\nexport const RUNNER_SCREEN_EVENT = 'screen';\nexport const RUNNER_LOG_STREAM_EVENT = 'append';\nexport const RUNNER_LOG_PING_EVENT = 'ping';\n","import fs from 'node:fs';\n\nimport { POLL_MS } from '../../constants/logTail';\nimport type { ILogTail, LogTailHandle, LogTailOptions } from '../../types/logTail';\n\n/** How often the tail restats the file; the same cadence the CLI's --follow uses. */\n\n/**\n * Polling tail: read the delta between the last offset and the file's current\n * size, and hand back only complete lines.\n *\n * Same reliability posture as this package's runner watcher: the poll is the\n * mechanism, not an accelerator for a change notification. fs.watchFile only\n * fires when it observes a difference from a baseline it took itself, so a\n * write that lands before its first stat is a change it never reports; a\n * restat every tick has no such window.\n *\n * A write that lands mid-line is common, so the trailing fragment is held\n * until its newline arrives rather than emitted as a line that will change.\n */\nexport class LogTail implements ILogTail {\n follow(logPath: string, options: LogTailOptions): LogTailHandle {\n let offset = options.from;\n let pending = '';\n let closed = false;\n\n const drain = (size: number): void => {\n // A shrunken file was rotated or truncated; start over rather than\n // reading from an offset that now points into different content.\n if (size < offset) {\n offset = 0;\n pending = '';\n }\n if (size === offset) return;\n const length = size - offset;\n const buffer = Buffer.alloc(length);\n const descriptor = fs.openSync(logPath, 'r');\n let bytesRead: number;\n try {\n bytesRead = fs.readSync(descriptor, buffer, 0, length, offset);\n } finally {\n fs.closeSync(descriptor);\n }\n offset = size;\n if (bytesRead <= 0) return;\n pending += buffer.subarray(0, bytesRead).toString('utf8');\n const lines = pending.split('\\n');\n pending = lines.pop() ?? '';\n if (lines.length > 0) options.onLines(lines, offset - Buffer.byteLength(pending, 'utf8'));\n };\n\n const tick = (): void => {\n if (closed) return;\n try {\n drain(fs.statSync(logPath).size);\n } catch (error) {\n // The runner has not opened its log yet; the next tick settles it.\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;\n options.onError(error instanceof Error ? error : new Error(String(error)));\n }\n };\n\n const timer = setInterval(tick, POLL_MS);\n // Following a log must never be the reason a process stays alive.\n timer.unref?.();\n return {\n close() {\n if (closed) return;\n closed = true;\n clearInterval(timer);\n },\n };\n }\n}\n","import type { LogSlice } from './logReader';\n\n/**\n * The runner log API, shared by this package's session-scoped routes and its\n * cockpit plugin. The two halves run in different processes, so the wire\n * vocabulary is declared here: the browser half may reach `src/types` and\n * nothing else on the server side.\n *\n * Where the routes live is not here. That is `src/types/apiRoutes`, which both\n * halves read, and the generated client turns into the URLs the page addresses.\n * The file a run writes to is still read from its metadata record by the server\n * alone.\n */\n\n/**\n * One chunk of an attached pane's output.\n *\n * Base64 because these are terminal bytes, escape sequences and all, and a\n * server-sent event is a line-oriented text frame that would mangle them.\n */\nexport interface RunnerScreenEvent {\n chunk: string;\n /** Byte offset just past this chunk, so a reconnect resumes rather than replays. */\n offset?: number;\n /** Set once the runner exits, so the page can stop watching. */\n ended?: boolean;\n}\n\n/** The body of a keystroke POST. Text, not key codes: the pane wants bytes. */\nexport interface RunnerInputRequest {\n text: string;\n}\n\n/** The named SSE event carrying a RunnerScreenEvent payload. */\n\n/**\n * A log request as query parameters. `grep` is a literal substring, not a\n * regular expression, because that is what the reader matches; `lines` bounds\n * the answer to the last N lines of whatever survived the filter.\n */\nexport interface RunnerLogQueryParams {\n lines?: number;\n grep?: string;\n ignoreCase?: boolean;\n contextLines?: number;\n}\n\n/** The slice the log route answers with, plus which runner it came from. */\nexport interface RunnerLogResponse extends LogSlice {\n runId: string;\n /** True while the runner is still writing, so the page knows following is worth offering. */\n running: boolean;\n}\n\n/**\n * One server-sent event on the follow stream: the lines appended since the last\n * one. The stream never filters: a page that sets a query stops following and\n * reads a filtered slice instead, because grep with context is a whole-file\n * answer and cannot be assembled from a chunk at a time.\n */\nexport interface RunnerLogStreamEvent {\n /** Appended lines, in order; empty when the event only reports the runner ending. */\n lines: string[];\n /**\n * Byte offset just past the last line in this event.\n *\n * A follower that loses the stream resumes here. Without it the page could\n * only fall back to the offset its last slice reported, which every line\n * delivered since has already moved past, so a reconnect would replay them.\n */\n offset?: number;\n /** Set once the runner exits, so the page can stop following without polling the run list. */\n ended?: boolean;\n}\n\n/** The named SSE event carrying a RunnerLogStreamEvent payload. */\n\n/**\n * The named SSE event that carries nothing.\n *\n * A runner can be alive and silent for minutes, which on the wire is\n * indistinguishable from a connection that died. This is sent on a timer so\n * the socket keeps proving itself. Readers that only listen for `append`\n * ignore it, which is what makes it safe to add.\n */\n\n/** Query parameter names, shared so the page and the route cannot drift apart. */\nexport const RUNNER_LOG_PARAMS = {\n lines: 'lines',\n grep: 'grep',\n ignoreCase: 'ignoreCase',\n contextLines: 'contextLines',\n from: 'from',\n} as const;\n","import { apiResponse, defineApiRoutes } from '@agimon-ai/doompi-core/web';\n\nimport { RUN_ID_PARAM } from '../constants/webRunnerLog';\nimport { RUNNER_LOG_PARAMS, type RunnerLogResponse } from './webRunnerLog';\n\n/**\n * This package's routes, as data.\n *\n * No scope and no base path: the build reads both off\n * `src/extensions/workspaces/sessions/(backend)/api/runner/`, so the mount is\n * stated once, by the folder that creates it. This package stated it four times\n * before, once per URL builder, each of them re-spelling `/plugins/` and the\n * session prefix by hand.\n *\n * The Hono app registers these paths verbatim and the browser addresses them,\n * so a path cannot move on one side alone. `:runId` is Hono's own parameter\n * syntax for that reason; a caller supplies the value as `params`, and the\n * client encodes the segment.\n */\nexport default defineApiRoutes({\n log: {\n method: 'GET',\n path: `/runners/:${RUN_ID_PARAM}/log`,\n query: [\n RUNNER_LOG_PARAMS.lines,\n RUNNER_LOG_PARAMS.grep,\n RUNNER_LOG_PARAMS.ignoreCase,\n RUNNER_LOG_PARAMS.contextLines,\n ],\n response: apiResponse<RunnerLogResponse>(),\n },\n /**\n * The live tail of that same log.\n *\n * `stream: true`, so the client exposes a URL and no call method: the\n * consumer is the browser's own `EventSource`, and a relayed call would\n * buffer a stream that never ends.\n */\n logStream: {\n method: 'GET',\n path: `/runners/:${RUN_ID_PARAM}/log/stream`,\n query: [RUNNER_LOG_PARAMS.from],\n stream: true,\n },\n /** The unscrubbed pane bytes, for a terminal emulator rather than a reader. */\n screenStream: {\n method: 'GET',\n path: `/runners/:${RUN_ID_PARAM}/screen/stream`,\n query: [RUNNER_LOG_PARAMS.from],\n stream: true,\n },\n input: {\n method: 'POST',\n path: `/runners/:${RUN_ID_PARAM}/screen/input`,\n },\n});\n","import fs from 'node:fs';\nimport path from 'node:path';\n\nimport type { DoomApi, DoomApiContext, DoomApiHandler } from '@agimon-ai/doompi-core/packageApi';\nimport { Hono } from 'hono';\nimport { streamSSE } from 'hono/streaming';\n\nimport {\n DEFAULT_LINES,\n HEARTBEAT_MS,\n LOG_DIR_NAME,\n MAX_INPUT_CHARS,\n MAX_LINES,\n NOT_ATTACHABLE,\n RAW_LOG_SUFFIX,\n RUNNING_STATE,\n SAFE_SEGMENT,\n SCREEN_POLL_MS,\n STATE_EXTENSION,\n STATE_POLL_MS,\n} from '../../constants/runnerLogApi';\nimport {\n RUN_ID_PARAM,\n RUNNER_API_BASE_PATH,\n RUNNER_LOG_PING_EVENT,\n RUNNER_LOG_STREAM_EVENT,\n RUNNER_SCREEN_EVENT,\n} from '../../constants/webRunnerLog';\nimport { LogReader } from '../../services/logReader';\nimport { LogTail } from '../../services/logTail';\nimport { PtyBackendChain } from '../../services/ptyBackendChain';\nimport { RmuxBackend } from '../../services/rmuxBackend';\nimport { RunnerPaths, resolveRunnerStoreDirectory, runnerStateDirFor } from '../../services/runnerPaths';\nimport { TmuxBackend } from '../../services/tmuxBackend';\nimport { parseRunnerRecord } from '../../services/webRunnerRuns';\nimport routes from '../../types/apiRoutes';\nimport type { ILogReader, LogQuery } from '../../types/logReader';\nimport type { ILogTail } from '../../types/logTail';\nimport type { IRmuxBackend } from '../../types/rmuxBackend';\nimport type { RunnerRecord } from '../../types/runnerRegistry';\nimport {\n RUNNER_LOG_PARAMS,\n type RunnerLogResponse,\n type RunnerLogStreamEvent,\n type RunnerScreenEvent,\n} from '../../types/webRunnerLog';\n\n/** How often a follow re-reads the record to notice the runner exiting. */\n/**\n * How often a silent but living stream proves it is still there. Short enough\n * that an idle proxy does not reap the socket, long enough to cost nothing.\n */\n/** Kept in step with LogQuery's own default, so an unasked-for tail is the same size everywhere. */\n/** A page that asks for more than this gets this; the whole point of the route is a bounded read. */\n/** Path segments the client supplies are names, never paths. */\n/** Backends that expose a pane another process can read and write. */\nconst MULTIPLEXER_BACKENDS: ReadonlySet<string> = new Set(['rmux', 'tmux']);\n/** How often an attached pane's new bytes are picked up. */\n/** The unscrubbed copy the runner's sink writes beside the log, for interactive runs. */\n/** One keystroke batch; a paste is not a file transfer. */\n/** Said the same way for every reason, so the route never reports which check failed. */\n\nexport interface RunnerLogApiOptions {\n /** The session these routes answer for; the host owns exactly one. */\n sessionId: string;\n /** The store root; defaults to this environment's agent directory. */\n storeDir?: string;\n environment?: Readonly<Record<string, string | undefined>>;\n cwd?: string;\n logReader?: ILogReader;\n logTail?: ILogTail;\n /**\n * Reaches a live pane to read its screen and send it input.\n *\n * A session API is handed nothing but its session id, so it cannot touch the\n * in-process PTY handles the extension holds. This talks to the multiplexer\n * over its own socket instead, the same way the CLI's `input` verb does,\n * which is what makes an attached pane reachable across processes at all.\n */\n pane?: IRmuxBackend;\n}\n\n/** Rejects anything that could climb out of the session's own directory before a path is built. */\nfunction isSafeSegment(value: string): boolean {\n return value !== '' && value !== '.' && value !== '..' && SAFE_SEGMENT.test(value);\n}\n\n/**\n * The log file a run wrote, or undefined when there is no such run or its\n * record points somewhere it has no business pointing.\n *\n * The record names its own log path, and that record is written by another\n * process, so the path is checked rather than trusted: it must sit inside this\n * session's own logs directory. Without that check a doctored record would\n * turn this route into an arbitrary file read.\n */\nfunction resolveLogFile(stateDir: string, logsDir: string, sessionId: string, runId: string): RunnerRecord | undefined {\n const statePath = path.join(stateDir, `${runId}${STATE_EXTENSION}`);\n let raw: string;\n try {\n raw = fs.readFileSync(statePath, 'utf8');\n } catch {\n return undefined; // No such run in this session; the route answers 404.\n }\n const record = parseRunnerRecord(raw);\n if (record === undefined || record.id !== runId || record.sessionId !== sessionId) return undefined;\n const resolved = path.resolve(record.logPath);\n if (resolved !== logsDir && !resolved.startsWith(`${logsDir}${path.sep}`)) return undefined;\n return record;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nfunction numberParam(value: string | undefined, fallback: number, max: number): number {\n const parsed = value === undefined ? Number.NaN : Number.parseInt(value, 10);\n if (!Number.isFinite(parsed) || parsed < 0) return fallback;\n return Math.min(parsed, max);\n}\n\nfunction logQueryOf(url: URL): LogQuery {\n const grep = url.searchParams.get(RUNNER_LOG_PARAMS.grep) ?? undefined;\n return {\n lines: numberParam(url.searchParams.get(RUNNER_LOG_PARAMS.lines) ?? undefined, DEFAULT_LINES, MAX_LINES),\n ...(grep === undefined || grep === '' ? {} : { grep }),\n ignoreCase: url.searchParams.get(RUNNER_LOG_PARAMS.ignoreCase) === 'true',\n contextLines: numberParam(url.searchParams.get(RUNNER_LOG_PARAMS.contextLines) ?? undefined, 0, MAX_LINES),\n };\n}\n\n/**\n * This package's HTTP surface: one runner's log, as a bounded slice and as a\n * live tail.\n *\n * The routes are mounted inside one session's own server, so they name a runner\n * and nothing else; the session is the host, not a parameter. The reader only\n * ever returns the last N lines of what matched, so both routes stay bounded no\n * matter how large the file on disk is.\n *\n * Paths come from src/types/apiRoutes, the one table the browser's generated\n * client reads too, so this app and the cockpit cannot disagree about where a\n * route lives. They stay relative to the mount the host strips.\n */\nexport function createRunnerLogApi(options: RunnerLogApiOptions): Hono {\n const logReader = options.logReader ?? new LogReader();\n const logTail = options.logTail ?? new LogTail();\n const sessionId = options.sessionId;\n const paths = new RunnerPaths(options.cwd, options.environment);\n const storeDir = options.storeDir ?? resolveRunnerStoreDirectory(options.environment ?? process.env, options.cwd);\n const stateDir =\n options.storeDir === undefined ? paths.stateDirectory(sessionId) : runnerStateDirFor(storeDir, sessionId);\n const logsDir =\n options.storeDir === undefined ? paths.logDirectory(sessionId) : path.join(storeDir, sessionId, LOG_DIR_NAME);\n const app = new Hono();\n\n // Built on first use, not on mount: most sessions never attach to a pane,\n // and constructing this probes the filesystem for a multiplexer binary.\n let paneBackend: IRmuxBackend | undefined = options.pane;\n const pane = (): IRmuxBackend => {\n paneBackend ??= (() => {\n const paths = new RunnerPaths(options.cwd, options.environment);\n return new PtyBackendChain(new RmuxBackend(paths), new TmuxBackend(paths));\n })();\n return paneBackend;\n };\n\n /** The record behind a request, or undefined when this session has no such run. */\n const runnerOf = (runId: string): RunnerRecord | undefined =>\n isSafeSegment(runId) ? resolveLogFile(stateDir, logsDir, sessionId, runId) : undefined;\n\n /**\n * The pane a request may attach to.\n *\n * Same posture as the log routes: the record is written by another process,\n * so nothing on it is trusted until it is checked. The target must be one\n * this package owns, the run must still be up, and it must have been started\n * interactive. A non-interactive run has no terminal to type into, and a\n * subprocess-backed run has no pane at all.\n */\n const attachableOf = (runId: string): { record: RunnerRecord; target: string } | undefined => {\n const record = runnerOf(runId);\n if (record === undefined || record.state !== RUNNING_STATE || record.interactive !== true) return undefined;\n const target = record.backendTarget;\n if (target === undefined || !MULTIPLEXER_BACKENDS.has(record.backend) || !isSafeSegment(target)) return undefined;\n return { record, target };\n };\n app.get(routes.log.path, (context) => {\n const runId = context.req.param(RUN_ID_PARAM);\n const record = runnerOf(runId);\n if (record === undefined) return context.json({ error: `No runner '${runId}' in this session.` }, 404);\n const slice = logReader.read(record.logPath, logQueryOf(new URL(context.req.url)));\n const body: RunnerLogResponse = { ...slice, runId, running: record.state === RUNNING_STATE };\n return context.json(body);\n });\n\n app.get(routes.logStream.path, (context) => {\n const runId = context.req.param(RUN_ID_PARAM);\n const record = runnerOf(runId);\n if (record === undefined) return context.json({ error: `No runner '${runId}' in this session.` }, 404);\n const from = numberParam(\n new URL(context.req.url).searchParams.get(RUNNER_LOG_PARAMS.from) ?? undefined,\n 0,\n Number.MAX_SAFE_INTEGER,\n );\n\n return streamSSE(context, async (stream) => {\n const send = (event: RunnerLogStreamEvent): Promise<void> =>\n stream.writeSSE({ event: RUNNER_LOG_STREAM_EVENT, data: JSON.stringify(event) });\n\n // The tail and the state poll both push onto one queue, so the stream\n // writes in the order things happened rather than interleaving two\n // writers onto the same socket.\n let pump: Promise<void> = Promise.resolve();\n const queue = (event: RunnerLogStreamEvent): void => {\n pump = pump.then(() => send(event)).catch(() => undefined);\n };\n\n let finish = (): void => undefined;\n const done = new Promise<void>((resolve) => {\n finish = resolve;\n });\n const handle = logTail.follow(record.logPath, {\n from,\n onLines: (lines, completeThrough) => queue({ lines, offset: completeThrough }),\n onError: () => finish(),\n });\n // A finished runner writes nothing more, so the page is told once and\n // the stream ends rather than holding a socket open forever.\n const poll = setInterval(() => {\n const current = runnerOf(runId);\n if (current !== undefined && current.state === RUNNING_STATE) return;\n queue({ lines: [], ended: true });\n finish();\n }, STATE_POLL_MS);\n // A runner can be alive and produce nothing for minutes. Without this the\n // page cannot tell that from a stream that quietly died.\n const heartbeat = setInterval(() => {\n pump = pump.then(() => stream.writeSSE({ event: RUNNER_LOG_PING_EVENT, data: '' })).catch(() => undefined);\n }, HEARTBEAT_MS);\n\n stream.onAbort(() => finish());\n await done;\n clearInterval(poll);\n clearInterval(heartbeat);\n handle.close();\n await pump;\n });\n });\n\n app.get(routes.screenStream.path, (context) => {\n const runId = context.req.param(RUN_ID_PARAM);\n const attachable = attachableOf(runId);\n if (attachable === undefined) return context.json({ error: NOT_ATTACHABLE }, 404);\n // The record's log path is already proven to sit inside this session's own\n // logs directory, and the raw copy is that same path with a suffix, so it\n // cannot point anywhere new.\n const rawPath = `${attachable.record.logPath}${RAW_LOG_SUFFIX}`;\n const from = numberParam(\n new URL(context.req.url).searchParams.get(RUNNER_LOG_PARAMS.from) ?? undefined,\n 0,\n Number.MAX_SAFE_INTEGER,\n );\n\n return streamSSE(context, async (stream) => {\n let pump: Promise<void> = Promise.resolve();\n const queue = (event: RunnerScreenEvent): void => {\n pump = pump\n .then(() => stream.writeSSE({ event: RUNNER_SCREEN_EVENT, data: JSON.stringify(event) }))\n .catch(() => undefined);\n };\n\n let finish = (): void => undefined;\n const done = new Promise<void>((resolve) => {\n finish = resolve;\n });\n\n let offset = from;\n const drain = (): void => {\n let size: number;\n try {\n size = fs.statSync(rawPath).size;\n } catch {\n return; // The sink has not opened the file yet; the next tick settles it.\n }\n // The sink starts the file over when it passes its ceiling, so a\n // smaller file is a fresh one rather than a bad offset.\n if (size < offset) offset = 0;\n if (size === offset) return;\n const length = size - offset;\n const buffer = Buffer.alloc(length);\n const descriptor = fs.openSync(rawPath, 'r');\n let read: number;\n try {\n read = fs.readSync(descriptor, buffer, 0, length, offset);\n } finally {\n fs.closeSync(descriptor);\n }\n if (read <= 0) return;\n offset += read;\n // Bytes, not lines: a terminal's escape sequences straddle newlines and\n // mean nothing once split on them.\n queue({ chunk: buffer.subarray(0, read).toString('base64'), offset });\n };\n\n const poll = setInterval(() => {\n const current = runnerOf(runId);\n drain();\n if (current === undefined || current.state !== RUNNING_STATE) {\n queue({ chunk: '', offset, ended: true });\n finish();\n }\n }, SCREEN_POLL_MS);\n\n stream.onAbort(() => finish());\n await done;\n clearInterval(poll);\n await pump;\n });\n });\n\n /**\n * Types into a live pane.\n *\n * This is the one route in the package that writes rather than reads, so it\n * is deliberately narrow: an owned target, still running, started\n * interactive, and a body that is nothing but text.\n */\n app.post(routes.input.path, async (context) => {\n const runId = context.req.param(RUN_ID_PARAM);\n const attachable = attachableOf(runId);\n if (attachable === undefined) return context.json({ error: NOT_ATTACHABLE }, 404);\n\n let body: unknown;\n try {\n body = await context.req.json();\n } catch {\n return context.json({ error: 'Expected a JSON body carrying text.' }, 400);\n }\n const text = isRecord(body) ? body.text : undefined;\n if (typeof text !== 'string' || text === '') return context.json({ error: 'Expected a non-empty text.' }, 400);\n if (text.length > MAX_INPUT_CHARS) return context.json({ error: 'That is more input than a pane takes.' }, 413);\n\n const delivered = await pane().input(attachable.target, text);\n if (!delivered) return context.json({ error: 'The runner did not take the input.' }, 502);\n return context.json({ delivered: true });\n });\n\n return app;\n}\n\n/** The named export a host imports from this package's built session entry. */\nexport const api: DoomApi = {\n basePath: RUNNER_API_BASE_PATH,\n start(context: DoomApiContext): DoomApiHandler {\n // The host is one session's server, so its id is the whole scope; a hub\n // would have handed no session at all, and there is nothing to answer.\n const app = createRunnerLogApi({\n sessionId: context.sessionId ?? '',\n cwd: context.cwd,\n environment: context.environment,\n });\n return {\n fetch: (request) => app.fetch(request),\n // Nothing outlives a request: a follow's watch and its poll are both\n // torn down when its own stream ends or the socket aborts.\n close: () => undefined,\n };\n },\n};\n","import { defineRoute } from '@agimon-ai/doompi-core/extensionFile';\n\nimport { api } from '../../../../../../services/runnerLogApi';\n\n/**\n * Mounts this package's HTTP API at `runner`, which the folder names.\n *\n * The folder is the declaration: the build reads the base path off it and hands\n * the browser a client bound to the same segment, so the mount is stated once\n * rather than in a constant and in four URL builders that were each free to\n * drift.\n */\nexport default defineRoute(api);\n","import { defineServerCommand, type WithRoot } from '@agimon-ai/doompi-core/extensionFile';\nimport type { DoomServerPluginContext } from '@agimon-ai/doompi-core/serverFacet';\n\nimport type { RunnerServerScope } from '../_lib/serverRoot';\nexport default defineServerCommand(\n (context: WithRoot<DoomServerPluginContext, RunnerServerScope>) => context.root.command,\n);\n","import { defineServerTool, type WithRoot } from '@agimon-ai/doompi-core/extensionFile';\nimport type { DoomServerPluginContext } from '@agimon-ai/doompi-core/serverFacet';\n\nimport type { RunnerServerScope } from '../_lib/serverRoot';\nexport default defineServerTool((context: WithRoot<DoomServerPluginContext, RunnerServerScope>) => context.root.tool);\n","// Generated by @agimon-ai/doompi-build. Do not edit by hand.\nimport { defineServerPlugin, type DoomServerSessionPlugin } from '@agimon-ai/doompi-core/serverFacet';\nimport { composeRootHooks } from '@agimon-ai/doompi-core/extensionFile';\n\nimport rootSession from '../src/extensions/workspaces/sessions/(backend)/root.server';\nimport channelRunnerRuns from '../src/extensions/(backend)/channel/runnerRuns.server';\nimport apiRoute from '../src/extensions/workspaces/sessions/(backend)/api/runner/route.server';\nimport commandRunners from '../src/extensions/workspaces/sessions/(backend)/command/runners.server';\nimport toolBash from '../src/extensions/workspaces/sessions/(backend)/tool/bash.server';\n\ntype Factory<T, C> = (context: C) => T;\nconst at = <T, C>(value: T | Factory<T, C>, context: C): T =>\n typeof value === 'function' ? (value as Factory<T, C>)(context) : value;\nconst via = <T>(identity: Record<string, unknown>, value: unknown): T =>\n (value === undefined ? undefined : { ...identity, ...(value as object) }) as T;\n\nexport const facet = defineServerPlugin({\n name: '@agimon-ai/doompi-runner',\n global: () => ({\n get channels(): DoomServerSessionPlugin['channels'] { return [() => via({ frameType: 'runner_runs' }, channelRunnerRuns())]; },\n }),\n workspace: () => ({\n get channels(): DoomServerSessionPlugin['channels'] { return [() => via({ frameType: 'runner_runs' }, channelRunnerRuns())]; },\n }),\n session: async (context) => {\n const scopeSession = await rootSession(context);\n const contextSession = { ...context, root: scopeSession.value };\n return {\n ...composeRootHooks(scopeSession),\n get api(): DoomServerSessionPlugin['api'] { return [at(apiRoute, contextSession)]; },\n get channels(): DoomServerSessionPlugin['channels'] { return [() => via({ frameType: 'runner_runs' }, channelRunnerRuns())]; },\n get commands(): DoomServerSessionPlugin['commands'] { return [via({ name: 'runners' }, at(commandRunners, contextSession))]; },\n get tools(): DoomServerSessionPlugin['tools'] { return [via({ name: 'bash' }, at(toolBash, contextSession))]; },\n services: [...(scopeSession.services ?? [])],\n get activities(): DoomServerSessionPlugin['activities'] { return [...(scopeSession.activities ?? [])]; },\n };\n },\n});\n\nexport default facet;\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"server.mjs","names":["routes","channelRunnerRuns","rootSession","apiRoute","commandRunners","toolBash"],"sources":["../../src/constants/webRunners.ts","../../src/constants/webRunnerRuns.ts","../../src/services/webRunnerRuns/index.ts","../../src/services/runnerServerRuntime/index.ts","../../src/extensions/workspaces/sessions/(backend)/_lib/serverRoot.ts","../../src/extensions/workspaces/sessions/(backend)/root.server.ts","../../src/services/runnersChannel/index.ts","../../src/extensions/(backend)/channel/runnerRuns.server.ts","../../src/constants/runnerLogApi.ts","../../src/constants/webRunnerLog.ts","../../src/services/logTail/index.ts","../../src/types/webRunnerLog.ts","../../src/types/apiRoutes.ts","../../src/services/runnerLogApi/index.ts","../../src/extensions/workspaces/sessions/(backend)/api/runner/route.server.ts","../../src/extensions/workspaces/sessions/(backend)/command/runners.server.ts","../../src/extensions/workspaces/sessions/(backend)/tool/bash.server.ts","../../generated/server.ts"],"sourcesContent":["export const RUNNER_RUNS_TYPE = 'runner_runs';\n","export const FINISHED_RUNNER_RETENTION_MS = 600_000;\n","import { FINISHED_RUNNER_RETENTION_MS } from '../../constants/webRunnerRuns';\nimport type { RunnerRecord } from '../../types/runnerRegistry';\nimport type { RunnerRunView } from '../../types/webRunners';\nimport { isRunnerRecord } from '../runnerRecord';\n\n/** How long a finished runner stays in the cockpit's feed after it exited. */\n\n/** One on-disk metadata record, or undefined when the text is not a runner record. */\nexport function parseRunnerRecord(raw: string): RunnerRecord | undefined {\n try {\n const parsed: unknown = JSON.parse(raw);\n return isRunnerRecord(parsed) ? parsed : undefined;\n } catch {\n return undefined; // Mid-write or foreign file; the next scan settles it.\n }\n}\n\nexport function toRunnerRunView(record: RunnerRecord): RunnerRunView {\n return {\n id: record.id,\n name: record.name,\n pid: record.pid,\n command: record.command,\n cwd: record.cwd,\n interactive: record.interactive,\n backend: record.backend,\n state: record.state,\n promoted: record.promoted,\n startedAt: record.startedAt,\n logPath: record.logPath,\n ...(record.exit\n ? {\n exit: {\n reason: record.exit.reason,\n code: record.exit.code,\n signal: record.exit.signal,\n ...(record.exit.terminationReason === undefined\n ? {}\n : { terminationReason: record.exit.terminationReason }),\n ...(record.exit.stopReason === undefined ? {} : { stopReason: record.exit.stopReason }),\n finishedAt: record.exit.finishedAt,\n },\n }\n : {}),\n };\n}\n\nfunction finishedAt(record: RunnerRecord): number {\n const parsed = record.exit === undefined ? Number.NaN : Date.parse(record.exit.finishedAt);\n return Number.isFinite(parsed) ? parsed : Date.parse(record.startedAt);\n}\n\n/**\n * The feed the cockpit shows: running runners first, newest start first, then\n * the recently finished ones, newest exit first. Finished runners older than\n * the retention window are left out; the registry keeps them for the CLI.\n */\nexport function presentRunnerRuns(records: readonly RunnerRecord[], now: number): RunnerRunView[] {\n const running = records\n .filter((record) => record.state === 'running')\n .sort((left, right) => right.startedAt.localeCompare(left.startedAt));\n const finished = records\n .filter((record) => record.state === 'completed' && now - finishedAt(record) <= FINISHED_RUNNER_RETENTION_MS)\n .sort((left, right) => finishedAt(right) - finishedAt(left));\n return [...running, ...finished].map(toRunnerRunView);\n}\n","import {\n DOOM_BACKGROUND_WORK_SERVICE,\n readDoomBackgroundWorkService,\n type BackgroundProviderWorkItem,\n type BackgroundWorkProviderHandle,\n} from '@agimon-ai/doompi-core/backgroundWork';\nimport type { DoomHeadlessActivity, DoomHeadlessExecutionContext } from '@agimon-ai/doompi-core/headless';\nimport type { DoomServerHostService } from '@agimon-ai/doompi-core/serverFacet';\nimport type { Context } from '@deepseek-ai/cordis';\n\nimport { BACKGROUND_WORK_PROVIDER } from '../../constants/runnerRuntime';\nimport { RUNNER_RUNS_TYPE } from '../../constants/webRunners';\nimport { reconcileActiveRunners, stopRunnerProcess } from '../reconcile';\nimport type { RunnerDependencies } from '../runnerDependencies/type';\nimport { formatRunnerFinished } from '../runnerRecord';\nimport { presentRunnerRuns } from '../webRunnerRuns';\n\nconst COMPLETION_RETRY_MS = 1_000;\n\nexport interface RunnerServerRuntime {\n readonly container: RunnerDependencies;\n readonly activity: DoomHeadlessActivity;\n readonly backgroundWorkPlugin: (context: Context) => void;\n ensureSession(sessionId: string): Promise<void>;\n /** Wakes the agent once when this runner, which it sent to the background, exits. */\n watchRunner(id: string): void;\n dispose(this: void): Promise<void>;\n}\nexport function createRunnerServerRuntime(\n container: RunnerDependencies,\n directEvents: NonNullable<DoomServerHostService['context']['directEvents']>,\n /** Absent when no agent runs in this session, so there is nobody to wake. */\n wakeAgent?: (content: string, requestId: string) => Promise<void>,\n): RunnerServerRuntime {\n let sessionId: string | undefined;\n let supervision: Promise<void> | undefined;\n let runtimeDisposal: Promise<void> | undefined;\n let backgroundWorkItems: BackgroundProviderWorkItem[] = [];\n let backgroundWorkProvider: BackgroundWorkProviderHandle | undefined;\n // Nothing else tells the model a background runner ended, so without this an\n // agent that finished its turn waiting on one idles until a person prompts it.\n const watched = new Set<string>();\n let unsubscribeWatched: (() => void) | undefined;\n let wakes: Promise<void> = Promise.resolve();\n let closing = false;\n let retryTimer: ReturnType<typeof setTimeout> | undefined;\n\n // The activity is optional and source-gated. The retained facet owns the\n // session runtime, so disabling the activity must not tear down its jobs.\n const ensureSession = (requestedSessionId: string): Promise<void> => {\n if (closing) return Promise.reject(new Error('Runner runtime is closing.'));\n if (sessionId !== undefined && sessionId !== requestedSessionId) {\n throw new Error(`Runner runtime belongs to session ${sessionId}.`);\n }\n sessionId ??= requestedSessionId;\n supervision ??= (async () => {\n container.paths.setSessionId(requestedSessionId);\n if ((await container.lifeline.arm(requestedSessionId)) === undefined) {\n throw new Error('Could not arm runner supervision lifeline.');\n }\n const reconciled = await reconcileActiveRunners({\n registry: container.runnerRegistry,\n launcher: container.launcher,\n rmuxBackend: container.rmuxBackend,\n processControl: container.processControl,\n currentHostPid: process.pid,\n startup: false,\n active: await container.runnerRegistry.listBySession(requestedSessionId),\n });\n for (const error of reconciled.errors) process.emitWarning(error);\n if (!closing) {\n unsubscribeRunnerUpdates ??= container.runnerRegistry.subscribe(\n () => requestPublish(requestedSessionId),\n requestedSessionId,\n );\n requestPublish(requestedSessionId);\n }\n })().catch((error: unknown) => {\n supervision = undefined;\n throw error;\n });\n return supervision;\n };\n\n const disposeRuntime = (): Promise<void> => {\n runtimeDisposal ??= (async () => {\n await supervision?.catch((error: unknown) =>\n process.emitWarning(`Could not initialize headless runner supervision: ${String(error)}`),\n );\n const ownedSessionId = sessionId;\n if (ownedSessionId === undefined) return;\n const records = await container.runnerRegistry.listBySession(ownedSessionId).catch(() => []);\n await Promise.all(\n records.map(async (record) => {\n try {\n const stopped = await stopRunnerProcess(record, container.launcher, container.rmuxBackend, {\n registry: container.runnerRegistry,\n intent: {\n reason: 'stopped',\n terminationReason: 'parent_session_cleanup',\n stopReason: 'session ended',\n },\n });\n if (!stopped && container.processControl.isAlive(record.pid)) {\n process.emitWarning(`Could not stop runner ${record.id} during session shutdown`);\n return;\n }\n await container.runnerRegistry.complete(\n record.id,\n { reason: 'stopped', code: null, signal: null, stopReason: 'session ended' },\n ownedSessionId,\n );\n } catch (error) {\n process.emitWarning(`Could not clean up runner ${record.id}: ${String(error)}`);\n }\n }),\n );\n try {\n await container.ptyHost.disposeAll();\n } finally {\n container.lifeline.dispose();\n container.runnerRegistry.close();\n }\n })();\n return runtimeDisposal;\n };\n\n const publishRuns = async (ownedSessionId: string): Promise<void> => {\n try {\n const records = await container.runnerRegistry.listAll(ownedSessionId);\n const nextBackgroundWork = records\n .filter((record) => record.state === 'running')\n .map((record) => ({\n id: record.id,\n sessionId: record.sessionId,\n label: record.name,\n status: record.state,\n }));\n if (JSON.stringify(nextBackgroundWork) !== JSON.stringify(backgroundWorkItems)) {\n backgroundWorkItems = nextBackgroundWork;\n backgroundWorkProvider?.update();\n }\n directEvents.publish(RUNNER_RUNS_TYPE, ownedSessionId, {\n runs: presentRunnerRuns(records, Date.now()),\n });\n } catch (error) {\n process.emitWarning(`Could not publish runner state: ${String(error)}`);\n }\n };\n let publishInFlight: Promise<void> | undefined;\n let publishAgain = false;\n let unsubscribeRunnerUpdates: (() => void) | undefined;\n const requestPublish = (ownedSessionId: string): void => {\n if (closing) return;\n publishAgain = true;\n if (publishInFlight !== undefined) return;\n publishInFlight = (async () => {\n do {\n publishAgain = false;\n await publishRuns(ownedSessionId);\n } while (publishAgain && !closing);\n })()\n .catch((error: unknown) => {\n process.emitWarning(`Could not schedule runner state publication: ${String(error)}`);\n })\n .finally(() => {\n publishInFlight = undefined;\n if (publishAgain) requestPublish(ownedSessionId);\n });\n };\n\n // Sequenced so two runners exiting in the same tick each wake the agent once.\n const wakeForFinished = (ownedSessionId: string): void => {\n if (closing) return;\n wakes = wakes\n .then(async () => {\n if (closing) return;\n let retry = false;\n // Deleting the entry being visited is safe for a Set iterator.\n for (const id of watched) {\n if (closing) return;\n try {\n const record = await container.runnerRegistry.get(id, ownedSessionId);\n if (closing) return;\n if (record?.state === 'running') continue;\n // A record swept before it was seen leaves nothing to report.\n if (record)\n await wakeAgent?.(\n formatRunnerFinished(record),\n `runner-finished:${JSON.stringify([ownedSessionId, id])}`,\n );\n watched.delete(id);\n } catch (error) {\n retry = true;\n process.emitWarning(`Could not report finished runner ${id}: ${String(error)}`);\n }\n }\n if (retry && !closing && retryTimer === undefined) {\n retryTimer = setTimeout(() => {\n retryTimer = undefined;\n wakeForFinished(ownedSessionId);\n }, COMPLETION_RETRY_MS);\n retryTimer.unref?.();\n }\n if (watched.size === 0) {\n clearTimeout(retryTimer);\n retryTimer = undefined;\n unsubscribeWatched?.();\n unsubscribeWatched = undefined;\n }\n })\n .catch((error: unknown) => {\n process.emitWarning(`Could not report a finished runner: ${String(error)}`);\n });\n };\n\n const watchRunner = (id: string): void => {\n const ownedSessionId = sessionId;\n if (!wakeAgent || closing || ownedSessionId === undefined) return;\n watched.add(id);\n // Independent of the optional activity: the wake-up must not depend on a\n // browser watching the runner list.\n unsubscribeWatched ??= container.runnerRegistry.subscribe(() => wakeForFinished(ownedSessionId), ownedSessionId);\n // It may already have exited between promotion and subscription.\n wakeForFinished(ownedSessionId);\n };\n\n const activity: DoomHeadlessActivity = {\n name: 'runner',\n start: async (executionContext: DoomHeadlessExecutionContext) => {\n await ensureSession(executionContext.sessionId);\n requestPublish(executionContext.sessionId);\n return () => {\n // Let the next optional activation reconcile again, while keeping\n // the retained runtime and all child job supervisors alive.\n supervision = undefined;\n };\n },\n };\n return {\n container,\n activity,\n ensureSession,\n watchRunner,\n backgroundWorkPlugin: (context) => {\n context.inject([DOOM_BACKGROUND_WORK_SERVICE], (serviceContext) => {\n const service = readDoomBackgroundWorkService(serviceContext);\n if (!service) return undefined;\n const registration = service.register({\n provider: BACKGROUND_WORK_PROVIDER,\n listActiveWork: () => backgroundWorkItems,\n });\n backgroundWorkProvider = registration;\n return () => {\n registration.dispose();\n if (backgroundWorkProvider === registration) backgroundWorkProvider = undefined;\n };\n });\n },\n async dispose() {\n // Session cleanup completes every runner it stops, and none of those may\n // start a turn in a session that is going away.\n closing = true;\n clearTimeout(retryTimer);\n retryTimer = undefined;\n watched.clear();\n unsubscribeWatched?.();\n unsubscribeWatched = undefined;\n unsubscribeRunnerUpdates?.();\n unsubscribeRunnerUpdates = undefined;\n await wakes;\n await publishInFlight;\n await disposeRuntime();\n },\n };\n}\n","import type { DoomServerPluginContext } from '@agimon-ai/doompi-core/serverFacet';\nimport type { Context } from '@deepseek-ai/cordis';\n\nimport { createHeadlessBashTool, createHeadlessRunnersCommand } from '../../../../../services/headless';\nimport { summarizeLog } from '../../../../../services/logReader';\nimport { createRunnerDependencies } from '../../../../../services/runnerDependencies';\nimport { createRunnerServerRuntime } from '../../../../../services/runnerServerRuntime';\n\nexport const RUNNER_SERVER_SCOPE_SERVICE = 'doom/runner-server-scope';\n\ndeclare module '@deepseek-ai/cordis' {\n interface Context {\n 'doom/runner-server-scope': RunnerServerScope;\n }\n}\n\nexport const createRunnerServerRoot = ({ host, agent, signal }: DoomServerPluginContext) => {\n if (!host.context.directEvents) throw new Error('Runner headless facet requires the session direct event bus.');\n if (!host.context.cwd || !host.context.environment) {\n throw new Error('Runner headless facet requires session cwd and environment.');\n }\n const dependencies = createRunnerDependencies({\n cwd: host.context.cwd,\n environment: host.context.environment,\n ...(host.context.executionBudget === undefined ? {} : { executionBudget: host.context.executionBudget }),\n });\n const execution = agent?.context;\n const wakeAgent =\n execution &&\n (async (content: string, requestId: string): Promise<void> => {\n try {\n // 'steer' joins a running turn and starts one on an idle session.\n // Reuse completion identity across retries and acknowledge admission, not settlement.\n if (!execution.session.admitPrompt) throw new Error('The session cannot admit a runner notification.');\n await execution.session.admitPrompt(content, 'steer', undefined, requestId);\n } catch (error) {\n // Keep completion ownership with the watcher until admission succeeds.\n await Promise.resolve()\n .then(() => execution.client.notify({ body: String(error), level: 'warning' }))\n .catch(() => undefined);\n throw error;\n }\n });\n const runtime = createRunnerServerRuntime(dependencies, host.context.directEvents, wakeAgent);\n const bash = (wakeOnExit: boolean) =>\n createHeadlessBashTool(\n {\n run: async (request) => {\n await runtime.ensureSession(request.sessionId);\n const result = await dependencies.bashRunService.run({\n ...request,\n ...(signal ? { signal: request.signal ? AbortSignal.any([signal, request.signal]) : signal } : {}),\n });\n // Without this, nothing tells the agent the runner ended.\n if (wakeOnExit && result.kind === 'promoted') runtime.watchRunner(result.id);\n return result;\n },\n },\n summarizeLog,\n );\n const value = {\n tool: bash(true),\n // Remote MCP runners must not wake the local agent; the remote caller polls instead.\n remoteTool: bash(false),\n command: createHeadlessRunnersCommand(dependencies),\n };\n return {\n value,\n services: [\n runtime.backgroundWorkPlugin,\n (context: Context) => {\n context.plugin((providerContext) => providerContext.provide(RUNNER_SERVER_SCOPE_SERVICE, value));\n },\n ],\n activities: [runtime.activity],\n onStop: runtime.dispose,\n };\n};\n\nexport type RunnerServerScope = Awaited<ReturnType<typeof createRunnerServerRoot>>['value'];\n","import { defineRoot } from '@agimon-ai/doompi-core/extensionFile';\n\nimport { createRunnerServerRoot } from './_lib/serverRoot';\n\nexport default defineRoot(createRunnerServerRoot);\n","import type { DoomHubChannel, DoomHubChannelSource } from '@agimon-ai/doompi-core/hubChannel';\n\nimport { RUNNER_RUNS_TYPE } from '../../constants/webRunners';\nimport { type RunnerRunView } from '../../types/webRunners';\n\nfunction runnerPayload(value: unknown): value is { runs: RunnerRunView[] } {\n return typeof value === 'object' && value !== null && Array.isArray((value as { runs?: unknown }).runs);\n}\n\n/**\n * The runners data channel consumes lifecycle-owned snapshots from the session\n * server. Durable registry files are read by the owning session only, never by\n * this hub channel.\n */\nexport function createRunnersChannel(): DoomHubChannel {\n return {\n frameType: RUNNER_RUNS_TYPE,\n start(host) {\n const latest = new Map<string, RunnerRunView[]>();\n const subscriptions = new Map<string, () => void>();\n const source: DoomHubChannelSource = {\n payloadFor(scope) {\n const runs = latest.get(scope.sessionId);\n return runs === undefined ? undefined : { runs };\n },\n sessionAdded(scope) {\n subscriptions.get(scope.sessionId)?.();\n latest.delete(scope.sessionId);\n const unsubscribe = host.directEvents.subscribe(\n RUNNER_RUNS_TYPE,\n scope.sessionId,\n (payload) => {\n if (!runnerPayload(payload)) return;\n latest.set(scope.sessionId, payload.runs);\n host.publish(scope.sessionId, payload);\n },\n { replayLatest: true },\n );\n subscriptions.set(scope.sessionId, unsubscribe);\n },\n sessionRemoved(sessionId) {\n subscriptions.get(sessionId)?.();\n subscriptions.delete(sessionId);\n latest.delete(sessionId);\n },\n close() {\n for (const unsubscribe of subscriptions.values()) unsubscribe();\n subscriptions.clear();\n latest.clear();\n },\n };\n return source;\n },\n };\n}\n","import { defineChannel } from '@agimon-ai/doompi-core/extensionFile';\nimport type { DoomHubChannel } from '@agimon-ai/doompi-core/hubChannel';\n\nimport { createRunnersChannel } from '../../../services/runnersChannel';\nexport default defineChannel(function runnerRunsChannel(): DoomHubChannel {\n return createRunnersChannel();\n});\n","export const LOG_DIR_NAME = 'logs';\nexport const STATE_EXTENSION = '.json';\nexport const RUNNING_STATE = 'running';\nexport const STATE_POLL_MS = 1000;\nexport const HEARTBEAT_MS = 15_000;\nexport const DEFAULT_LINES = 200;\nexport const MAX_LINES = 5000;\nexport const SAFE_SEGMENT = /^[\\w.@-]+$/;\nexport const SCREEN_POLL_MS = 100;\nexport const RAW_LOG_SUFFIX = '.raw';\nexport const MAX_INPUT_CHARS = 4096;\nexport const NOT_ATTACHABLE = 'No attachable runner by that name in this session.';\n","export const RUNNER_API_BASE_PATH = 'runner';\nexport const RUN_ID_PARAM = 'runId';\nexport const RUNNER_SCREEN_EVENT = 'screen';\nexport const RUNNER_LOG_STREAM_EVENT = 'append';\nexport const RUNNER_LOG_PING_EVENT = 'ping';\n","import fs from 'node:fs';\n\nimport { POLL_MS } from '../../constants/logTail';\nimport type { ILogTail, LogTailHandle, LogTailOptions } from '../../types/logTail';\n\n/** How often the tail restats the file; the same cadence the CLI's --follow uses. */\n\n/**\n * Polling tail: read the delta between the last offset and the file's current\n * size, and hand back only complete lines.\n *\n * Same reliability posture as this package's runner watcher: the poll is the\n * mechanism, not an accelerator for a change notification. fs.watchFile only\n * fires when it observes a difference from a baseline it took itself, so a\n * write that lands before its first stat is a change it never reports; a\n * restat every tick has no such window.\n *\n * A write that lands mid-line is common, so the trailing fragment is held\n * until its newline arrives rather than emitted as a line that will change.\n */\nexport class LogTail implements ILogTail {\n follow(logPath: string, options: LogTailOptions): LogTailHandle {\n let offset = options.from;\n let pending = '';\n let closed = false;\n\n const drain = (size: number): void => {\n // A shrunken file was rotated or truncated; start over rather than\n // reading from an offset that now points into different content.\n if (size < offset) {\n offset = 0;\n pending = '';\n }\n if (size === offset) return;\n const length = size - offset;\n const buffer = Buffer.alloc(length);\n const descriptor = fs.openSync(logPath, 'r');\n let bytesRead: number;\n try {\n bytesRead = fs.readSync(descriptor, buffer, 0, length, offset);\n } finally {\n fs.closeSync(descriptor);\n }\n offset = size;\n if (bytesRead <= 0) return;\n pending += buffer.subarray(0, bytesRead).toString('utf8');\n const lines = pending.split('\\n');\n pending = lines.pop() ?? '';\n if (lines.length > 0) options.onLines(lines, offset - Buffer.byteLength(pending, 'utf8'));\n };\n\n const tick = (): void => {\n if (closed) return;\n try {\n drain(fs.statSync(logPath).size);\n } catch (error) {\n // The runner has not opened its log yet; the next tick settles it.\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;\n options.onError(error instanceof Error ? error : new Error(String(error)));\n }\n };\n\n const timer = setInterval(tick, POLL_MS);\n // Following a log must never be the reason a process stays alive.\n timer.unref?.();\n return {\n close() {\n if (closed) return;\n closed = true;\n clearInterval(timer);\n },\n };\n }\n}\n","import type { LogSlice } from './logReader';\n\n/**\n * The runner log API, shared by this package's session-scoped routes and its\n * cockpit plugin. The two halves run in different processes, so the wire\n * vocabulary is declared here: the browser half may reach `src/types` and\n * nothing else on the server side.\n *\n * Where the routes live is not here. That is `src/types/apiRoutes`, which both\n * halves read, and the generated client turns into the URLs the page addresses.\n * The file a run writes to is still read from its metadata record by the server\n * alone.\n */\n\n/**\n * One chunk of an attached pane's output.\n *\n * Base64 because these are terminal bytes, escape sequences and all, and a\n * server-sent event is a line-oriented text frame that would mangle them.\n */\nexport interface RunnerScreenEvent {\n chunk: string;\n /** Byte offset just past this chunk, so a reconnect resumes rather than replays. */\n offset?: number;\n /** Set once the runner exits, so the page can stop watching. */\n ended?: boolean;\n}\n\n/** The body of a keystroke POST. Text, not key codes: the pane wants bytes. */\nexport interface RunnerInputRequest {\n text: string;\n}\n\n/** The named SSE event carrying a RunnerScreenEvent payload. */\n\n/**\n * A log request as query parameters. `grep` is a literal substring, not a\n * regular expression, because that is what the reader matches; `lines` bounds\n * the answer to the last N lines of whatever survived the filter.\n */\nexport interface RunnerLogQueryParams {\n lines?: number;\n grep?: string;\n ignoreCase?: boolean;\n contextLines?: number;\n}\n\n/** The slice the log route answers with, plus which runner it came from. */\nexport interface RunnerLogResponse extends LogSlice {\n runId: string;\n /** True while the runner is still writing, so the page knows following is worth offering. */\n running: boolean;\n}\n\n/**\n * One server-sent event on the follow stream: the lines appended since the last\n * one. The stream never filters: a page that sets a query stops following and\n * reads a filtered slice instead, because grep with context is a whole-file\n * answer and cannot be assembled from a chunk at a time.\n */\nexport interface RunnerLogStreamEvent {\n /** Appended lines, in order; empty when the event only reports the runner ending. */\n lines: string[];\n /**\n * Byte offset just past the last line in this event.\n *\n * A follower that loses the stream resumes here. Without it the page could\n * only fall back to the offset its last slice reported, which every line\n * delivered since has already moved past, so a reconnect would replay them.\n */\n offset?: number;\n /** Set once the runner exits, so the page can stop following without polling the run list. */\n ended?: boolean;\n}\n\n/** The named SSE event carrying a RunnerLogStreamEvent payload. */\n\n/**\n * The named SSE event that carries nothing.\n *\n * A runner can be alive and silent for minutes, which on the wire is\n * indistinguishable from a connection that died. This is sent on a timer so\n * the socket keeps proving itself. Readers that only listen for `append`\n * ignore it, which is what makes it safe to add.\n */\n\n/** Query parameter names, shared so the page and the route cannot drift apart. */\nexport const RUNNER_LOG_PARAMS = {\n lines: 'lines',\n grep: 'grep',\n ignoreCase: 'ignoreCase',\n contextLines: 'contextLines',\n from: 'from',\n} as const;\n","import { apiResponse, defineApiRoutes } from '@agimon-ai/doompi-core/web';\n\nimport { RUN_ID_PARAM } from '../constants/webRunnerLog';\nimport { RUNNER_LOG_PARAMS, type RunnerLogResponse } from './webRunnerLog';\n\n/**\n * This package's routes, as data.\n *\n * No scope and no base path: the build reads both off\n * `src/extensions/workspaces/sessions/(backend)/api/runner/`, so the mount is\n * stated once, by the folder that creates it. This package stated it four times\n * before, once per URL builder, each of them re-spelling `/plugins/` and the\n * session prefix by hand.\n *\n * The Hono app registers these paths verbatim and the browser addresses them,\n * so a path cannot move on one side alone. `:runId` is Hono's own parameter\n * syntax for that reason; a caller supplies the value as `params`, and the\n * client encodes the segment.\n */\nexport default defineApiRoutes({\n log: {\n method: 'GET',\n path: `/runners/:${RUN_ID_PARAM}/log`,\n query: [\n RUNNER_LOG_PARAMS.lines,\n RUNNER_LOG_PARAMS.grep,\n RUNNER_LOG_PARAMS.ignoreCase,\n RUNNER_LOG_PARAMS.contextLines,\n ],\n response: apiResponse<RunnerLogResponse>(),\n },\n /**\n * The live tail of that same log.\n *\n * `stream: true`, so the client exposes a URL and no call method: the\n * consumer is the browser's own `EventSource`, and a relayed call would\n * buffer a stream that never ends.\n */\n logStream: {\n method: 'GET',\n path: `/runners/:${RUN_ID_PARAM}/log/stream`,\n query: [RUNNER_LOG_PARAMS.from],\n stream: true,\n },\n /** The unscrubbed pane bytes, for a terminal emulator rather than a reader. */\n screenStream: {\n method: 'GET',\n path: `/runners/:${RUN_ID_PARAM}/screen/stream`,\n query: [RUNNER_LOG_PARAMS.from],\n stream: true,\n },\n input: {\n method: 'POST',\n path: `/runners/:${RUN_ID_PARAM}/screen/input`,\n },\n});\n","import fs from 'node:fs';\nimport path from 'node:path';\n\nimport type { DoomApi, DoomApiContext, DoomApiHandler } from '@agimon-ai/doompi-core/packageApi';\nimport { Hono } from 'hono';\nimport { streamSSE } from 'hono/streaming';\n\nimport {\n DEFAULT_LINES,\n HEARTBEAT_MS,\n LOG_DIR_NAME,\n MAX_INPUT_CHARS,\n MAX_LINES,\n NOT_ATTACHABLE,\n RAW_LOG_SUFFIX,\n RUNNING_STATE,\n SAFE_SEGMENT,\n SCREEN_POLL_MS,\n STATE_EXTENSION,\n STATE_POLL_MS,\n} from '../../constants/runnerLogApi';\nimport {\n RUN_ID_PARAM,\n RUNNER_API_BASE_PATH,\n RUNNER_LOG_PING_EVENT,\n RUNNER_LOG_STREAM_EVENT,\n RUNNER_SCREEN_EVENT,\n} from '../../constants/webRunnerLog';\nimport { LogReader } from '../../services/logReader';\nimport { LogTail } from '../../services/logTail';\nimport { PtyBackendChain } from '../../services/ptyBackendChain';\nimport { RmuxBackend } from '../../services/rmuxBackend';\nimport { RunnerPaths, resolveRunnerStoreDirectory, runnerStateDirFor } from '../../services/runnerPaths';\nimport { TmuxBackend } from '../../services/tmuxBackend';\nimport { parseRunnerRecord } from '../../services/webRunnerRuns';\nimport routes from '../../types/apiRoutes';\nimport type { ILogReader, LogQuery } from '../../types/logReader';\nimport type { ILogTail } from '../../types/logTail';\nimport type { IRmuxBackend } from '../../types/rmuxBackend';\nimport type { RunnerRecord } from '../../types/runnerRegistry';\nimport {\n RUNNER_LOG_PARAMS,\n type RunnerLogResponse,\n type RunnerLogStreamEvent,\n type RunnerScreenEvent,\n} from '../../types/webRunnerLog';\n\n/** How often a follow re-reads the record to notice the runner exiting. */\n/**\n * How often a silent but living stream proves it is still there. Short enough\n * that an idle proxy does not reap the socket, long enough to cost nothing.\n */\n/** Kept in step with LogQuery's own default, so an unasked-for tail is the same size everywhere. */\n/** A page that asks for more than this gets this; the whole point of the route is a bounded read. */\n/** Path segments the client supplies are names, never paths. */\n/** Backends that expose a pane another process can read and write. */\nconst MULTIPLEXER_BACKENDS: ReadonlySet<string> = new Set(['rmux', 'tmux']);\n/** How often an attached pane's new bytes are picked up. */\n/** The unscrubbed copy the runner's sink writes beside the log, for interactive runs. */\n/** One keystroke batch; a paste is not a file transfer. */\n/** Said the same way for every reason, so the route never reports which check failed. */\n\nexport interface RunnerLogApiOptions {\n /** The session these routes answer for; the host owns exactly one. */\n sessionId: string;\n /** The store root; defaults to this environment's agent directory. */\n storeDir?: string;\n environment?: Readonly<Record<string, string | undefined>>;\n cwd?: string;\n logReader?: ILogReader;\n logTail?: ILogTail;\n /**\n * Reaches a live pane to read its screen and send it input.\n *\n * A session API is handed nothing but its session id, so it cannot touch the\n * in-process PTY handles the extension holds. This talks to the multiplexer\n * over its own socket instead, the same way the CLI's `input` verb does,\n * which is what makes an attached pane reachable across processes at all.\n */\n pane?: IRmuxBackend;\n}\n\n/** Rejects anything that could climb out of the session's own directory before a path is built. */\nfunction isSafeSegment(value: string): boolean {\n return value !== '' && value !== '.' && value !== '..' && SAFE_SEGMENT.test(value);\n}\n\n/**\n * The log file a run wrote, or undefined when there is no such run or its\n * record points somewhere it has no business pointing.\n *\n * The record names its own log path, and that record is written by another\n * process, so the path is checked rather than trusted: it must sit inside this\n * session's own logs directory. Without that check a doctored record would\n * turn this route into an arbitrary file read.\n */\nfunction resolveLogFile(stateDir: string, logsDir: string, sessionId: string, runId: string): RunnerRecord | undefined {\n const statePath = path.join(stateDir, `${runId}${STATE_EXTENSION}`);\n let raw: string;\n try {\n raw = fs.readFileSync(statePath, 'utf8');\n } catch {\n return undefined; // No such run in this session; the route answers 404.\n }\n const record = parseRunnerRecord(raw);\n if (record === undefined || record.id !== runId || record.sessionId !== sessionId) return undefined;\n const resolved = path.resolve(record.logPath);\n if (resolved !== logsDir && !resolved.startsWith(`${logsDir}${path.sep}`)) return undefined;\n return record;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nfunction numberParam(value: string | undefined, fallback: number, max: number): number {\n const parsed = value === undefined ? Number.NaN : Number.parseInt(value, 10);\n if (!Number.isFinite(parsed) || parsed < 0) return fallback;\n return Math.min(parsed, max);\n}\n\nfunction logQueryOf(url: URL): LogQuery {\n const grep = url.searchParams.get(RUNNER_LOG_PARAMS.grep) ?? undefined;\n return {\n lines: numberParam(url.searchParams.get(RUNNER_LOG_PARAMS.lines) ?? undefined, DEFAULT_LINES, MAX_LINES),\n ...(grep === undefined || grep === '' ? {} : { grep }),\n ignoreCase: url.searchParams.get(RUNNER_LOG_PARAMS.ignoreCase) === 'true',\n contextLines: numberParam(url.searchParams.get(RUNNER_LOG_PARAMS.contextLines) ?? undefined, 0, MAX_LINES),\n };\n}\n\n/**\n * This package's HTTP surface: one runner's log, as a bounded slice and as a\n * live tail.\n *\n * The routes are mounted inside one session's own server, so they name a runner\n * and nothing else; the session is the host, not a parameter. The reader only\n * ever returns the last N lines of what matched, so both routes stay bounded no\n * matter how large the file on disk is.\n *\n * Paths come from src/types/apiRoutes, the one table the browser's generated\n * client reads too, so this app and the cockpit cannot disagree about where a\n * route lives. They stay relative to the mount the host strips.\n */\nexport function createRunnerLogApi(options: RunnerLogApiOptions): Hono {\n const logReader = options.logReader ?? new LogReader();\n const logTail = options.logTail ?? new LogTail();\n const sessionId = options.sessionId;\n const paths = new RunnerPaths(options.cwd, options.environment);\n const storeDir = options.storeDir ?? resolveRunnerStoreDirectory(options.environment ?? process.env, options.cwd);\n const stateDir =\n options.storeDir === undefined ? paths.stateDirectory(sessionId) : runnerStateDirFor(storeDir, sessionId);\n const logsDir =\n options.storeDir === undefined ? paths.logDirectory(sessionId) : path.join(storeDir, sessionId, LOG_DIR_NAME);\n const app = new Hono();\n\n // Built on first use, not on mount: most sessions never attach to a pane,\n // and constructing this probes the filesystem for a multiplexer binary.\n let paneBackend: IRmuxBackend | undefined = options.pane;\n const pane = (): IRmuxBackend => {\n paneBackend ??= (() => {\n const paths = new RunnerPaths(options.cwd, options.environment);\n return new PtyBackendChain(new RmuxBackend(paths), new TmuxBackend(paths));\n })();\n return paneBackend;\n };\n\n /** The record behind a request, or undefined when this session has no such run. */\n const runnerOf = (runId: string): RunnerRecord | undefined =>\n isSafeSegment(runId) ? resolveLogFile(stateDir, logsDir, sessionId, runId) : undefined;\n\n /**\n * The pane a request may attach to.\n *\n * Same posture as the log routes: the record is written by another process,\n * so nothing on it is trusted until it is checked. The target must be one\n * this package owns, the run must still be up, and it must have been started\n * interactive. A non-interactive run has no terminal to type into, and a\n * subprocess-backed run has no pane at all.\n */\n const attachableOf = (runId: string): { record: RunnerRecord; target: string } | undefined => {\n const record = runnerOf(runId);\n if (record === undefined || record.state !== RUNNING_STATE || record.interactive !== true) return undefined;\n const target = record.backendTarget;\n if (target === undefined || !MULTIPLEXER_BACKENDS.has(record.backend) || !isSafeSegment(target)) return undefined;\n return { record, target };\n };\n app.get(routes.log.path, (context) => {\n const runId = context.req.param(RUN_ID_PARAM);\n const record = runnerOf(runId);\n if (record === undefined) return context.json({ error: `No runner '${runId}' in this session.` }, 404);\n const slice = logReader.read(record.logPath, logQueryOf(new URL(context.req.url)));\n const body: RunnerLogResponse = { ...slice, runId, running: record.state === RUNNING_STATE };\n return context.json(body);\n });\n\n app.get(routes.logStream.path, (context) => {\n const runId = context.req.param(RUN_ID_PARAM);\n const record = runnerOf(runId);\n if (record === undefined) return context.json({ error: `No runner '${runId}' in this session.` }, 404);\n const from = numberParam(\n new URL(context.req.url).searchParams.get(RUNNER_LOG_PARAMS.from) ?? undefined,\n 0,\n Number.MAX_SAFE_INTEGER,\n );\n\n return streamSSE(context, async (stream) => {\n const send = (event: RunnerLogStreamEvent): Promise<void> =>\n stream.writeSSE({ event: RUNNER_LOG_STREAM_EVENT, data: JSON.stringify(event) });\n\n // The tail and the state poll both push onto one queue, so the stream\n // writes in the order things happened rather than interleaving two\n // writers onto the same socket.\n let pump: Promise<void> = Promise.resolve();\n const queue = (event: RunnerLogStreamEvent): void => {\n pump = pump.then(() => send(event)).catch(() => undefined);\n };\n\n let finish = (): void => undefined;\n const done = new Promise<void>((resolve) => {\n finish = resolve;\n });\n const handle = logTail.follow(record.logPath, {\n from,\n onLines: (lines, completeThrough) => queue({ lines, offset: completeThrough }),\n onError: () => finish(),\n });\n // A finished runner writes nothing more, so the page is told once and\n // the stream ends rather than holding a socket open forever.\n const poll = setInterval(() => {\n const current = runnerOf(runId);\n if (current !== undefined && current.state === RUNNING_STATE) return;\n queue({ lines: [], ended: true });\n finish();\n }, STATE_POLL_MS);\n // A runner can be alive and produce nothing for minutes. Without this the\n // page cannot tell that from a stream that quietly died.\n const heartbeat = setInterval(() => {\n pump = pump.then(() => stream.writeSSE({ event: RUNNER_LOG_PING_EVENT, data: '' })).catch(() => undefined);\n }, HEARTBEAT_MS);\n\n stream.onAbort(() => finish());\n await done;\n clearInterval(poll);\n clearInterval(heartbeat);\n handle.close();\n await pump;\n });\n });\n\n app.get(routes.screenStream.path, (context) => {\n const runId = context.req.param(RUN_ID_PARAM);\n const attachable = attachableOf(runId);\n if (attachable === undefined) return context.json({ error: NOT_ATTACHABLE }, 404);\n // The record's log path is already proven to sit inside this session's own\n // logs directory, and the raw copy is that same path with a suffix, so it\n // cannot point anywhere new.\n const rawPath = `${attachable.record.logPath}${RAW_LOG_SUFFIX}`;\n const from = numberParam(\n new URL(context.req.url).searchParams.get(RUNNER_LOG_PARAMS.from) ?? undefined,\n 0,\n Number.MAX_SAFE_INTEGER,\n );\n\n return streamSSE(context, async (stream) => {\n let pump: Promise<void> = Promise.resolve();\n const queue = (event: RunnerScreenEvent): void => {\n pump = pump\n .then(() => stream.writeSSE({ event: RUNNER_SCREEN_EVENT, data: JSON.stringify(event) }))\n .catch(() => undefined);\n };\n\n let finish = (): void => undefined;\n const done = new Promise<void>((resolve) => {\n finish = resolve;\n });\n\n let offset = from;\n const drain = (): void => {\n let size: number;\n try {\n size = fs.statSync(rawPath).size;\n } catch {\n return; // The sink has not opened the file yet; the next tick settles it.\n }\n // The sink starts the file over when it passes its ceiling, so a\n // smaller file is a fresh one rather than a bad offset.\n if (size < offset) offset = 0;\n if (size === offset) return;\n const length = size - offset;\n const buffer = Buffer.alloc(length);\n const descriptor = fs.openSync(rawPath, 'r');\n let read: number;\n try {\n read = fs.readSync(descriptor, buffer, 0, length, offset);\n } finally {\n fs.closeSync(descriptor);\n }\n if (read <= 0) return;\n offset += read;\n // Bytes, not lines: a terminal's escape sequences straddle newlines and\n // mean nothing once split on them.\n queue({ chunk: buffer.subarray(0, read).toString('base64'), offset });\n };\n\n const poll = setInterval(() => {\n const current = runnerOf(runId);\n drain();\n if (current === undefined || current.state !== RUNNING_STATE) {\n queue({ chunk: '', offset, ended: true });\n finish();\n }\n }, SCREEN_POLL_MS);\n\n stream.onAbort(() => finish());\n await done;\n clearInterval(poll);\n await pump;\n });\n });\n\n /**\n * Types into a live pane.\n *\n * This is the one route in the package that writes rather than reads, so it\n * is deliberately narrow: an owned target, still running, started\n * interactive, and a body that is nothing but text.\n */\n app.post(routes.input.path, async (context) => {\n const runId = context.req.param(RUN_ID_PARAM);\n const attachable = attachableOf(runId);\n if (attachable === undefined) return context.json({ error: NOT_ATTACHABLE }, 404);\n\n let body: unknown;\n try {\n body = await context.req.json();\n } catch {\n return context.json({ error: 'Expected a JSON body carrying text.' }, 400);\n }\n const text = isRecord(body) ? body.text : undefined;\n if (typeof text !== 'string' || text === '') return context.json({ error: 'Expected a non-empty text.' }, 400);\n if (text.length > MAX_INPUT_CHARS) return context.json({ error: 'That is more input than a pane takes.' }, 413);\n\n const delivered = await pane().input(attachable.target, text);\n if (!delivered) return context.json({ error: 'The runner did not take the input.' }, 502);\n return context.json({ delivered: true });\n });\n\n return app;\n}\n\n/** The named export a host imports from this package's built session entry. */\nexport const api: DoomApi = {\n basePath: RUNNER_API_BASE_PATH,\n start(context: DoomApiContext): DoomApiHandler {\n // The host is one session's server, so its id is the whole scope; a hub\n // would have handed no session at all, and there is nothing to answer.\n const app = createRunnerLogApi({\n sessionId: context.sessionId ?? '',\n cwd: context.cwd,\n environment: context.environment,\n });\n return {\n fetch: (request) => app.fetch(request),\n // Nothing outlives a request: a follow's watch and its poll are both\n // torn down when its own stream ends or the socket aborts.\n close: () => undefined,\n };\n },\n};\n","import { defineRoute } from '@agimon-ai/doompi-core/extensionFile';\n\nimport { api } from '../../../../../../services/runnerLogApi';\n\n/**\n * Mounts this package's HTTP API at `runner`, which the folder names.\n *\n * The folder is the declaration: the build reads the base path off it and hands\n * the browser a client bound to the same segment, so the mount is stated once\n * rather than in a constant and in four URL builders that were each free to\n * drift.\n */\nexport default defineRoute(api);\n","import { defineServerCommand, type WithRoot } from '@agimon-ai/doompi-core/extensionFile';\nimport type { DoomServerPluginContext } from '@agimon-ai/doompi-core/serverFacet';\n\nimport type { RunnerServerScope } from '../_lib/serverRoot';\nexport default defineServerCommand(\n (context: WithRoot<DoomServerPluginContext, RunnerServerScope>) => context.root.command,\n);\n","import { defineServerTool, type WithRoot } from '@agimon-ai/doompi-core/extensionFile';\nimport type { DoomServerPluginContext } from '@agimon-ai/doompi-core/serverFacet';\n\nimport type { RunnerServerScope } from '../_lib/serverRoot';\nexport default defineServerTool((context: WithRoot<DoomServerPluginContext, RunnerServerScope>) => context.root.tool);\n","// Generated by @agimon-ai/doompi-build. Do not edit by hand.\nimport { defineServerPlugin, type DoomServerSessionPlugin } from '@agimon-ai/doompi-core/serverFacet';\nimport { composeRootHooks } from '@agimon-ai/doompi-core/extensionFile';\n\nimport rootSession from '../src/extensions/workspaces/sessions/(backend)/root.server';\nimport channelRunnerRuns from '../src/extensions/(backend)/channel/runnerRuns.server';\nimport apiRoute from '../src/extensions/workspaces/sessions/(backend)/api/runner/route.server';\nimport commandRunners from '../src/extensions/workspaces/sessions/(backend)/command/runners.server';\nimport toolBash from '../src/extensions/workspaces/sessions/(backend)/tool/bash.server';\n\ntype Factory<T, C> = (context: C) => T;\nconst at = <T, C>(value: T | Factory<T, C>, context: C): T =>\n typeof value === 'function' ? (value as Factory<T, C>)(context) : value;\nconst via = <T>(identity: Record<string, unknown>, value: unknown): T =>\n (value === undefined ? undefined : { ...identity, ...(value as object) }) as T;\n\nexport const facet = defineServerPlugin({\n name: '@agimon-ai/doompi-runner',\n global: () => ({\n get channels(): DoomServerSessionPlugin['channels'] { return [() => via({ frameType: 'runner_runs' }, channelRunnerRuns())]; },\n }),\n workspace: () => ({\n get channels(): DoomServerSessionPlugin['channels'] { return [() => via({ frameType: 'runner_runs' }, channelRunnerRuns())]; },\n }),\n session: async (context) => {\n const scopeSession = await rootSession(context);\n const contextSession = { ...context, root: scopeSession.value };\n return {\n ...composeRootHooks(scopeSession),\n get api(): DoomServerSessionPlugin['api'] { return [at(apiRoute, contextSession)]; },\n get channels(): DoomServerSessionPlugin['channels'] { return [() => via({ frameType: 'runner_runs' }, channelRunnerRuns())]; },\n get commands(): DoomServerSessionPlugin['commands'] { return [via({ name: 'runners' }, at(commandRunners, contextSession))]; },\n get tools(): DoomServerSessionPlugin['tools'] { return [via({ name: 'bash' }, at(toolBash, contextSession))]; },\n services: [...(scopeSession.services ?? [])],\n get activities(): DoomServerSessionPlugin['activities'] { return [...(scopeSession.activities ?? [])]; },\n };\n },\n});\n\nexport default facet;\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agimon-ai/doompi-runner",
|
|
3
|
-
"version": "0.0.1-alpha.
|
|
3
|
+
"version": "0.0.1-alpha.123",
|
|
4
4
|
"description": "Supervised shell execution, background process control, and run logs for Pi coding agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -228,10 +228,10 @@
|
|
|
228
228
|
"registry": "https://registry.npmjs.org/"
|
|
229
229
|
},
|
|
230
230
|
"dependencies": {
|
|
231
|
-
"@agimon-ai/doompi-core": "0.0.1-alpha.
|
|
231
|
+
"@agimon-ai/doompi-core": "0.0.1-alpha.122",
|
|
232
232
|
"@agimon-ai/doompi-telemetry": "0.0.1-alpha.103",
|
|
233
|
-
"@agimon-ai/doompi-ui": "0.0.1-alpha.
|
|
234
|
-
"@agimon-ai/doompi-web-components": "0.0.1-alpha.
|
|
233
|
+
"@agimon-ai/doompi-ui": "0.0.1-alpha.122",
|
|
234
|
+
"@agimon-ai/doompi-web-components": "0.0.1-alpha.80",
|
|
235
235
|
"@agimon-ai/doompi-web-security": "0.0.1-alpha.66",
|
|
236
236
|
"@agimon-ai/foundation-process-registry": "0.30.8",
|
|
237
237
|
"@deepseek-ai/cordis": "4.0.4",
|
|
@@ -34,10 +34,12 @@ Input requires a running interactive process backed by RMUX. Preserve the return
|
|
|
34
34
|
|
|
35
35
|
## Close the lifecycle
|
|
36
36
|
|
|
37
|
-
Remote MCP
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
Remote MCP never receives runner completion notices, and a runner you start
|
|
38
|
+
does not resume your conversation when it ends. While a runner matters to your
|
|
39
|
+
task, check `doom-runner status <id>` between steps of your own work and read
|
|
40
|
+
`doom-runner logs <id> --lines 100`. Stop checking when State is completed, and
|
|
41
|
+
report a runner that is still running with its identifier. Do not promise
|
|
42
|
+
automatic follow-up or relaunch uncertain work.
|
|
41
43
|
|
|
42
44
|
- Inspect logs instead of relaunching a command whose status is uncertain.
|
|
43
45
|
- Stop watchers, servers, and failed interactive processes when they are no longer needed.
|