@agimon-ai/po-runner 0.0.0-alpha.47 → 0.0.0-alpha.48
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/bashTool.d.mts +1 -1
- package/dist/bin/cli.cjs +1 -1
- package/dist/bin/cli.mjs +1 -1
- package/dist/bin/runnerHost.cjs +5 -33
- package/dist/bin/runnerHost.cjs.map +1 -1
- package/dist/bin/runnerHost.mjs +5 -33
- package/dist/bin/runnerHost.mjs.map +1 -1
- package/dist/extensions/mcp.cjs +1 -1
- package/dist/extensions/mcp.cjs.map +1 -1
- package/dist/extensions/mcp.mjs +1 -1
- package/dist/extensions/mcp.mjs.map +1 -1
- package/dist/extensions/server.cjs +15 -19
- package/dist/extensions/server.cjs.map +1 -1
- package/dist/extensions/server.mjs +15 -19
- package/dist/extensions/server.mjs.map +1 -1
- package/dist/{index-CuYtXV5V.d.mts → index-CoBOAigd.d.mts} +5 -2
- package/dist/index-CoBOAigd.d.mts.map +1 -0
- package/dist/{index-DyxtAbaK.d.mts → index-DoeHCVHQ.d.mts} +2 -2
- package/dist/{index-DyxtAbaK.d.mts.map → index-DoeHCVHQ.d.mts.map} +1 -1
- package/dist/{index-CKc2Fi5k.d.mts → index-EFeluIMR.d.mts} +8 -2
- package/dist/index-EFeluIMR.d.mts.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +1 -1
- package/dist/{runnerDependencies-DihSv3kW.cjs → runnerDependencies-k4qqvHxN.cjs} +3 -3
- package/dist/{runnerDependencies-DihSv3kW.cjs.map → runnerDependencies-k4qqvHxN.cjs.map} +1 -1
- package/dist/{runnerDependencies-CxcowAwe.mjs → runnerDependencies-nf5jRCfc.mjs} +3 -3
- package/dist/{runnerDependencies-CxcowAwe.mjs.map → runnerDependencies-nf5jRCfc.mjs.map} +1 -1
- package/dist/runnerDependencies.cjs +1 -1
- package/dist/runnerDependencies.d.mts +1 -1
- package/dist/runnerDependencies.mjs +1 -1
- package/dist/{runnerRegistry-qIYq2pZW.cjs → runnerRegistry-BucrRJsG.cjs} +21 -2
- package/dist/runnerRegistry-BucrRJsG.cjs.map +1 -0
- package/dist/{runnerRegistry-CUKzavaV.mjs → runnerRegistry-_B6LXMhS.mjs} +21 -2
- package/dist/runnerRegistry-_B6LXMhS.mjs.map +1 -0
- package/dist/runnerRegistry.cjs +1 -1
- package/dist/runnerRegistry.d.mts +1 -1
- package/dist/runnerRegistry.mjs +1 -1
- package/dist/{type-COYoMif1.d.mts → type-CRhqDus6.d.mts} +2 -2
- package/dist/{type-COYoMif1.d.mts.map → type-CRhqDus6.d.mts.map} +1 -1
- package/dist/types.d.mts +1 -1
- package/package.json +16 -16
- package/dist/index-CKc2Fi5k.d.mts.map +0 -1
- package/dist/index-CuYtXV5V.d.mts.map +0 -1
- package/dist/runnerRegistry-CUKzavaV.mjs.map +0 -1
- package/dist/runnerRegistry-qIYq2pZW.cjs.map +0 -1
package/dist/bashTool.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { n as BashRunResult } from "./bashRunService-CmdDhaTn.mjs";
|
|
2
2
|
import { a as ResultBudget, s as ToolResult } from "./bashResult-CfOPpOHN.mjs";
|
|
3
|
-
import { t as createHeadlessBashTool } from "./index-
|
|
3
|
+
import { t as createHeadlessBashTool } from "./index-DoeHCVHQ.mjs";
|
|
4
4
|
//#region src/services/runnerResult/index.d.ts
|
|
5
5
|
declare function formatRunnerResult(result: BashRunResult, budget?: ResultBudget): Promise<ToolResult>;
|
|
6
6
|
//#endregion
|
package/dist/bin/cli.cjs
CHANGED
|
@@ -3,7 +3,7 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
|
3
3
|
const require_runnerSequenceStore = require("../runnerSequenceStore-Ck6M-8r_.cjs");
|
|
4
4
|
const require_cliApp = require("../cliApp-GKcqD6CT.cjs");
|
|
5
5
|
const require_session = require("../session-D_nH4XhL.cjs");
|
|
6
|
-
const require_runnerDependencies = require("../runnerDependencies-
|
|
6
|
+
const require_runnerDependencies = require("../runnerDependencies-k4qqvHxN.cjs");
|
|
7
7
|
let node_fs_promises = require("node:fs/promises");
|
|
8
8
|
let node_url = require("node:url");
|
|
9
9
|
let _agimon_ai_po_core_telemetry = require("@agimon-ai/po-core/telemetry");
|
package/dist/bin/cli.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { t as createRunnerSequenceStore } from "../runnerSequenceStore-CaY7S_Sd.mjs";
|
|
3
3
|
import { n as runnerCliHelp, t as runCli } from "../cliApp-g7APOwcU.mjs";
|
|
4
4
|
import { t as inheritedSessionId } from "../session-3oSU3QjI.mjs";
|
|
5
|
-
import { t as createRunnerDependencies } from "../runnerDependencies-
|
|
5
|
+
import { t as createRunnerDependencies } from "../runnerDependencies-nf5jRCfc.mjs";
|
|
6
6
|
import { realpath } from "node:fs/promises";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { createPoTelemetry } from "@agimon-ai/po-core/telemetry";
|
package/dist/bin/runnerHost.cjs
CHANGED
|
@@ -12,33 +12,16 @@ node_os = require_rolldown_runtime.__toESM(node_os, 1);
|
|
|
12
12
|
//#region src/bin/runnerHost.ts
|
|
13
13
|
const POLL_MS = 10;
|
|
14
14
|
const FORWARDED_SIGNAL_GRACE_MS = 2e3;
|
|
15
|
-
const HOST_FAILED_EVENT = "po_runner.host_failed";
|
|
16
15
|
const INVALID_COMMAND_SPEC_ERROR = "invalid runner command spec";
|
|
17
16
|
async function main(argv = process.argv.slice(2)) {
|
|
18
17
|
if (argv[0] === "--observe-sessions") {
|
|
19
18
|
require_services_lifeline_client.observeSessions(cleanupSessions);
|
|
20
19
|
return 0;
|
|
21
20
|
}
|
|
22
|
-
const startedAt = Date.now();
|
|
23
|
-
const { createPoTelemetry } = await import("@agimon-ai/po-core/telemetry");
|
|
24
|
-
const telemetry = createPoTelemetry({
|
|
25
|
-
serviceName: "po-runner-host",
|
|
26
|
-
packageName: "@agimon-ai/po-runner",
|
|
27
|
-
env: process.env,
|
|
28
|
-
enableLogs: true,
|
|
29
|
-
enableTraces: true
|
|
30
|
-
});
|
|
31
21
|
const [specPath, gatePath, exitPath] = argv;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
spec = await readSpec(specPath);
|
|
36
|
-
await waitForGate(gatePath);
|
|
37
|
-
} catch (error) {
|
|
38
|
-
await telemetry.recordError(HOST_FAILED_EVENT, error, { duration_ms: Date.now() - startedAt });
|
|
39
|
-
await telemetry.shutdown();
|
|
40
|
-
throw error;
|
|
41
|
-
}
|
|
22
|
+
if (!specPath || !gatePath || !exitPath) throw new Error("runnerHost requires spec, gate, and exit paths");
|
|
23
|
+
const spec = await readSpec(specPath);
|
|
24
|
+
await waitForGate(gatePath);
|
|
42
25
|
let child;
|
|
43
26
|
let forwardedSignal = null;
|
|
44
27
|
let escalationTimer;
|
|
@@ -89,21 +72,10 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
89
72
|
stopReason: "owner session ended"
|
|
90
73
|
} : {}
|
|
91
74
|
});
|
|
92
|
-
|
|
93
|
-
await telemetry.recordEvent("po_runner.host_finished", {
|
|
94
|
-
outcome: result.signal || forwardedSignal ? "signaled" : exitCode === 0 ? "completed" : "failed",
|
|
95
|
-
"runner.exit_code": exitCode,
|
|
96
|
-
...result.signal || forwardedSignal ? { "runner.signal": result.signal ?? forwardedSignal ?? "" } : {},
|
|
97
|
-
duration_ms: Date.now() - startedAt
|
|
98
|
-
});
|
|
99
|
-
return exitCode;
|
|
100
|
-
} catch (error) {
|
|
101
|
-
await telemetry.recordError(HOST_FAILED_EVENT, error, { duration_ms: Date.now() - startedAt });
|
|
102
|
-
throw error;
|
|
75
|
+
return result.code !== null ? result.code : result.signal ? 128 + node_os.default.constants.signals[result.signal] : 1;
|
|
103
76
|
} finally {
|
|
104
77
|
detachLifeline();
|
|
105
78
|
if (escalationTimer) clearTimeout(escalationTimer);
|
|
106
|
-
await telemetry.shutdown();
|
|
107
79
|
}
|
|
108
80
|
}
|
|
109
81
|
async function readSpec(target) {
|
|
@@ -178,7 +150,7 @@ async function loadCleaner() {
|
|
|
178
150
|
const [{ cleanupRunnerSession }, { RunnerPaths }, { createDefaultProcessRegistry }] = await Promise.all([
|
|
179
151
|
Promise.resolve().then(() => require("../runnerHost-BXk4sPBP.cjs")).then((n) => n.runnerHost_exports),
|
|
180
152
|
Promise.resolve().then(() => require("../runnerPaths-Dkl43cmS.cjs")).then((n) => n.runnerPaths_exports),
|
|
181
|
-
Promise.resolve().then(() => require("../runnerRegistry-
|
|
153
|
+
Promise.resolve().then(() => require("../runnerRegistry-BucrRJsG.cjs")).then((n) => n.runnerRegistry_exports)
|
|
182
154
|
]);
|
|
183
155
|
const registries = /* @__PURE__ */ new Map();
|
|
184
156
|
process.once("beforeExit", () => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runnerHost.cjs","names":["spawn","watchOwner","LIFELINE_ENV","os","fs","pathToFileURL","REGISTRY_PATH_ENV"],"sources":["../../src/bin/runnerHost.ts"],"sourcesContent":["import { type ChildProcess, spawn } from 'node:child_process';\nimport fs from 'node:fs/promises';\nimport os from 'node:os';\nimport { pathToFileURL } from 'node:url';\n\nimport type { PoTelemetry } from '@agimon-ai/po-core/telemetry';\n\nimport { OBSERVE_SESSIONS_FLAG } from '../constants/lifeline';\nimport { REGISTRY_PATH_ENV } from '../constants/runnerRegistry';\nimport { LIFELINE_ENV, observeSessions, watchOwner } from '../services/lifeline/client';\nimport type { ProcessRegistryPort } from '../services/runnerRegistry';\nimport type { LifelineWatch } from '../types/lifeline';\n\n// Everything heavier loads on first use: the observer idles for its owner's whole life and\n// these imports alone would more than double its memory.\n\ninterface CommandSpec {\n command: string;\n cwd: string;\n env: Record<string, string>;\n}\n\nconst POLL_MS = 10;\nconst FORWARDED_SIGNAL_GRACE_MS = 2_000;\nconst HOST_FAILED_EVENT = 'po_runner.host_failed';\nconst INVALID_COMMAND_SPEC_ERROR = 'invalid runner command spec';\n\nexport async function main(argv: readonly string[] = process.argv.slice(2)): Promise<number> {\n if (argv[0] === OBSERVE_SESSIONS_FLAG) {\n observeSessions(cleanupSessions);\n return 0;\n }\n const startedAt = Date.now();\n const { createPoTelemetry } = await import('@agimon-ai/po-core/telemetry');\n const telemetry = createPoTelemetry({\n serviceName: 'po-runner-host',\n packageName: '@agimon-ai/po-runner',\n env: process.env,\n enableLogs: true,\n enableTraces: true,\n });\n const [specPath, gatePath, exitPath] = argv;\n let spec: CommandSpec;\n try {\n if (!specPath || !gatePath || !exitPath) throw new Error('runnerHost requires spec, gate, and exit paths');\n spec = await readSpec(specPath);\n await waitForGate(gatePath);\n } catch (error) {\n await telemetry.recordError(HOST_FAILED_EVENT, error, { duration_ms: Date.now() - startedAt });\n await telemetry.shutdown();\n throw error;\n }\n let child: ChildProcess | undefined;\n let forwardedSignal: NodeJS.Signals | null = null;\n let escalationTimer: NodeJS.Timeout | undefined;\n let ownerLost = false;\n const signalChild = (signal: NodeJS.Signals): void => {\n if (!child?.pid) return;\n try {\n process.kill(-child.pid, signal);\n } catch {\n child.kill(signal);\n }\n };\n const forward = (signal: NodeJS.Signals): void => {\n forwardedSignal = signal;\n signalChild(signal);\n escalationTimer ??= setTimeout(() => signalChild('SIGKILL'), FORWARDED_SIGNAL_GRACE_MS);\n };\n const signals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT', 'SIGHUP'];\n for (const signal of signals) process.once(signal, () => forward(signal));\n\n child = spawn(spec.command, {\n cwd: spec.cwd,\n env: spec.env,\n shell: '/bin/bash',\n stdio: 'inherit',\n detached: true,\n });\n if (forwardedSignal) forward(forwardedSignal);\n\n // This process supervises the command for its whole life, so it is also the\n // right place to notice an owner that died without stopping it.\n //\n // The lifeline comes from the spec rather than this process's environment: an\n // rmux server outlives the session that started it, so its own environment can\n // still name a previous session's socket.\n const detachLifeline = watchOwner(() => {\n ownerLost = true;\n forward('SIGTERM');\n }, spec.env[LIFELINE_ENV]);\n\n try {\n const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {\n child?.once('error', reject);\n child?.once('exit', (code, signal) => resolve({ code, signal: signal ?? forwardedSignal }));\n });\n await writeAtomic(exitPath, {\n ...result,\n ...(ownerLost ? { reason: 'stopped', terminationReason: 'owner_lost', stopReason: 'owner session ended' } : {}),\n });\n const exitCode = result.code !== null ? result.code : result.signal ? 128 + os.constants.signals[result.signal] : 1;\n await telemetry.recordEvent('po_runner.host_finished', {\n outcome: result.signal || forwardedSignal ? 'signaled' : exitCode === 0 ? 'completed' : 'failed',\n 'runner.exit_code': exitCode,\n ...(result.signal || forwardedSignal ? { 'runner.signal': result.signal ?? forwardedSignal ?? '' } : {}),\n duration_ms: Date.now() - startedAt,\n });\n return exitCode;\n } catch (error) {\n await telemetry.recordError(HOST_FAILED_EVENT, error, { duration_ms: Date.now() - startedAt });\n throw error;\n } finally {\n detachLifeline();\n if (escalationTimer) clearTimeout(escalationTimer);\n await telemetry.shutdown();\n }\n}\n\nasync function readSpec(target: string): Promise<CommandSpec> {\n const value: unknown = JSON.parse(await fs.readFile(target, 'utf8'));\n if (typeof value !== 'object' || value === null) throw new Error(INVALID_COMMAND_SPEC_ERROR);\n const spec = value as Partial<CommandSpec>;\n if (typeof spec.command !== 'string' || typeof spec.cwd !== 'string' || !spec.env) {\n throw new Error(INVALID_COMMAND_SPEC_ERROR);\n }\n return { command: spec.command, cwd: spec.cwd, env: spec.env };\n}\n\nasync function waitForGate(target: string): Promise<void> {\n for (;;) {\n try {\n await fs.access(target);\n return;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n }\n await new Promise((resolve) => setTimeout(resolve, POLL_MS));\n }\n}\n\nasync function writeAtomic(target: string, value: unknown): Promise<void> {\n const temporary = `${target}.${process.pid}.tmp`;\n await fs.writeFile(temporary, `${JSON.stringify(value)}\\n`, { mode: 0o600 });\n await fs.rename(temporary, target);\n}\n\nif (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {\n main().then(\n (code) => {\n process.exitCode = code;\n },\n (error: unknown) => {\n process.stderr.write(`${error instanceof Error ? error.message : String(error)}\\n`);\n process.exitCode = 1;\n },\n );\n}\n\n/** Loaded on the first session an owner leaves behind, then shared by every later one. */\nlet cleaner: Promise<(watch: LifelineWatch) => Promise<void>> | undefined;\nlet telemetry: Promise<PoTelemetry> | undefined;\n\n/**\n * A separate process survives SIGKILL of the owner, even if that session has no active runners.\n * The owner cleans up each session it ends with its own shared services and removes its marker,\n * so only a session still marked with its generation is left here; the rest load nothing.\n */\nasync function cleanupSessions(watches: readonly LifelineWatch[]): Promise<void> {\n const failures: { watch: LifelineWatch; error: unknown }[] = [];\n // One session failing to clean up must not stop the others.\n for (const watch of watches) {\n try {\n // The same fence cleanupRunnerSession applies, checked before anything loads for it.\n if ((await readMarker(watch.marker)) !== watch.generation) continue;\n const clean = await (cleaner ??= loadCleaner());\n await clean(watch);\n } catch (error) {\n failures.push({ watch, error });\n }\n }\n const [first] = failures;\n if (!first) return;\n const log = await (telemetry ??= loadTelemetry(first.watch.environment));\n for (const { watch, error } of failures) {\n await log.recordError('po_runner.observer_cleanup_failed', error, { 'session.id': watch.sessionId });\n }\n await log.flush();\n throw new AggregateError(\n failures.map(({ error }) => error),\n `Could not clean up runner sessions ${failures.map(({ watch }) => watch.sessionId).join(', ')}`,\n );\n}\n\nasync function readMarker(marker: string): Promise<string | undefined> {\n try {\n return await fs.readFile(marker, 'utf8');\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;\n throw error;\n }\n}\n\n/** One registry per registry path, held until the observer runs out of work. */\nasync function loadCleaner(): Promise<(watch: LifelineWatch) => Promise<void>> {\n const [{ cleanupRunnerSession }, { RunnerPaths }, { createDefaultProcessRegistry }] = await Promise.all([\n import('../services/runnerHost'),\n import('../services/runnerPaths'),\n import('../services/runnerRegistry'),\n ]);\n const registries = new Map<string | undefined, ProcessRegistryPort>();\n process.once('beforeExit', () => {\n for (const registry of registries.values()) registry.close();\n });\n return (watch) => {\n const registryPath = watch.environment[REGISTRY_PATH_ENV];\n let registry = registries.get(registryPath);\n if (!registry) {\n registry = createDefaultProcessRegistry(watch.environment);\n registries.set(registryPath, registry);\n }\n return cleanupRunnerSession({\n sessionId: watch.sessionId,\n paths: new RunnerPaths(watch.repository, watch.environment),\n processRegistry: registry,\n ownerPid: watch.ownerPid,\n generation: watch.generation,\n });\n };\n}\n\nasync function loadTelemetry(environment: LifelineWatch['environment']): Promise<PoTelemetry> {\n const { createPoTelemetry } = await import('@agimon-ai/po-core/telemetry');\n const shared = createPoTelemetry({\n serviceName: 'po-runner-host',\n packageName: '@agimon-ai/po-runner',\n env: environment,\n enableLogs: true,\n });\n process.once('beforeExit', () => void shared.shutdown());\n return shared;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"runnerHost.cjs","names":["spawn","watchOwner","LIFELINE_ENV","os","fs","pathToFileURL","REGISTRY_PATH_ENV"],"sources":["../../src/bin/runnerHost.ts"],"sourcesContent":["import { type ChildProcess, spawn } from 'node:child_process';\nimport fs from 'node:fs/promises';\nimport os from 'node:os';\nimport { pathToFileURL } from 'node:url';\n\nimport type { PoTelemetry } from '@agimon-ai/po-core/telemetry';\n\nimport { OBSERVE_SESSIONS_FLAG } from '../constants/lifeline';\nimport { REGISTRY_PATH_ENV } from '../constants/runnerRegistry';\nimport { LIFELINE_ENV, observeSessions, watchOwner } from '../services/lifeline/client';\nimport type { ProcessRegistryPort } from '../services/runnerRegistry';\nimport type { LifelineWatch } from '../types/lifeline';\n\n// Everything heavier loads on first use: the observer idles for its owner's whole life and\n// these imports alone would more than double its memory. A command's host loads none of it:\n// the server records the runner when it completes the record from this host's exit file.\n\ninterface CommandSpec {\n command: string;\n cwd: string;\n env: Record<string, string>;\n}\n\nconst POLL_MS = 10;\nconst FORWARDED_SIGNAL_GRACE_MS = 2_000;\nconst INVALID_COMMAND_SPEC_ERROR = 'invalid runner command spec';\n\nexport async function main(argv: readonly string[] = process.argv.slice(2)): Promise<number> {\n if (argv[0] === OBSERVE_SESSIONS_FLAG) {\n observeSessions(cleanupSessions);\n return 0;\n }\n const [specPath, gatePath, exitPath] = argv;\n if (!specPath || !gatePath || !exitPath) throw new Error('runnerHost requires spec, gate, and exit paths');\n const spec = await readSpec(specPath);\n await waitForGate(gatePath);\n let child: ChildProcess | undefined;\n let forwardedSignal: NodeJS.Signals | null = null;\n let escalationTimer: NodeJS.Timeout | undefined;\n let ownerLost = false;\n const signalChild = (signal: NodeJS.Signals): void => {\n if (!child?.pid) return;\n try {\n process.kill(-child.pid, signal);\n } catch {\n child.kill(signal);\n }\n };\n const forward = (signal: NodeJS.Signals): void => {\n forwardedSignal = signal;\n signalChild(signal);\n escalationTimer ??= setTimeout(() => signalChild('SIGKILL'), FORWARDED_SIGNAL_GRACE_MS);\n };\n const signals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT', 'SIGHUP'];\n for (const signal of signals) process.once(signal, () => forward(signal));\n\n child = spawn(spec.command, {\n cwd: spec.cwd,\n env: spec.env,\n shell: '/bin/bash',\n stdio: 'inherit',\n detached: true,\n });\n if (forwardedSignal) forward(forwardedSignal);\n\n // This process supervises the command for its whole life, so it is also the\n // right place to notice an owner that died without stopping it.\n //\n // The lifeline comes from the spec rather than this process's environment: an\n // rmux server outlives the session that started it, so its own environment can\n // still name a previous session's socket.\n const detachLifeline = watchOwner(() => {\n ownerLost = true;\n forward('SIGTERM');\n }, spec.env[LIFELINE_ENV]);\n\n try {\n const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {\n child?.once('error', reject);\n child?.once('exit', (code, signal) => resolve({ code, signal: signal ?? forwardedSignal }));\n });\n await writeAtomic(exitPath, {\n ...result,\n ...(ownerLost ? { reason: 'stopped', terminationReason: 'owner_lost', stopReason: 'owner session ended' } : {}),\n });\n return result.code !== null ? result.code : result.signal ? 128 + os.constants.signals[result.signal] : 1;\n } finally {\n detachLifeline();\n if (escalationTimer) clearTimeout(escalationTimer);\n }\n}\n\nasync function readSpec(target: string): Promise<CommandSpec> {\n const value: unknown = JSON.parse(await fs.readFile(target, 'utf8'));\n if (typeof value !== 'object' || value === null) throw new Error(INVALID_COMMAND_SPEC_ERROR);\n const spec = value as Partial<CommandSpec>;\n if (typeof spec.command !== 'string' || typeof spec.cwd !== 'string' || !spec.env) {\n throw new Error(INVALID_COMMAND_SPEC_ERROR);\n }\n return { command: spec.command, cwd: spec.cwd, env: spec.env };\n}\n\nasync function waitForGate(target: string): Promise<void> {\n for (;;) {\n try {\n await fs.access(target);\n return;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n }\n await new Promise((resolve) => setTimeout(resolve, POLL_MS));\n }\n}\n\nasync function writeAtomic(target: string, value: unknown): Promise<void> {\n const temporary = `${target}.${process.pid}.tmp`;\n await fs.writeFile(temporary, `${JSON.stringify(value)}\\n`, { mode: 0o600 });\n await fs.rename(temporary, target);\n}\n\nif (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {\n main().then(\n (code) => {\n process.exitCode = code;\n },\n (error: unknown) => {\n process.stderr.write(`${error instanceof Error ? error.message : String(error)}\\n`);\n process.exitCode = 1;\n },\n );\n}\n\n/** Loaded on the first session an owner leaves behind, then shared by every later one. */\nlet cleaner: Promise<(watch: LifelineWatch) => Promise<void>> | undefined;\nlet telemetry: Promise<PoTelemetry> | undefined;\n\n/**\n * A separate process survives SIGKILL of the owner, even if that session has no active runners.\n * The owner cleans up each session it ends with its own shared services and removes its marker,\n * so only a session still marked with its generation is left here; the rest load nothing.\n */\nasync function cleanupSessions(watches: readonly LifelineWatch[]): Promise<void> {\n const failures: { watch: LifelineWatch; error: unknown }[] = [];\n // One session failing to clean up must not stop the others.\n for (const watch of watches) {\n try {\n // The same fence cleanupRunnerSession applies, checked before anything loads for it.\n if ((await readMarker(watch.marker)) !== watch.generation) continue;\n const clean = await (cleaner ??= loadCleaner());\n await clean(watch);\n } catch (error) {\n failures.push({ watch, error });\n }\n }\n const [first] = failures;\n if (!first) return;\n const log = await (telemetry ??= loadTelemetry(first.watch.environment));\n for (const { watch, error } of failures) {\n await log.recordError('po_runner.observer_cleanup_failed', error, { 'session.id': watch.sessionId });\n }\n await log.flush();\n throw new AggregateError(\n failures.map(({ error }) => error),\n `Could not clean up runner sessions ${failures.map(({ watch }) => watch.sessionId).join(', ')}`,\n );\n}\n\nasync function readMarker(marker: string): Promise<string | undefined> {\n try {\n return await fs.readFile(marker, 'utf8');\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;\n throw error;\n }\n}\n\n/** One registry per registry path, held until the observer runs out of work. */\nasync function loadCleaner(): Promise<(watch: LifelineWatch) => Promise<void>> {\n const [{ cleanupRunnerSession }, { RunnerPaths }, { createDefaultProcessRegistry }] = await Promise.all([\n import('../services/runnerHost'),\n import('../services/runnerPaths'),\n import('../services/runnerRegistry'),\n ]);\n const registries = new Map<string | undefined, ProcessRegistryPort>();\n process.once('beforeExit', () => {\n for (const registry of registries.values()) registry.close();\n });\n return (watch) => {\n const registryPath = watch.environment[REGISTRY_PATH_ENV];\n let registry = registries.get(registryPath);\n if (!registry) {\n registry = createDefaultProcessRegistry(watch.environment);\n registries.set(registryPath, registry);\n }\n return cleanupRunnerSession({\n sessionId: watch.sessionId,\n paths: new RunnerPaths(watch.repository, watch.environment),\n processRegistry: registry,\n ownerPid: watch.ownerPid,\n generation: watch.generation,\n });\n };\n}\n\nasync function loadTelemetry(environment: LifelineWatch['environment']): Promise<PoTelemetry> {\n const { createPoTelemetry } = await import('@agimon-ai/po-core/telemetry');\n const shared = createPoTelemetry({\n serviceName: 'po-runner-host',\n packageName: '@agimon-ai/po-runner',\n env: environment,\n enableLogs: true,\n });\n process.once('beforeExit', () => void shared.shutdown());\n return shared;\n}\n"],"mappings":"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"}
|
package/dist/bin/runnerHost.mjs
CHANGED
|
@@ -8,33 +8,16 @@ import os from "node:os";
|
|
|
8
8
|
//#region src/bin/runnerHost.ts
|
|
9
9
|
const POLL_MS = 10;
|
|
10
10
|
const FORWARDED_SIGNAL_GRACE_MS = 2e3;
|
|
11
|
-
const HOST_FAILED_EVENT = "po_runner.host_failed";
|
|
12
11
|
const INVALID_COMMAND_SPEC_ERROR = "invalid runner command spec";
|
|
13
12
|
async function main(argv = process.argv.slice(2)) {
|
|
14
13
|
if (argv[0] === "--observe-sessions") {
|
|
15
14
|
observeSessions(cleanupSessions);
|
|
16
15
|
return 0;
|
|
17
16
|
}
|
|
18
|
-
const startedAt = Date.now();
|
|
19
|
-
const { createPoTelemetry } = await import("@agimon-ai/po-core/telemetry");
|
|
20
|
-
const telemetry = createPoTelemetry({
|
|
21
|
-
serviceName: "po-runner-host",
|
|
22
|
-
packageName: "@agimon-ai/po-runner",
|
|
23
|
-
env: process.env,
|
|
24
|
-
enableLogs: true,
|
|
25
|
-
enableTraces: true
|
|
26
|
-
});
|
|
27
17
|
const [specPath, gatePath, exitPath] = argv;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
spec = await readSpec(specPath);
|
|
32
|
-
await waitForGate(gatePath);
|
|
33
|
-
} catch (error) {
|
|
34
|
-
await telemetry.recordError(HOST_FAILED_EVENT, error, { duration_ms: Date.now() - startedAt });
|
|
35
|
-
await telemetry.shutdown();
|
|
36
|
-
throw error;
|
|
37
|
-
}
|
|
18
|
+
if (!specPath || !gatePath || !exitPath) throw new Error("runnerHost requires spec, gate, and exit paths");
|
|
19
|
+
const spec = await readSpec(specPath);
|
|
20
|
+
await waitForGate(gatePath);
|
|
38
21
|
let child;
|
|
39
22
|
let forwardedSignal = null;
|
|
40
23
|
let escalationTimer;
|
|
@@ -85,21 +68,10 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
85
68
|
stopReason: "owner session ended"
|
|
86
69
|
} : {}
|
|
87
70
|
});
|
|
88
|
-
|
|
89
|
-
await telemetry.recordEvent("po_runner.host_finished", {
|
|
90
|
-
outcome: result.signal || forwardedSignal ? "signaled" : exitCode === 0 ? "completed" : "failed",
|
|
91
|
-
"runner.exit_code": exitCode,
|
|
92
|
-
...result.signal || forwardedSignal ? { "runner.signal": result.signal ?? forwardedSignal ?? "" } : {},
|
|
93
|
-
duration_ms: Date.now() - startedAt
|
|
94
|
-
});
|
|
95
|
-
return exitCode;
|
|
96
|
-
} catch (error) {
|
|
97
|
-
await telemetry.recordError(HOST_FAILED_EVENT, error, { duration_ms: Date.now() - startedAt });
|
|
98
|
-
throw error;
|
|
71
|
+
return result.code !== null ? result.code : result.signal ? 128 + os.constants.signals[result.signal] : 1;
|
|
99
72
|
} finally {
|
|
100
73
|
detachLifeline();
|
|
101
74
|
if (escalationTimer) clearTimeout(escalationTimer);
|
|
102
|
-
await telemetry.shutdown();
|
|
103
75
|
}
|
|
104
76
|
}
|
|
105
77
|
async function readSpec(target) {
|
|
@@ -174,7 +146,7 @@ async function loadCleaner() {
|
|
|
174
146
|
const [{ cleanupRunnerSession }, { RunnerPaths }, { createDefaultProcessRegistry }] = await Promise.all([
|
|
175
147
|
import("../runnerHost-B2Jiba1U.mjs").then((n) => n.r),
|
|
176
148
|
import("../runnerPaths-BiO-WuKm.mjs").then((n) => n.r),
|
|
177
|
-
import("../runnerRegistry-
|
|
149
|
+
import("../runnerRegistry-_B6LXMhS.mjs").then((n) => n.r)
|
|
178
150
|
]);
|
|
179
151
|
const registries = /* @__PURE__ */ new Map();
|
|
180
152
|
process.once("beforeExit", () => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runnerHost.mjs","names":[],"sources":["../../src/bin/runnerHost.ts"],"sourcesContent":["import { type ChildProcess, spawn } from 'node:child_process';\nimport fs from 'node:fs/promises';\nimport os from 'node:os';\nimport { pathToFileURL } from 'node:url';\n\nimport type { PoTelemetry } from '@agimon-ai/po-core/telemetry';\n\nimport { OBSERVE_SESSIONS_FLAG } from '../constants/lifeline';\nimport { REGISTRY_PATH_ENV } from '../constants/runnerRegistry';\nimport { LIFELINE_ENV, observeSessions, watchOwner } from '../services/lifeline/client';\nimport type { ProcessRegistryPort } from '../services/runnerRegistry';\nimport type { LifelineWatch } from '../types/lifeline';\n\n// Everything heavier loads on first use: the observer idles for its owner's whole life and\n// these imports alone would more than double its memory.\n\ninterface CommandSpec {\n command: string;\n cwd: string;\n env: Record<string, string>;\n}\n\nconst POLL_MS = 10;\nconst FORWARDED_SIGNAL_GRACE_MS = 2_000;\nconst HOST_FAILED_EVENT = 'po_runner.host_failed';\nconst INVALID_COMMAND_SPEC_ERROR = 'invalid runner command spec';\n\nexport async function main(argv: readonly string[] = process.argv.slice(2)): Promise<number> {\n if (argv[0] === OBSERVE_SESSIONS_FLAG) {\n observeSessions(cleanupSessions);\n return 0;\n }\n const startedAt = Date.now();\n const { createPoTelemetry } = await import('@agimon-ai/po-core/telemetry');\n const telemetry = createPoTelemetry({\n serviceName: 'po-runner-host',\n packageName: '@agimon-ai/po-runner',\n env: process.env,\n enableLogs: true,\n enableTraces: true,\n });\n const [specPath, gatePath, exitPath] = argv;\n let spec: CommandSpec;\n try {\n if (!specPath || !gatePath || !exitPath) throw new Error('runnerHost requires spec, gate, and exit paths');\n spec = await readSpec(specPath);\n await waitForGate(gatePath);\n } catch (error) {\n await telemetry.recordError(HOST_FAILED_EVENT, error, { duration_ms: Date.now() - startedAt });\n await telemetry.shutdown();\n throw error;\n }\n let child: ChildProcess | undefined;\n let forwardedSignal: NodeJS.Signals | null = null;\n let escalationTimer: NodeJS.Timeout | undefined;\n let ownerLost = false;\n const signalChild = (signal: NodeJS.Signals): void => {\n if (!child?.pid) return;\n try {\n process.kill(-child.pid, signal);\n } catch {\n child.kill(signal);\n }\n };\n const forward = (signal: NodeJS.Signals): void => {\n forwardedSignal = signal;\n signalChild(signal);\n escalationTimer ??= setTimeout(() => signalChild('SIGKILL'), FORWARDED_SIGNAL_GRACE_MS);\n };\n const signals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT', 'SIGHUP'];\n for (const signal of signals) process.once(signal, () => forward(signal));\n\n child = spawn(spec.command, {\n cwd: spec.cwd,\n env: spec.env,\n shell: '/bin/bash',\n stdio: 'inherit',\n detached: true,\n });\n if (forwardedSignal) forward(forwardedSignal);\n\n // This process supervises the command for its whole life, so it is also the\n // right place to notice an owner that died without stopping it.\n //\n // The lifeline comes from the spec rather than this process's environment: an\n // rmux server outlives the session that started it, so its own environment can\n // still name a previous session's socket.\n const detachLifeline = watchOwner(() => {\n ownerLost = true;\n forward('SIGTERM');\n }, spec.env[LIFELINE_ENV]);\n\n try {\n const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {\n child?.once('error', reject);\n child?.once('exit', (code, signal) => resolve({ code, signal: signal ?? forwardedSignal }));\n });\n await writeAtomic(exitPath, {\n ...result,\n ...(ownerLost ? { reason: 'stopped', terminationReason: 'owner_lost', stopReason: 'owner session ended' } : {}),\n });\n const exitCode = result.code !== null ? result.code : result.signal ? 128 + os.constants.signals[result.signal] : 1;\n await telemetry.recordEvent('po_runner.host_finished', {\n outcome: result.signal || forwardedSignal ? 'signaled' : exitCode === 0 ? 'completed' : 'failed',\n 'runner.exit_code': exitCode,\n ...(result.signal || forwardedSignal ? { 'runner.signal': result.signal ?? forwardedSignal ?? '' } : {}),\n duration_ms: Date.now() - startedAt,\n });\n return exitCode;\n } catch (error) {\n await telemetry.recordError(HOST_FAILED_EVENT, error, { duration_ms: Date.now() - startedAt });\n throw error;\n } finally {\n detachLifeline();\n if (escalationTimer) clearTimeout(escalationTimer);\n await telemetry.shutdown();\n }\n}\n\nasync function readSpec(target: string): Promise<CommandSpec> {\n const value: unknown = JSON.parse(await fs.readFile(target, 'utf8'));\n if (typeof value !== 'object' || value === null) throw new Error(INVALID_COMMAND_SPEC_ERROR);\n const spec = value as Partial<CommandSpec>;\n if (typeof spec.command !== 'string' || typeof spec.cwd !== 'string' || !spec.env) {\n throw new Error(INVALID_COMMAND_SPEC_ERROR);\n }\n return { command: spec.command, cwd: spec.cwd, env: spec.env };\n}\n\nasync function waitForGate(target: string): Promise<void> {\n for (;;) {\n try {\n await fs.access(target);\n return;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n }\n await new Promise((resolve) => setTimeout(resolve, POLL_MS));\n }\n}\n\nasync function writeAtomic(target: string, value: unknown): Promise<void> {\n const temporary = `${target}.${process.pid}.tmp`;\n await fs.writeFile(temporary, `${JSON.stringify(value)}\\n`, { mode: 0o600 });\n await fs.rename(temporary, target);\n}\n\nif (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {\n main().then(\n (code) => {\n process.exitCode = code;\n },\n (error: unknown) => {\n process.stderr.write(`${error instanceof Error ? error.message : String(error)}\\n`);\n process.exitCode = 1;\n },\n );\n}\n\n/** Loaded on the first session an owner leaves behind, then shared by every later one. */\nlet cleaner: Promise<(watch: LifelineWatch) => Promise<void>> | undefined;\nlet telemetry: Promise<PoTelemetry> | undefined;\n\n/**\n * A separate process survives SIGKILL of the owner, even if that session has no active runners.\n * The owner cleans up each session it ends with its own shared services and removes its marker,\n * so only a session still marked with its generation is left here; the rest load nothing.\n */\nasync function cleanupSessions(watches: readonly LifelineWatch[]): Promise<void> {\n const failures: { watch: LifelineWatch; error: unknown }[] = [];\n // One session failing to clean up must not stop the others.\n for (const watch of watches) {\n try {\n // The same fence cleanupRunnerSession applies, checked before anything loads for it.\n if ((await readMarker(watch.marker)) !== watch.generation) continue;\n const clean = await (cleaner ??= loadCleaner());\n await clean(watch);\n } catch (error) {\n failures.push({ watch, error });\n }\n }\n const [first] = failures;\n if (!first) return;\n const log = await (telemetry ??= loadTelemetry(first.watch.environment));\n for (const { watch, error } of failures) {\n await log.recordError('po_runner.observer_cleanup_failed', error, { 'session.id': watch.sessionId });\n }\n await log.flush();\n throw new AggregateError(\n failures.map(({ error }) => error),\n `Could not clean up runner sessions ${failures.map(({ watch }) => watch.sessionId).join(', ')}`,\n );\n}\n\nasync function readMarker(marker: string): Promise<string | undefined> {\n try {\n return await fs.readFile(marker, 'utf8');\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;\n throw error;\n }\n}\n\n/** One registry per registry path, held until the observer runs out of work. */\nasync function loadCleaner(): Promise<(watch: LifelineWatch) => Promise<void>> {\n const [{ cleanupRunnerSession }, { RunnerPaths }, { createDefaultProcessRegistry }] = await Promise.all([\n import('../services/runnerHost'),\n import('../services/runnerPaths'),\n import('../services/runnerRegistry'),\n ]);\n const registries = new Map<string | undefined, ProcessRegistryPort>();\n process.once('beforeExit', () => {\n for (const registry of registries.values()) registry.close();\n });\n return (watch) => {\n const registryPath = watch.environment[REGISTRY_PATH_ENV];\n let registry = registries.get(registryPath);\n if (!registry) {\n registry = createDefaultProcessRegistry(watch.environment);\n registries.set(registryPath, registry);\n }\n return cleanupRunnerSession({\n sessionId: watch.sessionId,\n paths: new RunnerPaths(watch.repository, watch.environment),\n processRegistry: registry,\n ownerPid: watch.ownerPid,\n generation: watch.generation,\n });\n };\n}\n\nasync function loadTelemetry(environment: LifelineWatch['environment']): Promise<PoTelemetry> {\n const { createPoTelemetry } = await import('@agimon-ai/po-core/telemetry');\n const shared = createPoTelemetry({\n serviceName: 'po-runner-host',\n packageName: '@agimon-ai/po-runner',\n env: environment,\n enableLogs: true,\n });\n process.once('beforeExit', () => void shared.shutdown());\n return shared;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"runnerHost.mjs","names":[],"sources":["../../src/bin/runnerHost.ts"],"sourcesContent":["import { type ChildProcess, spawn } from 'node:child_process';\nimport fs from 'node:fs/promises';\nimport os from 'node:os';\nimport { pathToFileURL } from 'node:url';\n\nimport type { PoTelemetry } from '@agimon-ai/po-core/telemetry';\n\nimport { OBSERVE_SESSIONS_FLAG } from '../constants/lifeline';\nimport { REGISTRY_PATH_ENV } from '../constants/runnerRegistry';\nimport { LIFELINE_ENV, observeSessions, watchOwner } from '../services/lifeline/client';\nimport type { ProcessRegistryPort } from '../services/runnerRegistry';\nimport type { LifelineWatch } from '../types/lifeline';\n\n// Everything heavier loads on first use: the observer idles for its owner's whole life and\n// these imports alone would more than double its memory. A command's host loads none of it:\n// the server records the runner when it completes the record from this host's exit file.\n\ninterface CommandSpec {\n command: string;\n cwd: string;\n env: Record<string, string>;\n}\n\nconst POLL_MS = 10;\nconst FORWARDED_SIGNAL_GRACE_MS = 2_000;\nconst INVALID_COMMAND_SPEC_ERROR = 'invalid runner command spec';\n\nexport async function main(argv: readonly string[] = process.argv.slice(2)): Promise<number> {\n if (argv[0] === OBSERVE_SESSIONS_FLAG) {\n observeSessions(cleanupSessions);\n return 0;\n }\n const [specPath, gatePath, exitPath] = argv;\n if (!specPath || !gatePath || !exitPath) throw new Error('runnerHost requires spec, gate, and exit paths');\n const spec = await readSpec(specPath);\n await waitForGate(gatePath);\n let child: ChildProcess | undefined;\n let forwardedSignal: NodeJS.Signals | null = null;\n let escalationTimer: NodeJS.Timeout | undefined;\n let ownerLost = false;\n const signalChild = (signal: NodeJS.Signals): void => {\n if (!child?.pid) return;\n try {\n process.kill(-child.pid, signal);\n } catch {\n child.kill(signal);\n }\n };\n const forward = (signal: NodeJS.Signals): void => {\n forwardedSignal = signal;\n signalChild(signal);\n escalationTimer ??= setTimeout(() => signalChild('SIGKILL'), FORWARDED_SIGNAL_GRACE_MS);\n };\n const signals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT', 'SIGHUP'];\n for (const signal of signals) process.once(signal, () => forward(signal));\n\n child = spawn(spec.command, {\n cwd: spec.cwd,\n env: spec.env,\n shell: '/bin/bash',\n stdio: 'inherit',\n detached: true,\n });\n if (forwardedSignal) forward(forwardedSignal);\n\n // This process supervises the command for its whole life, so it is also the\n // right place to notice an owner that died without stopping it.\n //\n // The lifeline comes from the spec rather than this process's environment: an\n // rmux server outlives the session that started it, so its own environment can\n // still name a previous session's socket.\n const detachLifeline = watchOwner(() => {\n ownerLost = true;\n forward('SIGTERM');\n }, spec.env[LIFELINE_ENV]);\n\n try {\n const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {\n child?.once('error', reject);\n child?.once('exit', (code, signal) => resolve({ code, signal: signal ?? forwardedSignal }));\n });\n await writeAtomic(exitPath, {\n ...result,\n ...(ownerLost ? { reason: 'stopped', terminationReason: 'owner_lost', stopReason: 'owner session ended' } : {}),\n });\n return result.code !== null ? result.code : result.signal ? 128 + os.constants.signals[result.signal] : 1;\n } finally {\n detachLifeline();\n if (escalationTimer) clearTimeout(escalationTimer);\n }\n}\n\nasync function readSpec(target: string): Promise<CommandSpec> {\n const value: unknown = JSON.parse(await fs.readFile(target, 'utf8'));\n if (typeof value !== 'object' || value === null) throw new Error(INVALID_COMMAND_SPEC_ERROR);\n const spec = value as Partial<CommandSpec>;\n if (typeof spec.command !== 'string' || typeof spec.cwd !== 'string' || !spec.env) {\n throw new Error(INVALID_COMMAND_SPEC_ERROR);\n }\n return { command: spec.command, cwd: spec.cwd, env: spec.env };\n}\n\nasync function waitForGate(target: string): Promise<void> {\n for (;;) {\n try {\n await fs.access(target);\n return;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n }\n await new Promise((resolve) => setTimeout(resolve, POLL_MS));\n }\n}\n\nasync function writeAtomic(target: string, value: unknown): Promise<void> {\n const temporary = `${target}.${process.pid}.tmp`;\n await fs.writeFile(temporary, `${JSON.stringify(value)}\\n`, { mode: 0o600 });\n await fs.rename(temporary, target);\n}\n\nif (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {\n main().then(\n (code) => {\n process.exitCode = code;\n },\n (error: unknown) => {\n process.stderr.write(`${error instanceof Error ? error.message : String(error)}\\n`);\n process.exitCode = 1;\n },\n );\n}\n\n/** Loaded on the first session an owner leaves behind, then shared by every later one. */\nlet cleaner: Promise<(watch: LifelineWatch) => Promise<void>> | undefined;\nlet telemetry: Promise<PoTelemetry> | undefined;\n\n/**\n * A separate process survives SIGKILL of the owner, even if that session has no active runners.\n * The owner cleans up each session it ends with its own shared services and removes its marker,\n * so only a session still marked with its generation is left here; the rest load nothing.\n */\nasync function cleanupSessions(watches: readonly LifelineWatch[]): Promise<void> {\n const failures: { watch: LifelineWatch; error: unknown }[] = [];\n // One session failing to clean up must not stop the others.\n for (const watch of watches) {\n try {\n // The same fence cleanupRunnerSession applies, checked before anything loads for it.\n if ((await readMarker(watch.marker)) !== watch.generation) continue;\n const clean = await (cleaner ??= loadCleaner());\n await clean(watch);\n } catch (error) {\n failures.push({ watch, error });\n }\n }\n const [first] = failures;\n if (!first) return;\n const log = await (telemetry ??= loadTelemetry(first.watch.environment));\n for (const { watch, error } of failures) {\n await log.recordError('po_runner.observer_cleanup_failed', error, { 'session.id': watch.sessionId });\n }\n await log.flush();\n throw new AggregateError(\n failures.map(({ error }) => error),\n `Could not clean up runner sessions ${failures.map(({ watch }) => watch.sessionId).join(', ')}`,\n );\n}\n\nasync function readMarker(marker: string): Promise<string | undefined> {\n try {\n return await fs.readFile(marker, 'utf8');\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;\n throw error;\n }\n}\n\n/** One registry per registry path, held until the observer runs out of work. */\nasync function loadCleaner(): Promise<(watch: LifelineWatch) => Promise<void>> {\n const [{ cleanupRunnerSession }, { RunnerPaths }, { createDefaultProcessRegistry }] = await Promise.all([\n import('../services/runnerHost'),\n import('../services/runnerPaths'),\n import('../services/runnerRegistry'),\n ]);\n const registries = new Map<string | undefined, ProcessRegistryPort>();\n process.once('beforeExit', () => {\n for (const registry of registries.values()) registry.close();\n });\n return (watch) => {\n const registryPath = watch.environment[REGISTRY_PATH_ENV];\n let registry = registries.get(registryPath);\n if (!registry) {\n registry = createDefaultProcessRegistry(watch.environment);\n registries.set(registryPath, registry);\n }\n return cleanupRunnerSession({\n sessionId: watch.sessionId,\n paths: new RunnerPaths(watch.repository, watch.environment),\n processRegistry: registry,\n ownerPid: watch.ownerPid,\n generation: watch.generation,\n });\n };\n}\n\nasync function loadTelemetry(environment: LifelineWatch['environment']): Promise<PoTelemetry> {\n const { createPoTelemetry } = await import('@agimon-ai/po-core/telemetry');\n const shared = createPoTelemetry({\n serviceName: 'po-runner-host',\n packageName: '@agimon-ai/po-runner',\n env: environment,\n enableLogs: true,\n });\n process.once('beforeExit', () => void shared.shutdown());\n return shared;\n}\n"],"mappings":"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"}
|
package/dist/extensions/mcp.cjs
CHANGED
|
@@ -28,6 +28,7 @@ let _agimon_ai_po_core_mcpFacet = require("@agimon-ai/po-core/mcpFacet");
|
|
|
28
28
|
let _agimon_ai_po_core_serverPlugin = require("@agimon-ai/po-core/serverPlugin");
|
|
29
29
|
require("@agimon-ai/po-core/codemode");
|
|
30
30
|
require("@agimon-ai/po-core/platform");
|
|
31
|
+
require("@agimon-ai/po-core/telemetry");
|
|
31
32
|
let typebox = require("typebox");
|
|
32
33
|
let node_child_process = require("node:child_process");
|
|
33
34
|
require("@agimon-ai/po-core/packageApi");
|
|
@@ -40,7 +41,6 @@ require("typebox/value");
|
|
|
40
41
|
require("@agimon-ai/po-core/backgroundWork");
|
|
41
42
|
require("@agimon-ai/po-core/childProcess");
|
|
42
43
|
require("@agimon-ai/po-core/extensionFile");
|
|
43
|
-
require("@agimon-ai/po-core/telemetry");
|
|
44
44
|
require("@earendil-works/pi-coding-agent");
|
|
45
45
|
//#region src/extensions/workspaces/sessions/(backend)/skill/help-how-to-runner.mcp.ts
|
|
46
46
|
var help_how_to_runner_mcp_default = (0, _agimon_ai_po_core_mcpFacet.defineMcpSkill)({
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mcp.cjs","names":["defineMcpSkill","readPackageResource","Type","headless","promisify","execFile","defineMcpTool","defineMcpPlugin","skillHelpHowToRunner","toolBash"],"sources":["../../src/extensions/workspaces/sessions/(backend)/skill/help-how-to-runner.mcp.ts","../../src/constants/runnerSettings.ts","../../src/constants/runnerSequence.ts","../../src/schemas/bashTool.ts","../../src/services/ansiScrub/index.ts","../../src/services/ptyHost/index.ts","../../src/services/runnerRegistry/index.ts","../../src/services/runnerSettings/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/po-core/mcpFacet';\nimport { readPackageResource } from '@agimon-ai/po-core/serverPlugin';\n\nexport default defineMcpSkill({\n name: 'help-how-to-runner',\n description:\n 'Use Po Pi Runner to supervise shell commands, inspect durable logs, provide interactive input, and stop background runs.',\n read: () => readPackageResource(import.meta.url, 'src/prompts/help-how-to-runner/SKILL.md'),\n});\n","export const RUNNER_SETTINGS_FILE = 'po-runner.json';\nexport const RATIO_KEYS = ['headRatio', 'errorBudgetRatio'] as const;\nexport const MAX_RESULT_BYTES_CEILING = 262_144;\nexport const MAX_RESULT_LINES_CEILING = 5_000;\nexport const MAX_RESULT_TOKENS_CEILING = 100_000;\nexport const MAX_ERROR_ENTRIES_CEILING = 100;\nexport const MAX_PATTERNS = 32;\nexport const MAX_PATTERN_LENGTH = 512;\n","export const MAX_SEQUENCE_COMMANDS = 32;\nexport const MAX_SEQUENCE_COMMAND_LENGTH = 65_536;\nexport const MAX_SEQUENCE_RECORD_BYTES = 4 * 1024 * 1024;\nexport const SEQUENCE_DIRECTORY = 'sequences';\nexport const SEQUENCE_PREFIX = 'sequence-';\n","import { type Static, Type } from 'typebox';\n\nimport { MAX_SEQUENCE_COMMANDS, MAX_SEQUENCE_COMMAND_LENGTH } from '../constants/runnerSequence';\n\n/** Replaces pi's built-in `bash` tool. Descriptions are agent-facing prompt copy. */\nexport const BashParamsSchema = Type.Object({\n command: Type.Array(Type.String({ minLength: 1, maxLength: MAX_SEQUENCE_COMMAND_LENGTH }), {\n minItems: 1,\n maxItems: MAX_SEQUENCE_COMMANDS,\n description:\n 'Bash commands to run in order, stopping on first failure. Use a one-item array for one command. Each command gets a full log; return all log paths and only the last executed result. Do not pipe or redirect output to a file, head, or tail. Logs are saved automatically and returned output is bounded.',\n }),\n timeout: Type.Optional(\n Type.Number({ description: 'Timeout in seconds for each command (optional, no default timeout)' }),\n ),\n background: Type.Optional(\n Type.Boolean({\n description:\n 'Start the command sequence in the background immediately. 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 only the last command under a pseudo terminal when it needs input. Earlier commands are noninteractive. Send input to the last stage runner ID with runner.input in code mode, or po-runner input without code mode.',\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 { RunnerSettings } from '../../types/runnerSettings';\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 private readonly settings?: RunnerSettings,\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 = await this.logFile.open(request.id);\n const terminal = new HeadlessTerminal({ cols, rows, scrollback: SCROLLBACK, allowProposedApi: true });\n const memoryLimit = getResultMaxBytes(this.environment, this.settings) * 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 let rejectCompletion: (error: unknown) => void = () => undefined;\n const completion = new Promise<ExitResult>((resolve, reject) => {\n resolveCompletion = resolve;\n rejectCompletion = reject;\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 void writer.append(scrubbed).catch((error: unknown) => process.emitWarning(String(error)));\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(async (result) => {\n if (settled) return;\n settled = true;\n this.runs.delete(request.name);\n try {\n await writer.close();\n resolveCompletion({ code: result.exitCode, signal: null });\n } catch (error) {\n rejectCompletion(error);\n }\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: async () => 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/promises';\nimport path from 'node:path';\nimport { promisify } from 'node:util';\n\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';\nimport { resolveRootSessionId } from '../session';\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// Server plugin 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 private recordWrites: Promise<void> = Promise.resolve();\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: await 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 await 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: await this.repositoryPath(),\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 await this.paths.ensureDirectories(sessionId);\n const records: RunnerRecord[] = [];\n const directory = this.paths.stateDirectory(sessionId);\n for (const entry of await fs.readdir(directory)) {\n if (!isPrimaryMetadataEntry(entry)) continue;\n try {\n const parsed: unknown = JSON.parse(await fs.readFile(`${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(await fs.readFile(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 await 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 await 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 await 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: await 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 async repositoryPath(): Promise<string> {\n return normalizeRepositoryPath(await this.paths.repositoryPath());\n }\n\n private writeRecord(record: RunnerRecord): Promise<void> {\n const write = this.recordWrites.then(async () => {\n await this.paths.ensureDirectories(record.sessionId);\n const target = this.paths.statePathFor(record.id, record.sessionId);\n const temporary = `${target}.${process.pid}.tmp`;\n await fs.writeFile(temporary, `${JSON.stringify(record, null, 2)}\\n`, 'utf8');\n await fs.rename(temporary, target);\n });\n // Keep later writes ordered even after a failed write, which its caller observes.\n this.recordWrites = write.catch(() => undefined);\n return write;\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 fs from 'node:fs/promises';\nimport path from 'node:path';\n\nimport { BACKGROUND_CONTEXT, readTextIfExists, type FileSystem } from '@agimon-ai/po-core/platform';\nimport { CONFIG_DIR_NAME } from '@earendil-works/pi-coding-agent';\n\nimport {\n MAX_ERROR_ENTRIES_CEILING,\n MAX_PATTERNS,\n MAX_PATTERN_LENGTH,\n MAX_RESULT_BYTES_CEILING,\n MAX_RESULT_LINES_CEILING,\n MAX_RESULT_TOKENS_CEILING,\n RATIO_KEYS,\n RUNNER_SETTINGS_FILE,\n} from '../../constants/runnerSettings';\nimport type { IRunnerSettingsLoader, RunnerSettings, RunnerSettingsLoad } from '../../types/runnerSettings';\n\n/** Pi's convention is one JSON file per extension under the config directory. */\n\nconst EMPTY: RunnerSettingsLoad = { settings: {}, issues: [] };\nconst COUNT_KEYS = [\n 'maxResultBytes',\n 'maxResultLines',\n 'successMaxResultBytes',\n 'maxResultTokens',\n 'successMaxResultTokens',\n 'errorMaxEntries',\n 'errorMaxVariantsJoined',\n] as const;\nconst KNOWN_KEYS = new Set<string>([...RATIO_KEYS, ...COUNT_KEYS, 'errorPatterns']);\n/** Ceilings so a project file cannot flood the model's context. */\nconst CEILINGS: Readonly<Record<string, number>> = {\n maxResultBytes: MAX_RESULT_BYTES_CEILING,\n successMaxResultBytes: MAX_RESULT_BYTES_CEILING,\n maxResultTokens: MAX_RESULT_TOKENS_CEILING,\n successMaxResultTokens: MAX_RESULT_TOKENS_CEILING,\n maxResultLines: MAX_RESULT_LINES_CEILING,\n errorMaxEntries: MAX_ERROR_ENTRIES_CEILING,\n errorMaxVariantsJoined: MAX_ERROR_ENTRIES_CEILING,\n};\n\nexport class RunnerSettingsLoader implements IRunnerSettingsLoader {\n constructor(private readonly files?: FileSystem) {}\n\n async load(cwd: string, trusted: boolean): Promise<RunnerSettingsLoad> {\n // Project-local configuration is untrusted input until the project is.\n if (!trusted) return EMPTY;\n\n let raw: string;\n try {\n const target = path.join(cwd, CONFIG_DIR_NAME, RUNNER_SETTINGS_FILE);\n const text = this.files\n ? await readTextIfExists(this.files, target, BACKGROUND_CONTEXT)\n : await fs.readFile(target, 'utf8');\n if (text === undefined) return EMPTY;\n raw = text;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') return EMPTY;\n return { settings: {}, issues: [`${RUNNER_SETTINGS_FILE} could not be read`] };\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n return { settings: {}, issues: [`${RUNNER_SETTINGS_FILE} is not valid JSON`] };\n }\n return validate(parsed);\n }\n}\n\nfunction validate(parsed: unknown): RunnerSettingsLoad {\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n return { settings: {}, issues: [`${RUNNER_SETTINGS_FILE} must contain a JSON object`] };\n }\n\n const source = parsed as Record<string, unknown>;\n const settings: Record<string, unknown> = {};\n const issues: string[] = [];\n // A rejected key is reported rather than dropped: a silent typo reads as a\n // setting that simply does not work.\n for (const key of Object.keys(source)) if (!KNOWN_KEYS.has(key)) issues.push(`unknown key ${key}`);\n\n for (const key of COUNT_KEYS) {\n const value = source[key];\n if (value === undefined) continue;\n if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {\n issues.push(`${key} must be a positive integer`);\n continue;\n }\n settings[key] = Math.min(value, CEILINGS[key] ?? value);\n }\n\n for (const key of RATIO_KEYS) {\n const value = source[key];\n if (value === undefined) continue;\n if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value >= 1) {\n issues.push(`${key} must be a number above 0 and below 1`);\n continue;\n }\n settings[key] = value;\n }\n\n const patterns = source.errorPatterns;\n if (patterns !== undefined) {\n if (!Array.isArray(patterns) || patterns.some((entry) => typeof entry !== 'string')) {\n issues.push('errorPatterns must be an array of strings');\n } else {\n const accepted: string[] = [];\n for (const pattern of patterns.slice(0, MAX_PATTERNS) as string[]) {\n if (pattern.length > MAX_PATTERN_LENGTH) {\n issues.push('errorPatterns entry is too long');\n continue;\n }\n try {\n new RegExp(pattern, 'iu');\n accepted.push(pattern);\n } catch {\n issues.push(`errorPatterns entry is not a valid regular expression: ${pattern}`);\n }\n }\n if (accepted.length > 0) settings.errorPatterns = accepted;\n }\n }\n\n return { settings: settings as RunnerSettings, issues };\n}\n","import { PO_CODEMODE_SERVICE, readPoCodemode } from '@agimon-ai/po-core/codemode';\nimport { readPoFs, PO_SESSION_TEMP_DIR } from '@agimon-ai/po-core/platform';\nimport type { PoServerPluginContext } from '@agimon-ai/po-core/serverPlugin';\nimport type { Context } from '@deepseek-ai/cordis';\n\nimport { REGISTRY_PATH_ENV } from '../../../../../constants/runnerRegistry';\nimport {\n HISTORY_SWEEP_INTERVAL_MS,\n RUNNER_FINISHED_MESSAGE,\n RUNNER_STATUS_POLL_MS,\n} from '../../../../../constants/runnerRuntime';\nimport { RUNNER_SETTINGS_FILE } from '../../../../../constants/runnerSettings';\nimport { SystemClock } from '../../../../../services/clock';\nimport { createHeadlessBashTool, createHeadlessRunnersCommand } from '../../../../../services/headless';\nimport { LifelineObserver } from '../../../../../services/lifeline';\nimport { summarizeLog } from '../../../../../services/logReader';\nimport { createRunnerDependencies } from '../../../../../services/runnerDependencies';\nimport { createRunnerHost } from '../../../../../services/runnerHost';\nimport { createRunnerNamespace } from '../../../../../services/runnerNamespace';\nimport { createRunnerOperations } from '../../../../../services/runnerOperations';\nimport { createDefaultProcessRegistry } from '../../../../../services/runnerRegistry';\nimport { createRunnerSequenceService } from '../../../../../services/runnerSequence';\nimport { createRunnerSequenceStore } from '../../../../../services/runnerSequenceStore';\nimport { createRunnerServerRuntime } from '../../../../../services/runnerServerRuntime';\nimport { RunnerSettingsLoader } from '../../../../../services/runnerSettings';\n\nexport const RUNNER_SERVER_SCOPE_SERVICE = 'po/runner-server-scope';\n\ndeclare module '@deepseek-ai/cordis' {\n interface Context {\n 'po/runner-server-scope': RunnerServerScope;\n }\n}\n\nexport const createRunnerServerRoot = async ({ context, host, agent, signal, resources }: PoServerPluginContext) => {\n if (!host.context.directEvents) throw new Error('Runner server plugin requires the session direct event bus.');\n if (!host.context.cwd || !host.context.environment) {\n throw new Error('Runner server plugin requires session cwd and environment.');\n }\n // Inherit the current headless Pi bridge's isProjectTrusted: () => true.\n // This preserves runner configuration parity, not a new general trust policy.\n const fs = readPoFs(context);\n const loaded = await new RunnerSettingsLoader(fs).load(host.context.cwd, true);\n const settings = Object.freeze({\n ...loaded.settings,\n ...(loaded.settings.errorPatterns ? { errorPatterns: Object.freeze([...loaded.settings.errorPatterns]) } : {}),\n });\n for (const issue of loaded.issues) process.emitWarning(`${RUNNER_SETTINGS_FILE}: ${issue}`);\n const environment = host.context.environment;\n if (!environment[PO_SESSION_TEMP_DIR])\n throw new Error('Runner requires the host-admitted session temporary directory.');\n // One per registry for this build in the hub: every session shares the connection and the loop that\n // keeps house for it, and the last session to close disposes them.\n const runnerHost = await resources.acquire(\n ['runner-host', environment[REGISTRY_PATH_ENV] ?? null],\n () =>\n createRunnerHost({\n processRegistry: createDefaultProcessRegistry(environment),\n clock: new SystemClock(),\n statusPollMs: RUNNER_STATUS_POLL_MS,\n historySweepMs: HISTORY_SWEEP_INTERVAL_MS,\n onWarning: (message) => process.emitWarning(message),\n }),\n (shared) => shared.dispose(),\n );\n // One observer process cleans up after every session of the registry, rather than one each.\n const lifelineObserver = await resources.acquire(\n ['runner-lifeline-observer', environment[REGISTRY_PATH_ENV] ?? null],\n () => new LifelineObserver(environment),\n (shared) => shared.close(),\n );\n const dependencies = createRunnerDependencies({\n settings,\n cwd: host.context.cwd,\n environment,\n processRegistry: runnerHost.processRegistry,\n lifelineObserver,\n ...(host.context.executionBudget === undefined ? {} : { executionBudget: host.context.executionBudget }),\n });\n const execution = agent?.context;\n const wakeAgent =\n execution &&\n (async (content: string, requestId: string, notice: string): Promise<void> => {\n try {\n // 'steer' joins a running turn and starts one on an idle session.\n // Reuse completion identity across retries and acknowledge admission, not settlement.\n // A custom message, not a prompt: the model reads it as input, while the\n // transcript shows the notice as an event rather than as the person's words.\n if (!execution.session.admitMessage) throw new Error('The session cannot admit a runner notification.');\n await execution.session.admitMessage(\n { type: RUNNER_FINISHED_MESSAGE, text: content, display: true, details: { notice } },\n 'steer',\n requestId,\n );\n } catch (error) {\n // Keep completion ownership with the watcher until admission succeeds.\n await Promise.resolve()\n .then(() => execution.client.notify({ body: String(error), level: 'warning' }))\n .catch(() => undefined);\n throw error;\n }\n });\n const runtime = createRunnerServerRuntime(dependencies, runnerHost, host.context.directEvents, wakeAgent, execution);\n const sequences =\n fs && execution\n ? createRunnerSequenceService({\n dependencies,\n store: createRunnerSequenceStore(fs, dependencies.paths, execution.sessionId),\n sessionId: execution.sessionId,\n signal,\n summarizeLog: (path, lines, bytes, tokens) => summarizeLog(path, lines, bytes, tokens, settings),\n onChange: () => runtime.sequenceChanged(),\n onWarning: (message) => process.emitWarning(message),\n onComplete: async (record) => {\n if (!record.result) return;\n await wakeAgent?.(\n record.result.content.flatMap((part) => (part.type === 'text' ? [part.text] : [])).join('\\n'),\n `runner-sequence-finished:${JSON.stringify([record.sessionId, record.id])}`,\n `Command sequence ${record.name} finished: ${record.outcome ?? record.state}`,\n );\n },\n })\n : undefined;\n if (sequences) runtime.setSequences(sequences);\n const bash = (wakeOnExit: boolean) =>\n createHeadlessBashTool(\n sequences\n ? async (params, context, toolCallId, callSignal, onUpdate) => {\n await runtime.ensureSession(context.sessionId);\n return sequences.run(params, context, toolCallId, callSignal, onUpdate, wakeOnExit);\n }\n : undefined,\n dependencies.environment,\n );\n const value = {\n sequences,\n tool: bash(true),\n // Remote MCP runners must not wake the local agent; the remote caller polls instead.\n remoteTool: bash(false),\n command: createHeadlessRunnersCommand(dependencies, {\n // Without the platform fs a missing folder still fails, only at spawn and less plainly.\n fs,\n sequences,\n onStart: (startedSessionId, failure) => runtime.reportStart(startedSessionId, failure),\n }),\n recoverCompaction: runtime.recoverCompaction,\n };\n let controlsDisposed = false;\n if (fs && execution) {\n const namespace = createRunnerNamespace({\n sessionId: execution.sessionId,\n operations: createRunnerOperations({\n ...dependencies,\n registry: dependencies.runnerRegistry,\n fs,\n sessionId: execution.sessionId,\n }),\n sequences,\n assertAvailable: () => {\n signal?.throwIfAborted();\n if (controlsDisposed) throw new Error('Runner session is disposed.');\n },\n });\n context.inject([PO_CODEMODE_SERVICE], (scoped) => {\n const registration = readPoCodemode(scoped)?.register('@agimon-ai/po-runner', namespace);\n return () => registration?.dispose();\n });\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 // Package install is on the session-create path; bash awaits supervision itself.\n onStart: execution\n ? () => {\n void runtime\n .ensureSession(execution.sessionId)\n .catch((error: unknown) =>\n process.emitWarning(`Could not initialize headless runner supervision: ${String(error)}`),\n );\n }\n : undefined,\n onStop: async () => {\n controlsDisposed = true;\n await runtime.dispose();\n },\n };\n};\n\nexport type RunnerServerScope = Awaited<ReturnType<typeof createRunnerServerRoot>>['value'];\n","import { defineMcpTool, type PoMcpPluginContext } from '@agimon-ai/po-core/mcpFacet';\n\nimport { RUNNER_SERVER_SCOPE_SERVICE, type RunnerServerScope } from '../_lib/serverRoot';\n\nconst pollHint = (id: string): string =>\n `You will not be notified when this runner ends. Check it with \\`po-runner status ${id}\\` and read output with \\`po-runner logs ${id} --lines 100\\`, both through this tool, until State is completed.`;\n\nexport default defineMcpTool((context: PoMcpPluginContext) => {\n const scope = context.services.get<RunnerServerScope>(RUNNER_SERVER_SCOPE_SERVICE);\n if (!scope) throw new Error('Runner session service is unavailable.');\n return {\n ...scope.remoteTool,\n description:\n \"Run an array of shell commands (bash, terminal, CLI) in the Po session's repository on the host machine, not on your local machine. Use a one-item array for one command. Commands run in order and stop on first failure. Only the last command may be interactive. Do not pipe or redirect output to a file, head, or tail: full logs are saved automatically and returned output is bounded. Long-running commands return a sequence id. No completion notice reaches you, so check with `po-runner status <id>` and `po-runner logs <id> --lines 100` instead of relaunching.\",\n async execute(toolCallId, parameters, signal, onUpdate, execution) {\n const result = await scope.remoteTool.execute(\n toolCallId,\n parameters,\n AbortSignal.any([context.signal, signal ?? context.signal]),\n onUpdate,\n execution,\n );\n const details = result.details as { promoted?: unknown; id?: unknown } | undefined;\n if (details?.promoted !== true || typeof details.id !== 'string') return result;\n return { ...result, content: [...result.content, { type: 'text', text: pollHint(details.id) }] };\n },\n };\n});\n","// Generated by @agimon-ai/po-build. Do not edit by hand.\nimport { defineMcpPlugin, type PoMcpSessionPlugin } from '@agimon-ai/po-core/mcpFacet';\n\nimport skillHelpHowToRunner from '../src/extensions/workspaces/sessions/(backend)/skill/help-how-to-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, [\"po/widget\"]: key },\n});\n\nexport const mcp = defineMcpPlugin({\n name: '@agimon-ai/po-runner',\n session: (context) => ({\n get skills(): PoMcpSessionPlugin['skills'] { return [at(skillHelpHowToRunner, context)]; },\n get tools(): PoMcpSessionPlugin['tools'] { return [withWidget(\"@agimon-ai/po-runner/bash\", via({ name: 'bash' }, at(toolBash, context)))]; },\n }) satisfies PoMcpSessionPlugin,\n});\n\nexport default mcp;\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"mcp.cjs","names":["defineMcpSkill","readPackageResource","Type","headless","promisify","execFile","defineMcpTool","defineMcpPlugin","skillHelpHowToRunner","toolBash"],"sources":["../../src/extensions/workspaces/sessions/(backend)/skill/help-how-to-runner.mcp.ts","../../src/constants/runnerSettings.ts","../../src/constants/runnerSequence.ts","../../src/schemas/bashTool.ts","../../src/services/ansiScrub/index.ts","../../src/services/ptyHost/index.ts","../../src/services/runnerRegistry/index.ts","../../src/services/runnerSettings/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/po-core/mcpFacet';\nimport { readPackageResource } from '@agimon-ai/po-core/serverPlugin';\n\nexport default defineMcpSkill({\n name: 'help-how-to-runner',\n description:\n 'Use Po Pi Runner to supervise shell commands, inspect durable logs, provide interactive input, and stop background runs.',\n read: () => readPackageResource(import.meta.url, 'src/prompts/help-how-to-runner/SKILL.md'),\n});\n","export const RUNNER_SETTINGS_FILE = 'po-runner.json';\nexport const RATIO_KEYS = ['headRatio', 'errorBudgetRatio'] as const;\nexport const MAX_RESULT_BYTES_CEILING = 262_144;\nexport const MAX_RESULT_LINES_CEILING = 5_000;\nexport const MAX_RESULT_TOKENS_CEILING = 100_000;\nexport const MAX_ERROR_ENTRIES_CEILING = 100;\nexport const MAX_PATTERNS = 32;\nexport const MAX_PATTERN_LENGTH = 512;\n","export const MAX_SEQUENCE_COMMANDS = 32;\nexport const MAX_SEQUENCE_COMMAND_LENGTH = 65_536;\nexport const MAX_SEQUENCE_RECORD_BYTES = 4 * 1024 * 1024;\nexport const SEQUENCE_DIRECTORY = 'sequences';\nexport const SEQUENCE_PREFIX = 'sequence-';\n","import { type Static, Type } from 'typebox';\n\nimport { MAX_SEQUENCE_COMMANDS, MAX_SEQUENCE_COMMAND_LENGTH } from '../constants/runnerSequence';\n\n/** Replaces pi's built-in `bash` tool. Descriptions are agent-facing prompt copy. */\nexport const BashParamsSchema = Type.Object({\n command: Type.Array(Type.String({ minLength: 1, maxLength: MAX_SEQUENCE_COMMAND_LENGTH }), {\n minItems: 1,\n maxItems: MAX_SEQUENCE_COMMANDS,\n description:\n 'Bash commands to run in order, stopping on first failure. Use a one-item array for one command. Each command gets a full log; return all log paths and only the last executed result. Do not pipe or redirect output to a file, head, or tail. Logs are saved automatically and returned output is bounded.',\n }),\n timeout: Type.Optional(\n Type.Number({ description: 'Timeout in seconds for each command (optional, no default timeout)' }),\n ),\n background: Type.Optional(\n Type.Boolean({\n description:\n 'Start the command sequence in the background immediately. 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 only the last command under a pseudo terminal when it needs input. Earlier commands are noninteractive. Send input to the last stage runner ID with runner.input in code mode, or po-runner input without code mode.',\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 { RunnerSettings } from '../../types/runnerSettings';\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 private readonly settings?: RunnerSettings,\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 = await this.logFile.open(request.id);\n const terminal = new HeadlessTerminal({ cols, rows, scrollback: SCROLLBACK, allowProposedApi: true });\n const memoryLimit = getResultMaxBytes(this.environment, this.settings) * 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 let rejectCompletion: (error: unknown) => void = () => undefined;\n const completion = new Promise<ExitResult>((resolve, reject) => {\n resolveCompletion = resolve;\n rejectCompletion = reject;\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 void writer.append(scrubbed).catch((error: unknown) => process.emitWarning(String(error)));\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(async (result) => {\n if (settled) return;\n settled = true;\n this.runs.delete(request.name);\n try {\n await writer.close();\n resolveCompletion({ code: result.exitCode, signal: null });\n } catch (error) {\n rejectCompletion(error);\n }\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: async () => 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/promises';\nimport path from 'node:path';\nimport { promisify } from 'node:util';\n\nimport {\n createProcessRegistryService,\n normalizeRepositoryPath,\n type ProcessRegistryRecord,\n type ProcessRegistryService,\n resolveProcessTags,\n} from '@agimon-ai/foundation-process-registry';\nimport type { PoTelemetry } from '@agimon-ai/po-core/telemetry';\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';\nimport { resolveRootSessionId } from '../session';\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// Server plugin 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 private recordWrites: Promise<void> = Promise.resolve();\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 /** Records each runner once, here where it completes, instead of in every command's host process. */\n private readonly telemetry?: Pick<PoTelemetry, 'recordEvent'>,\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: await 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 await 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: await this.repositoryPath(),\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 await this.paths.ensureDirectories(sessionId);\n const records: RunnerRecord[] = [];\n const directory = this.paths.stateDirectory(sessionId);\n for (const entry of await fs.readdir(directory)) {\n if (!isPrimaryMetadataEntry(entry)) continue;\n try {\n const parsed: unknown = JSON.parse(await fs.readFile(`${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(await fs.readFile(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 await 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 await 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 await this.writeRecord(completed);\n await this.release(id, completed.sessionId);\n await this.recordFinished(completed);\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: await 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 async recordFinished(record: RunnerRecord): Promise<void> {\n const { exit } = record;\n if (!this.telemetry || !exit) return;\n try {\n await this.telemetry.recordEvent('po_runner.process_finished', {\n outcome: exit.reason,\n 'runner.backend': record.backend,\n ...(exit.code === null ? {} : { 'runner.exit_code': exit.code }),\n ...(exit.signal ? { 'runner.signal': exit.signal } : {}),\n ...(exit.terminationReason ? { 'runner.termination_reason': exit.terminationReason } : {}),\n duration_ms: Date.parse(exit.finishedAt) - Date.parse(record.startedAt),\n });\n } catch (error) {\n process.emitWarning(`Could not record runner telemetry: ${String(error)}`);\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 async repositoryPath(): Promise<string> {\n return normalizeRepositoryPath(await this.paths.repositoryPath());\n }\n\n private writeRecord(record: RunnerRecord): Promise<void> {\n const write = this.recordWrites.then(async () => {\n await this.paths.ensureDirectories(record.sessionId);\n const target = this.paths.statePathFor(record.id, record.sessionId);\n const temporary = `${target}.${process.pid}.tmp`;\n await fs.writeFile(temporary, `${JSON.stringify(record, null, 2)}\\n`, 'utf8');\n await fs.rename(temporary, target);\n });\n // Keep later writes ordered even after a failed write, which its caller observes.\n this.recordWrites = write.catch(() => undefined);\n return write;\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 fs from 'node:fs/promises';\nimport path from 'node:path';\n\nimport { BACKGROUND_CONTEXT, readTextIfExists, type FileSystem } from '@agimon-ai/po-core/platform';\nimport { CONFIG_DIR_NAME } from '@earendil-works/pi-coding-agent';\n\nimport {\n MAX_ERROR_ENTRIES_CEILING,\n MAX_PATTERNS,\n MAX_PATTERN_LENGTH,\n MAX_RESULT_BYTES_CEILING,\n MAX_RESULT_LINES_CEILING,\n MAX_RESULT_TOKENS_CEILING,\n RATIO_KEYS,\n RUNNER_SETTINGS_FILE,\n} from '../../constants/runnerSettings';\nimport type { IRunnerSettingsLoader, RunnerSettings, RunnerSettingsLoad } from '../../types/runnerSettings';\n\n/** Pi's convention is one JSON file per extension under the config directory. */\n\nconst EMPTY: RunnerSettingsLoad = { settings: {}, issues: [] };\nconst COUNT_KEYS = [\n 'maxResultBytes',\n 'maxResultLines',\n 'successMaxResultBytes',\n 'maxResultTokens',\n 'successMaxResultTokens',\n 'errorMaxEntries',\n 'errorMaxVariantsJoined',\n] as const;\nconst KNOWN_KEYS = new Set<string>([...RATIO_KEYS, ...COUNT_KEYS, 'errorPatterns']);\n/** Ceilings so a project file cannot flood the model's context. */\nconst CEILINGS: Readonly<Record<string, number>> = {\n maxResultBytes: MAX_RESULT_BYTES_CEILING,\n successMaxResultBytes: MAX_RESULT_BYTES_CEILING,\n maxResultTokens: MAX_RESULT_TOKENS_CEILING,\n successMaxResultTokens: MAX_RESULT_TOKENS_CEILING,\n maxResultLines: MAX_RESULT_LINES_CEILING,\n errorMaxEntries: MAX_ERROR_ENTRIES_CEILING,\n errorMaxVariantsJoined: MAX_ERROR_ENTRIES_CEILING,\n};\n\nexport class RunnerSettingsLoader implements IRunnerSettingsLoader {\n constructor(private readonly files?: FileSystem) {}\n\n async load(cwd: string, trusted: boolean): Promise<RunnerSettingsLoad> {\n // Project-local configuration is untrusted input until the project is.\n if (!trusted) return EMPTY;\n\n let raw: string;\n try {\n const target = path.join(cwd, CONFIG_DIR_NAME, RUNNER_SETTINGS_FILE);\n const text = this.files\n ? await readTextIfExists(this.files, target, BACKGROUND_CONTEXT)\n : await fs.readFile(target, 'utf8');\n if (text === undefined) return EMPTY;\n raw = text;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') return EMPTY;\n return { settings: {}, issues: [`${RUNNER_SETTINGS_FILE} could not be read`] };\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n return { settings: {}, issues: [`${RUNNER_SETTINGS_FILE} is not valid JSON`] };\n }\n return validate(parsed);\n }\n}\n\nfunction validate(parsed: unknown): RunnerSettingsLoad {\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n return { settings: {}, issues: [`${RUNNER_SETTINGS_FILE} must contain a JSON object`] };\n }\n\n const source = parsed as Record<string, unknown>;\n const settings: Record<string, unknown> = {};\n const issues: string[] = [];\n // A rejected key is reported rather than dropped: a silent typo reads as a\n // setting that simply does not work.\n for (const key of Object.keys(source)) if (!KNOWN_KEYS.has(key)) issues.push(`unknown key ${key}`);\n\n for (const key of COUNT_KEYS) {\n const value = source[key];\n if (value === undefined) continue;\n if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {\n issues.push(`${key} must be a positive integer`);\n continue;\n }\n settings[key] = Math.min(value, CEILINGS[key] ?? value);\n }\n\n for (const key of RATIO_KEYS) {\n const value = source[key];\n if (value === undefined) continue;\n if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value >= 1) {\n issues.push(`${key} must be a number above 0 and below 1`);\n continue;\n }\n settings[key] = value;\n }\n\n const patterns = source.errorPatterns;\n if (patterns !== undefined) {\n if (!Array.isArray(patterns) || patterns.some((entry) => typeof entry !== 'string')) {\n issues.push('errorPatterns must be an array of strings');\n } else {\n const accepted: string[] = [];\n for (const pattern of patterns.slice(0, MAX_PATTERNS) as string[]) {\n if (pattern.length > MAX_PATTERN_LENGTH) {\n issues.push('errorPatterns entry is too long');\n continue;\n }\n try {\n new RegExp(pattern, 'iu');\n accepted.push(pattern);\n } catch {\n issues.push(`errorPatterns entry is not a valid regular expression: ${pattern}`);\n }\n }\n if (accepted.length > 0) settings.errorPatterns = accepted;\n }\n }\n\n return { settings: settings as RunnerSettings, issues };\n}\n","import { PO_CODEMODE_SERVICE, readPoCodemode } from '@agimon-ai/po-core/codemode';\nimport { readPoFs, PO_SESSION_TEMP_DIR } from '@agimon-ai/po-core/platform';\nimport type { PoServerPluginContext } from '@agimon-ai/po-core/serverPlugin';\nimport { createPoTelemetry } from '@agimon-ai/po-core/telemetry';\nimport type { Context } from '@deepseek-ai/cordis';\n\nimport { REGISTRY_PATH_ENV } from '../../../../../constants/runnerRegistry';\nimport {\n HISTORY_SWEEP_INTERVAL_MS,\n RUNNER_FINISHED_MESSAGE,\n RUNNER_STATUS_POLL_MS,\n} from '../../../../../constants/runnerRuntime';\nimport { RUNNER_SETTINGS_FILE } from '../../../../../constants/runnerSettings';\nimport { SystemClock } from '../../../../../services/clock';\nimport { createHeadlessBashTool, createHeadlessRunnersCommand } from '../../../../../services/headless';\nimport { LifelineObserver } from '../../../../../services/lifeline';\nimport { summarizeLog } from '../../../../../services/logReader';\nimport { createRunnerDependencies } from '../../../../../services/runnerDependencies';\nimport { createRunnerHost } from '../../../../../services/runnerHost';\nimport { createRunnerNamespace } from '../../../../../services/runnerNamespace';\nimport { createRunnerOperations } from '../../../../../services/runnerOperations';\nimport { createDefaultProcessRegistry } from '../../../../../services/runnerRegistry';\nimport { createRunnerSequenceService } from '../../../../../services/runnerSequence';\nimport { createRunnerSequenceStore } from '../../../../../services/runnerSequenceStore';\nimport { createRunnerServerRuntime } from '../../../../../services/runnerServerRuntime';\nimport { RunnerSettingsLoader } from '../../../../../services/runnerSettings';\n\nexport const RUNNER_SERVER_SCOPE_SERVICE = 'po/runner-server-scope';\n\ndeclare module '@deepseek-ai/cordis' {\n interface Context {\n 'po/runner-server-scope': RunnerServerScope;\n }\n}\n\nexport const createRunnerServerRoot = async ({ context, host, agent, signal, resources }: PoServerPluginContext) => {\n if (!host.context.directEvents) throw new Error('Runner server plugin requires the session direct event bus.');\n if (!host.context.cwd || !host.context.environment) {\n throw new Error('Runner server plugin requires session cwd and environment.');\n }\n // Inherit the current headless Pi bridge's isProjectTrusted: () => true.\n // This preserves runner configuration parity, not a new general trust policy.\n const fs = readPoFs(context);\n const loaded = await new RunnerSettingsLoader(fs).load(host.context.cwd, true);\n const settings = Object.freeze({\n ...loaded.settings,\n ...(loaded.settings.errorPatterns ? { errorPatterns: Object.freeze([...loaded.settings.errorPatterns]) } : {}),\n });\n for (const issue of loaded.issues) process.emitWarning(`${RUNNER_SETTINGS_FILE}: ${issue}`);\n const environment = host.context.environment;\n if (!environment[PO_SESSION_TEMP_DIR])\n throw new Error('Runner requires the host-admitted session temporary directory.');\n // One per registry for this build in the hub: every session shares the connection and the loop that\n // keeps house for it, and the last session to close disposes them.\n const runnerHost = await resources.acquire(\n ['runner-host', environment[REGISTRY_PATH_ENV] ?? null],\n () =>\n createRunnerHost({\n processRegistry: createDefaultProcessRegistry(environment),\n clock: new SystemClock(),\n statusPollMs: RUNNER_STATUS_POLL_MS,\n historySweepMs: HISTORY_SWEEP_INTERVAL_MS,\n onWarning: (message) => process.emitWarning(message),\n }),\n (shared) => shared.dispose(),\n );\n // One observer process cleans up after every session of the registry, rather than one each.\n const lifelineObserver = await resources.acquire(\n ['runner-lifeline-observer', environment[REGISTRY_PATH_ENV] ?? null],\n () => new LifelineObserver(environment),\n (shared) => shared.close(),\n );\n const execution = agent?.context;\n const telemetry = execution\n ? createPoTelemetry({\n serviceName: 'po-runner',\n packageName: '@agimon-ai/po-runner',\n cwd: execution.cwd,\n env: execution.environment,\n enableLogs: true,\n enableTraces: true,\n })\n : undefined;\n const dependencies = createRunnerDependencies({\n settings,\n cwd: host.context.cwd,\n environment,\n processRegistry: runnerHost.processRegistry,\n lifelineObserver,\n ...(telemetry === undefined ? {} : { telemetry }),\n ...(host.context.executionBudget === undefined ? {} : { executionBudget: host.context.executionBudget }),\n });\n const wakeAgent =\n execution &&\n (async (content: string, requestId: string, notice: string): Promise<void> => {\n try {\n // 'steer' joins a running turn and starts one on an idle session.\n // Reuse completion identity across retries and acknowledge admission, not settlement.\n // A custom message, not a prompt: the model reads it as input, while the\n // transcript shows the notice as an event rather than as the person's words.\n if (!execution.session.admitMessage) throw new Error('The session cannot admit a runner notification.');\n await execution.session.admitMessage(\n { type: RUNNER_FINISHED_MESSAGE, text: content, display: true, details: { notice } },\n 'steer',\n requestId,\n );\n } catch (error) {\n // Keep completion ownership with the watcher until admission succeeds.\n await Promise.resolve()\n .then(() => execution.client.notify({ body: String(error), level: 'warning' }))\n .catch(() => undefined);\n throw error;\n }\n });\n const runtime = createRunnerServerRuntime(\n dependencies,\n runnerHost,\n host.context.directEvents,\n wakeAgent,\n execution,\n telemetry,\n );\n const sequences =\n fs && execution\n ? createRunnerSequenceService({\n dependencies,\n store: createRunnerSequenceStore(fs, dependencies.paths, execution.sessionId),\n sessionId: execution.sessionId,\n signal,\n summarizeLog: (path, lines, bytes, tokens) => summarizeLog(path, lines, bytes, tokens, settings),\n onChange: () => runtime.sequenceChanged(),\n onWarning: (message) => process.emitWarning(message),\n onComplete: async (record) => {\n if (!record.result) return;\n await wakeAgent?.(\n record.result.content.flatMap((part) => (part.type === 'text' ? [part.text] : [])).join('\\n'),\n `runner-sequence-finished:${JSON.stringify([record.sessionId, record.id])}`,\n `Command sequence ${record.name} finished: ${record.outcome ?? record.state}`,\n );\n },\n })\n : undefined;\n if (sequences) runtime.setSequences(sequences);\n const bash = (wakeOnExit: boolean) =>\n createHeadlessBashTool(\n sequences\n ? async (params, context, toolCallId, callSignal, onUpdate) => {\n await runtime.ensureSession(context.sessionId);\n return sequences.run(params, context, toolCallId, callSignal, onUpdate, wakeOnExit);\n }\n : undefined,\n dependencies.environment,\n );\n const value = {\n sequences,\n tool: bash(true),\n // Remote MCP runners must not wake the local agent; the remote caller polls instead.\n remoteTool: bash(false),\n command: createHeadlessRunnersCommand(dependencies, {\n // Without the platform fs a missing folder still fails, only at spawn and less plainly.\n fs,\n sequences,\n onStart: (startedSessionId, failure) => runtime.reportStart(startedSessionId, failure),\n }),\n recoverCompaction: runtime.recoverCompaction,\n };\n let controlsDisposed = false;\n if (fs && execution) {\n const namespace = createRunnerNamespace({\n sessionId: execution.sessionId,\n operations: createRunnerOperations({\n ...dependencies,\n registry: dependencies.runnerRegistry,\n fs,\n sessionId: execution.sessionId,\n }),\n sequences,\n assertAvailable: () => {\n signal?.throwIfAborted();\n if (controlsDisposed) throw new Error('Runner session is disposed.');\n },\n });\n context.inject([PO_CODEMODE_SERVICE], (scoped) => {\n const registration = readPoCodemode(scoped)?.register('@agimon-ai/po-runner', namespace);\n return () => registration?.dispose();\n });\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 // Package install is on the session-create path; bash awaits supervision itself.\n onStart: execution\n ? () => {\n void runtime\n .ensureSession(execution.sessionId)\n .catch((error: unknown) =>\n process.emitWarning(`Could not initialize headless runner supervision: ${String(error)}`),\n );\n }\n : undefined,\n onStop: async () => {\n controlsDisposed = true;\n await runtime.dispose();\n },\n };\n};\n\nexport type RunnerServerScope = Awaited<ReturnType<typeof createRunnerServerRoot>>['value'];\n","import { defineMcpTool, type PoMcpPluginContext } from '@agimon-ai/po-core/mcpFacet';\n\nimport { RUNNER_SERVER_SCOPE_SERVICE, type RunnerServerScope } from '../_lib/serverRoot';\n\nconst pollHint = (id: string): string =>\n `You will not be notified when this runner ends. Check it with \\`po-runner status ${id}\\` and read output with \\`po-runner logs ${id} --lines 100\\`, both through this tool, until State is completed.`;\n\nexport default defineMcpTool((context: PoMcpPluginContext) => {\n const scope = context.services.get<RunnerServerScope>(RUNNER_SERVER_SCOPE_SERVICE);\n if (!scope) throw new Error('Runner session service is unavailable.');\n return {\n ...scope.remoteTool,\n description:\n \"Run an array of shell commands (bash, terminal, CLI) in the Po session's repository on the host machine, not on your local machine. Use a one-item array for one command. Commands run in order and stop on first failure. Only the last command may be interactive. Do not pipe or redirect output to a file, head, or tail: full logs are saved automatically and returned output is bounded. Long-running commands return a sequence id. No completion notice reaches you, so check with `po-runner status <id>` and `po-runner logs <id> --lines 100` instead of relaunching.\",\n async execute(toolCallId, parameters, signal, onUpdate, execution) {\n const result = await scope.remoteTool.execute(\n toolCallId,\n parameters,\n AbortSignal.any([context.signal, signal ?? context.signal]),\n onUpdate,\n execution,\n );\n const details = result.details as { promoted?: unknown; id?: unknown } | undefined;\n if (details?.promoted !== true || typeof details.id !== 'string') return result;\n return { ...result, content: [...result.content, { type: 'text', text: pollHint(details.id) }] };\n },\n };\n});\n","// Generated by @agimon-ai/po-build. Do not edit by hand.\nimport { defineMcpPlugin, type PoMcpSessionPlugin } from '@agimon-ai/po-core/mcpFacet';\n\nimport skillHelpHowToRunner from '../src/extensions/workspaces/sessions/(backend)/skill/help-how-to-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, [\"po/widget\"]: key },\n});\n\nexport const mcp = defineMcpPlugin({\n name: '@agimon-ai/po-runner',\n session: (context) => ({\n get skills(): PoMcpSessionPlugin['skills'] { return [at(skillHelpHowToRunner, context)]; },\n get tools(): PoMcpSessionPlugin['tools'] { return [withWidget(\"@agimon-ai/po-runner/bash\", via({ name: 'bash' }, at(toolBash, context)))]; },\n }) satisfies PoMcpSessionPlugin,\n});\n\nexport default mcp;\n"],"mappings":"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"}
|
package/dist/extensions/mcp.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { defineMcpPlugin, defineMcpSkill, defineMcpTool } from "@agimon-ai/po-co
|
|
|
2
2
|
import { readPackageResource } from "@agimon-ai/po-core/serverPlugin";
|
|
3
3
|
import "@agimon-ai/po-core/codemode";
|
|
4
4
|
import "@agimon-ai/po-core/platform";
|
|
5
|
+
import "@agimon-ai/po-core/telemetry";
|
|
5
6
|
import { Type } from "typebox";
|
|
6
7
|
import { execFile } from "node:child_process";
|
|
7
8
|
import "@agimon-ai/po-core/packageApi";
|
|
@@ -13,7 +14,6 @@ import "typebox/value";
|
|
|
13
14
|
import "@agimon-ai/po-core/backgroundWork";
|
|
14
15
|
import "@agimon-ai/po-core/childProcess";
|
|
15
16
|
import "@agimon-ai/po-core/extensionFile";
|
|
16
|
-
import "@agimon-ai/po-core/telemetry";
|
|
17
17
|
import "@earendil-works/pi-coding-agent";
|
|
18
18
|
//#region src/extensions/workspaces/sessions/(backend)/skill/help-how-to-runner.mcp.ts
|
|
19
19
|
var help_how_to_runner_mcp_default = defineMcpSkill({
|