@agimon-ai/doompi-runner 0.0.1-alpha.95 → 0.0.1-alpha.96
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/bin/cli.cjs +1 -1
- package/dist/bin/cli.mjs +1 -1
- package/dist/extensions/mcp.cjs.map +1 -1
- package/dist/extensions/mcp.mjs.map +1 -1
- package/dist/extensions/pi.cjs +3 -10
- package/dist/extensions/pi.cjs.map +1 -1
- package/dist/extensions/pi.mjs +3 -10
- package/dist/extensions/pi.mjs.map +1 -1
- package/dist/extensions/server.cjs +53 -6
- package/dist/extensions/server.cjs.map +1 -1
- package/dist/extensions/server.mjs +53 -6
- package/dist/extensions/server.mjs.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{runnerDependencies-CTbinc_6.mjs → runnerDependencies-D9VaHshY.mjs} +2 -2
- package/dist/{runnerDependencies-CTbinc_6.mjs.map → runnerDependencies-D9VaHshY.mjs.map} +1 -1
- package/dist/{runnerDependencies-CKWU0Mxa.cjs → runnerDependencies-hCHqkMEP.cjs} +2 -2
- package/dist/{runnerDependencies-CKWU0Mxa.cjs.map → runnerDependencies-hCHqkMEP.cjs.map} +1 -1
- package/dist/runnerDependencies.cjs +1 -1
- package/dist/runnerDependencies.mjs +1 -1
- package/dist/{runnerRegistry-BvWqBcaM.cjs → runnerRegistry-CPbfdesw.cjs} +18 -1
- package/dist/runnerRegistry-CPbfdesw.cjs.map +1 -0
- package/dist/{runnerRegistry-BouzlG6h.mjs → runnerRegistry-ieeMhBTz.mjs} +13 -2
- package/dist/runnerRegistry-ieeMhBTz.mjs.map +1 -0
- package/dist/runnerRegistry.cjs +1 -1
- package/dist/runnerRegistry.mjs +1 -1
- package/package.json +16 -16
- package/dist/runnerRegistry-BouzlG6h.mjs.map +0 -1
- package/dist/runnerRegistry-BvWqBcaM.cjs.map +0 -1
package/dist/bin/cli.cjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
3
|
const require_cliApp = require("../cliApp-CP2WKUG_.cjs");
|
|
4
|
-
const require_runnerDependencies = require("../runnerDependencies-
|
|
4
|
+
const require_runnerDependencies = require("../runnerDependencies-hCHqkMEP.cjs");
|
|
5
5
|
let node_fs = require("node:fs");
|
|
6
6
|
let node_url = require("node:url");
|
|
7
7
|
let _agimon_ai_doompi_telemetry = require("@agimon-ai/doompi-telemetry");
|
package/dist/bin/cli.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { t as runCli } from "../cliApp-dVY9mnBz.mjs";
|
|
3
|
-
import { t as createRunnerDependencies } from "../runnerDependencies-
|
|
3
|
+
import { t as createRunnerDependencies } from "../runnerDependencies-D9VaHshY.mjs";
|
|
4
4
|
import { existsSync, realpathSync } from "node:fs";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { createDoomTelemetry } from "@agimon-ai/doompi-telemetry";
|
|
@@ -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 }: 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({ cwd: host.context.cwd, environment: host.context.environment });\n const runtime = createRunnerServerRuntime(dependencies, host.context.directEvents);\n const value = {\n tool: createHeadlessBashTool(\n {\n run: async (request) => {\n await runtime.ensureSession(request.sessionId);\n return dependencies.bashRunService.run(request);\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 }: 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({ cwd: host.context.cwd, environment: host.context.environment });\n const execution = agent?.context;\n const wakeAgent =\n execution &&\n (async (content: string): Promise<void> => {\n try {\n // 'steer' joins a running turn and starts one on an idle session.\n // `session.prompt(_, 'steer')` is enqueue-only and would park the\n // message on an idle lane, which is the bug this exists to fix.\n if (!execution.session.admitPrompt) throw new Error('The session cannot admit a runner notification.');\n await execution.session.admitPrompt(content, 'steer');\n } catch (error) {\n // The runner already finished; a failed wake-up costs the model its\n // prompt, not the result, so tell the operator instead.\n await execution.client.notify({ body: String(error), level: 'warning' });\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(request);\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 +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 }: 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({ cwd: host.context.cwd, environment: host.context.environment });\n const runtime = createRunnerServerRuntime(dependencies, host.context.directEvents);\n const value = {\n tool: createHeadlessBashTool(\n {\n run: async (request) => {\n await runtime.ensureSession(request.sessionId);\n return dependencies.bashRunService.run(request);\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 }: 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({ cwd: host.context.cwd, environment: host.context.environment });\n const execution = agent?.context;\n const wakeAgent =\n execution &&\n (async (content: string): Promise<void> => {\n try {\n // 'steer' joins a running turn and starts one on an idle session.\n // `session.prompt(_, 'steer')` is enqueue-only and would park the\n // message on an idle lane, which is the bug this exists to fix.\n if (!execution.session.admitPrompt) throw new Error('The session cannot admit a runner notification.');\n await execution.session.admitPrompt(content, 'steer');\n } catch (error) {\n // The runner already finished; a failed wake-up costs the model its\n // prompt, not the result, so tell the operator instead.\n await execution.client.notify({ body: String(error), level: 'warning' });\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(request);\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"}
|
package/dist/extensions/pi.cjs
CHANGED
|
@@ -11,7 +11,8 @@ const require_bashTool = require("../bashTool-DzucJQKE.cjs");
|
|
|
11
11
|
const require_compaction = require("../compaction-jYNEd05D.cjs");
|
|
12
12
|
const require_runnersCommand = require("../runnersCommand-BoLYarxd.cjs");
|
|
13
13
|
const require_reconcile = require("../reconcile-y5CcOmWz.cjs");
|
|
14
|
-
const require_runnerDependencies = require("../runnerDependencies-
|
|
14
|
+
const require_runnerDependencies = require("../runnerDependencies-hCHqkMEP.cjs");
|
|
15
|
+
const require_runnerRegistry = require("../runnerRegistry-CPbfdesw.cjs");
|
|
15
16
|
const require_format = require("../format-BaAcUdvA.cjs");
|
|
16
17
|
const require_runnerSpace = require("../runnerSpace-DwQOUo3E.cjs");
|
|
17
18
|
const require_runnerRuntime = require("../runnerRuntime-Cm9-GYrp.cjs");
|
|
@@ -314,17 +315,9 @@ function createRunnerRuntime(pi) {
|
|
|
314
315
|
for (const record of monitored) {
|
|
315
316
|
if (!record || record.sessionId !== activeSessionId || record.state !== "completed") continue;
|
|
316
317
|
if (notifiedRunnerIds.has(record.id)) continue;
|
|
317
|
-
const outcome = record.exit?.reason ?? "completed";
|
|
318
|
-
const code = record.exit?.code === null || record.exit?.code === void 0 ? "" : `, exit code ${record.exit.code}`;
|
|
319
|
-
const content = [
|
|
320
|
-
`Background runner ${record.name} exited: ${outcome}${code}.`,
|
|
321
|
-
`Runner ID: ${record.id}`,
|
|
322
|
-
`Log: ${record.logPath}`,
|
|
323
|
-
`Inspect: doom-runner logs ${record.id}`
|
|
324
|
-
].join("\n");
|
|
325
318
|
pi.sendMessage({
|
|
326
319
|
customType: require_runnerRuntime.RUNNER_FINISHED_MESSAGE,
|
|
327
|
-
content,
|
|
320
|
+
content: require_runnerRegistry.formatRunnerFinished(record),
|
|
328
321
|
display: true
|
|
329
322
|
}, {
|
|
330
323
|
triggerTurn: true,
|