@kal-elsam/kairo-runtime 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -261,7 +261,7 @@ node ./bin/kairo.js install --dry-run
261
261
 
262
262
  ## Supported adapters (agent-global)
263
263
 
264
- Kairo Runtime does **not** install Cursor, Codex, OpenCode, or Claude Code. It detects
264
+ Kairo Runtime does **not** install Cursor, Codex, OpenCode, Claude Code, or Pi. It detects
265
265
  their home-directory roots and writes managed sections into their config files.
266
266
 
267
267
  | Adapter | Label | Root | Config file |
@@ -270,6 +270,7 @@ their home-directory roots and writes managed sections into their config files.
270
270
  | `codex` | Codex | `~/.codex` | `~/.codex/AGENTS.md` |
271
271
  | `opencode` | OpenCode | `~/.config/opencode` | `~/.config/opencode/AGENTS.md` |
272
272
  | `claude` | Claude Code | `~/.claude` | `~/.claude/CLAUDE.md` |
273
+ | `pi` | Pi | `~/.pi/agent` | `~/.pi/agent/AGENTS.md` |
273
274
 
274
275
  Inspect detection and managed state:
275
276
 
@@ -281,14 +282,25 @@ kairo adapters --json
281
282
  Agent selection defaults:
282
283
 
283
284
  - If agent roots are detected → configure detected agents only.
284
- - If none are detected → safe fallback to all four supported adapters.
285
- - Force all four explicitly:
285
+ - If none are detected → safe fallback to all five supported adapters.
286
+ - Force all five explicitly:
286
287
 
287
288
  ```bash
288
289
  kairo setup --agents all
289
290
  kairo install --agents all
290
291
  ```
291
292
 
293
+ ### Pi runtime (auditable)
294
+
295
+ ```bash
296
+ kairo run --agent pi --task "Review this repository" --permissions read-only --follow
297
+ ```
298
+
299
+ Launches `pi --mode json --no-session` (optional `--model`). `read-only` maps to
300
+ `--tools read,grep,find,ls`; other permission aliases are rejected (never translated
301
+ to `--approve`). Custom `PI_CODING_AGENT_DIR` blocks config writes in 0.6.0 but does
302
+ not block runtime. Kairo does not install Pi or assert subscription/entitlement.
303
+
292
304
  Primary governance flow:
293
305
 
294
306
  ```txt
@@ -518,9 +530,11 @@ kairo components rollback engram-memory --receipt <id> --dry-run
518
530
 
519
531
  Without `--agents`, Kairo uses the intersection of detected agents and
520
532
  Kairo-managed Engram agents (`cursor`, `codex`, `opencode`, `claude` → setup slug
521
- `claude-code`). After setup, status is `restart_required` — restart the agent to
533
+ `claude-code`, `pi` → `pi`). After setup, status is `restart_required` — restart the agent to
522
534
  load MCP; configuration evidence is not runtime-active. Receipts live under
523
- `~/.harness/integrations/engram/`.
535
+ `~/.harness/integrations/engram/`. For Pi, positive evidence requires
536
+ `~/.pi/agent/settings.json` packages (`npm:gentle-engram`, `npm:pi-mcp-adapter`) and
537
+ `~/.pi/agent/mcp.json` with `mcpServers.engram`.
524
538
 
525
539
  ### SDD Core skills
526
540
 
@@ -537,7 +551,7 @@ same `sdd-core` apply path. Lifecycle keeps persona frozen (`preservePersona`):
537
551
  it never auto-activates teaching. Explicit persona changes stay on
538
552
  `kairo components configure sdd-core`.
539
553
 
540
- **Destinations.** Cursor, Codex, and OpenCode share `~/.agents/skills/<id>/`.
554
+ **Destinations.** Cursor, Codex, OpenCode, and Pi share `~/.agents/skills/<id>/`.
541
555
  Claude uses `~/.claude/skills/<id>/`. One physical tree per root; consumers are
542
556
  recorded per destination.
543
557
 
@@ -58,7 +58,8 @@
58
58
  "cursor": "- In repos with workspace harness: also read `.cursor/rules/` and `docs/specs/`.",
59
59
  "codex": "- In repos with workspace harness: also read `.codex/skills/sdd/` when present.",
60
60
  "claude": "- In repos with workspace harness: repo `AGENTS.md` wins over `CLAUDE.md` pointers.",
61
- "opencode": "- In repos with workspace harness: also read `.opencode/` SDD assets when present."
61
+ "opencode": "- In repos with workspace harness: also read `.opencode/` SDD assets when present.",
62
+ "pi": "- Skills load from `~/.agents/skills` (shared Agent Skills root). Teaching persona activates only via the managed section of `~/.pi/agent/AGENTS.md`."
62
63
  }
63
64
  },
64
65
  {
@@ -80,7 +81,8 @@
80
81
  "cursor": "- Configure Engram via `kairo components configure engram-memory` (official `engram setup cursor`).",
81
82
  "codex": "- Configure Engram via `kairo components configure engram-memory` (official `engram setup codex`).",
82
83
  "claude": "- Configure Engram via `kairo components configure engram-memory` (official `engram setup claude-code`).",
83
- "opencode": "- Configure Engram via `kairo components configure engram-memory` (official `engram setup opencode`)."
84
+ "opencode": "- Configure Engram via `kairo components configure engram-memory` (official `engram setup opencode`).",
85
+ "pi": "- Configure Engram via `kairo components configure engram-memory` (official `engram setup pi`). Evidence: `~/.pi/agent/settings.json` packages + `mcp.json` engram server. Restart Pi after setup; Kairo never runs `engram doctor`/`serve` or touches `engram.db`."
84
86
  }
85
87
  },
86
88
  {
@@ -5,7 +5,7 @@
5
5
  ## Role
6
6
 
7
7
  The Harness Orchestrator (conductor) coordinates work across local coding agents
8
- (Cursor, Codex, OpenCode, Claude). It does not replace any agent. It defines how
8
+ (Cursor, Codex, OpenCode, Claude, Pi). It does not replace any agent. It defines how
9
9
  agents hand off work and which source of truth governs.
10
10
 
11
11
  ## Authority order
@@ -5,7 +5,7 @@
5
5
  ## Role
6
6
 
7
7
  The Harness Orchestrator (conductor) coordinates work across local coding agents
8
- (Cursor, Codex, OpenCode, Claude). It does not replace any agent. It defines how
8
+ (Cursor, Codex, OpenCode, Claude, Pi). It does not replace any agent. It defines how
9
9
  agents hand off work and which source of truth governs.
10
10
 
11
11
  ## Authority order
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kal-elsam/kairo-runtime",
3
- "version": "0.5.0",
4
- "description": "Kairo Runtime — local agent operating system for Codex, Cursor, Claude, Gemini, Copilot, Engram, and Graphify.",
3
+ "version": "0.6.0",
4
+ "description": "Kairo Runtime — local agent operating system for Codex, Cursor, Claude, Pi, Engram, and Graphify.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Kal-elSam/harness#readme",
7
7
  "repository": {
@@ -45,6 +45,7 @@
45
45
  "agents",
46
46
  "codex",
47
47
  "cursor",
48
+ "pi",
48
49
  "kairo",
49
50
  "kairo-runtime",
50
51
  "harness",
@@ -26,7 +26,7 @@ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
26
26
  const require = createRequire(import.meta.url);
27
27
  const pkg = require(join(root, "package.json"));
28
28
 
29
- assert.equal(pkg.version, "0.5.0");
29
+ assert.equal(pkg.version, "0.6.0");
30
30
  assert.ok(pkg.dependencies["ansi-escapes"]);
31
31
 
32
32
  assert.equal(resolveLayoutMode({ columns: 120, rows: 40 }), LAYOUT_MODES.WIDE);
@@ -4,7 +4,7 @@
4
4
  # Safe by design:
5
5
  # - no sudo
6
6
  # - no shell profile changes
7
- # - does not install Cursor/Codex/OpenCode/Claude
7
+ # - does not install Cursor/Codex/OpenCode/Claude/Pi
8
8
  # - default ends with setup --dry-run (no writes)
9
9
  # - explicit apply: pass --yes to run setup --yes
10
10
  #
@@ -88,9 +88,9 @@ if (!payload.ok || payload.overall !== 'ok') {
88
88
  process.exit(1);
89
89
  }
90
90
  const managed = payload.agents.filter((agent) => agent.managed).map((agent) => agent.id).sort();
91
- const expected = ['claude', 'codex', 'cursor', 'opencode'];
91
+ const expected = ['claude', 'codex', 'cursor', 'opencode', 'pi'];
92
92
  if (JSON.stringify(managed) !== JSON.stringify(expected)) {
93
- console.error('Expected all four agents managed');
93
+ console.error('Expected all five agents managed');
94
94
  process.exit(1);
95
95
  }
96
96
  " "$status_json"
@@ -105,7 +105,8 @@ const targets = [
105
105
  '.cursor/AGENTS.md',
106
106
  '.codex/AGENTS.md',
107
107
  '.config/opencode/AGENTS.md',
108
- '.claude/CLAUDE.md'
108
+ '.claude/CLAUDE.md',
109
+ '.pi/agent/AGENTS.md'
109
110
  ];
110
111
 
111
112
  for (const relativePath of targets) {
@@ -143,7 +144,8 @@ mkdir -p \
143
144
  "$FAKE_HOME/.cursor" \
144
145
  "$FAKE_HOME/.codex" \
145
146
  "$FAKE_HOME/.config/opencode" \
146
- "$FAKE_HOME/.claude"
147
+ "$FAKE_HOME/.claude" \
148
+ "$FAKE_HOME/.pi/agent"
147
149
 
148
150
  echo
149
151
  echo "== curl install.sh | sh --version ${VERSION} --yes --agents all =="
package/src/cli.js CHANGED
@@ -732,7 +732,7 @@ function printHelp() {
732
732
  console.log(`${BRAND.displayName} (${PACKAGE_NAME})
733
733
 
734
734
  ${BRAND.tagline}. ${BRAND.displayName} does not install AI apps — it powers and
735
- coordinates agents you already have (Cursor, Codex, OpenCode, Claude) with managed
735
+ coordinates agents you already have (Cursor, Codex, OpenCode, Claude, Pi) with managed
736
736
  sections, components, backups, and drift repair under ~/.harness.
737
737
 
738
738
  Bootstrap: see README.md (curl install.sh or npx ${PACKAGE_NAME}).
@@ -0,0 +1,53 @@
1
+ import { resolve } from "node:path";
2
+ import { createManagedConfigAdapter } from "../managed-config-adapter.js";
3
+
4
+ export const PI_DEFAULT_ROOT_DIR = ".pi/agent";
5
+ export const PI_DEFAULT_CONFIG_FILE = ".pi/agent/AGENTS.md";
6
+ export const PI_CODING_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
7
+
8
+ const CUSTOM_DIR_MESSAGE =
9
+ `Pi config writes are unsupported when ${PI_CODING_AGENT_DIR_ENV} points away from `
10
+ + `~/${PI_DEFAULT_ROOT_DIR} (out of scope for Kairo 0.6.0). `
11
+ + `Unset ${PI_CODING_AGENT_DIR_ENV} to manage the default directory; runtime launches remain available.`;
12
+
13
+ /** True when PI_CODING_AGENT_DIR is set to a non-default absolute/relative path. */
14
+ export function isCustomPiCodingAgentDir(env = process.env, homeDir) {
15
+ const raw = env?.[PI_CODING_AGENT_DIR_ENV];
16
+ if (raw == null || String(raw).trim() === "") return false;
17
+ if (homeDir == null) return true;
18
+ return resolve(String(raw).trim()) !== resolve(homeDir, PI_DEFAULT_ROOT_DIR);
19
+ }
20
+
21
+ export function assertDefaultPiConfigDir(context = {}) {
22
+ const env = context.env ?? process.env;
23
+ if (isCustomPiCodingAgentDir(env, context.homeDir)) {
24
+ throw new Error(CUSTOM_DIR_MESSAGE);
25
+ }
26
+ }
27
+
28
+ const managed = createManagedConfigAdapter({
29
+ id: "pi",
30
+ label: "Pi",
31
+ rootDir: PI_DEFAULT_ROOT_DIR,
32
+ configFile: PI_DEFAULT_CONFIG_FILE
33
+ });
34
+
35
+ /** Managed adapter for ~/.pi/agent/AGENTS.md; custom PI_CODING_AGENT_DIR fails before writes. */
36
+ export default {
37
+ ...managed,
38
+
39
+ plan(context) {
40
+ assertDefaultPiConfigDir(context);
41
+ return managed.plan(context);
42
+ },
43
+
44
+ async apply(context, plan) {
45
+ assertDefaultPiConfigDir(context);
46
+ return managed.apply(context, plan);
47
+ },
48
+
49
+ async uninstall(context, stateEntry) {
50
+ assertDefaultPiConfigDir(context);
51
+ return managed.uninstall(context, stateEntry);
52
+ }
53
+ };
@@ -2,6 +2,7 @@ import cursorManaged from "../adapters/cursor.js";
2
2
  import codexManaged from "../adapters/codex.js";
3
3
  import opencodeManaged from "../adapters/opencode.js";
4
4
  import claudeManaged from "../adapters/claude.js";
5
+ import piManaged from "../adapters/pi.js";
5
6
  import { createAgentCapabilityAdapter } from "./create-capability-adapter.js";
6
7
 
7
8
  const cursor = createAgentCapabilityAdapter({
@@ -37,7 +38,16 @@ const claude = createAgentCapabilityAdapter({
37
38
  runExecutable: "claude"
38
39
  });
39
40
 
40
- const CAPABILITY_ADAPTERS = [cursor, codex, opencode, claude];
41
+ const pi = createAgentCapabilityAdapter({
42
+ id: "pi",
43
+ label: "Pi",
44
+ managedAdapter: piManaged,
45
+ executable: "pi",
46
+ opaqueAuth: true,
47
+ runExecutable: "pi"
48
+ });
49
+
50
+ const CAPABILITY_ADAPTERS = [cursor, codex, opencode, claude, pi];
41
51
 
42
52
  export const AGENT_CAPABILITY_IDS = CAPABILITY_ADAPTERS.map((adapter) => adapter.id);
43
53
 
@@ -30,14 +30,16 @@ export const AGENT_LABELS = {
30
30
  cursor: "Cursor",
31
31
  codex: "Codex",
32
32
  opencode: "OpenCode",
33
- claude: "Claude Code"
33
+ claude: "Claude Code",
34
+ pi: "Pi"
34
35
  };
35
36
 
36
37
  export const AGENT_SYMBOLS = {
37
38
  cursor: "◆",
38
39
  codex: "◆",
39
40
  opencode: "◆",
40
- claude: "◆"
41
+ claude: "◆",
42
+ pi: "◆"
41
43
  };
42
44
 
43
45
  export const TOKENS = {
@@ -150,7 +150,7 @@ export function resolveDashboardRecommendation({
150
150
  kind: NEXT_STEP_KINDS.LAUNCH,
151
151
  title: "Create a new run",
152
152
  message: "Create a new run",
153
- detail: "Delegate a task to Cursor, Codex or Claude.",
153
+ detail: "Delegate a task to Cursor, Codex, Claude, or Pi.",
154
154
  targetView: RECOMMENDATION_TARGETS.LAUNCH,
155
155
  targetAction: "launch"
156
156
  };
@@ -734,7 +734,7 @@ function printAdapterMatrixReport(report) {
734
734
  console.log(commandHeader("adapters — supported agent integrations"));
735
735
  console.log(`Home: ${report.homeDir}`);
736
736
  console.log("");
737
- console.log(`${BRAND.displayName} does not install Cursor, Codex, OpenCode, or Claude Code.`);
737
+ console.log(`${BRAND.displayName} does not install Cursor, Codex, OpenCode, Claude Code, or Pi.`);
738
738
  console.log("It configures managed sections in each agent's config files.");
739
739
  console.log("");
740
740
  console.log(`Supported: ${report.supportedCount} Detected: ${report.detectedCount} Managed: ${report.managedCount}`);
@@ -3,12 +3,13 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { probeCommand } from "../cli-probe.js";
5
5
 
6
- export const KAIRO_ENGRAM_AGENT_IDS = Object.freeze(["cursor", "codex", "opencode", "claude"]);
6
+ export const KAIRO_ENGRAM_AGENT_IDS = Object.freeze(["cursor", "codex", "opencode", "claude", "pi"]);
7
7
  export const ENGRAM_SETUP_SLUG_BY_AGENT = Object.freeze({
8
8
  cursor: "cursor",
9
9
  codex: "codex",
10
10
  opencode: "opencode",
11
- claude: "claude-code"
11
+ claude: "claude-code",
12
+ pi: "pi"
12
13
  });
13
14
  export const ENGRAM_MIN_VERSION = "1.19.0";
14
15
  export const ENGRAM_MAX_MAJOR = 2;
@@ -110,7 +111,7 @@ export function inspectEngramAgentConfig(agentId, { homeDir = homedir() } = {})
110
111
  const conflicts = evidence.filter((item) => item.conflict);
111
112
  let status = ENGRAM_INTEGRATION_STATUS.UNCONFIGURED;
112
113
  if (conflicts.length > 0) status = ENGRAM_INTEGRATION_STATUS.CONFLICT;
113
- else if (evidence.some((item) => item.present && item.kind === "mcp")) {
114
+ else if (isEngramAgentConfigured(agentId, evidence)) {
114
115
  status = ENGRAM_INTEGRATION_STATUS.CONFIGURED;
115
116
  }
116
117
  return { id: agentId, slug, status, evidence };
@@ -165,9 +166,46 @@ function collectAgentEvidence(agentId, homeDir) {
165
166
  fileEvidence(join(homeDir, ".cursor", "rules", "engram.mdc"), "protocol")
166
167
  ];
167
168
  }
169
+ if (agentId === "pi") {
170
+ return [
171
+ piSettingsPackagesEvidence(join(homeDir, ".pi", "agent", "settings.json")),
172
+ jsonKeyEvidence(join(homeDir, ".pi", "agent", "mcp.json"), ["mcpServers", "engram"], "mcp")
173
+ ];
174
+ }
168
175
  throw new Error(`Unsupported Engram agent "${agentId}".`);
169
176
  }
170
177
 
178
+ function isEngramAgentConfigured(agentId, evidence) {
179
+ if (agentId === "pi") {
180
+ return evidence.length > 0 && evidence.every((item) => item.present && !item.conflict);
181
+ }
182
+ return evidence.some((item) => item.present && item.kind === "mcp");
183
+ }
184
+
185
+ function piSettingsPackagesEvidence(path) {
186
+ const required = ["npm:gentle-engram", "npm:pi-mcp-adapter"];
187
+ if (!existsSync(path)) {
188
+ return { path, kind: "settings", present: false, conflict: false, required };
189
+ }
190
+ try {
191
+ const data = JSON.parse(readFileSync(path, "utf8"));
192
+ if (data == null || typeof data !== "object" || Array.isArray(data)) {
193
+ return { path, kind: "settings", present: false, conflict: true, required, detail: "invalid structure" };
194
+ }
195
+ const packages = Array.isArray(data.packages) ? data.packages : null;
196
+ if (packages == null) {
197
+ return { path, kind: "settings", present: false, conflict: true, required, detail: "invalid structure" };
198
+ }
199
+ const normalized = packages.map((entry) => String(entry));
200
+ const present = required.every((req) =>
201
+ normalized.some((entry) => entry === req || entry.startsWith(`${req}@`))
202
+ );
203
+ return { path, kind: "settings", present, conflict: false, required };
204
+ } catch {
205
+ return { path, kind: "settings", present: false, conflict: true, required, detail: "unreadable json" };
206
+ }
207
+ }
208
+
171
209
  function fileEvidence(path, kind) {
172
210
  return { path, kind, present: existsSync(path), conflict: false };
173
211
  }
@@ -12,8 +12,8 @@ export const SDD_SKILL_IDS = Object.freeze([
12
12
  "sdd-archive"
13
13
  ]);
14
14
 
15
- export const SDD_MANAGED_AGENT_IDS = Object.freeze(["cursor", "codex", "opencode", "claude"]);
16
- export const SDD_SHARED_AGENT_IDS = Object.freeze(["cursor", "codex", "opencode"]);
15
+ export const SDD_MANAGED_AGENT_IDS = Object.freeze(["cursor", "codex", "opencode", "claude", "pi"]);
16
+ export const SDD_SHARED_AGENT_IDS = Object.freeze(["cursor", "codex", "opencode", "pi"]);
17
17
  export const SDD_PERSONA_IDS = Object.freeze(["off", "teaching"]);
18
18
 
19
19
  export function resolveSddAgentSelection({
@@ -4,8 +4,9 @@ import claude from "./adapters/claude.js";
4
4
  import codex from "./adapters/codex.js";
5
5
  import cursor from "./adapters/cursor.js";
6
6
  import opencode from "./adapters/opencode.js";
7
+ import pi from "./adapters/pi.js";
7
8
 
8
- const ADAPTERS = [cursor, codex, opencode, claude];
9
+ const ADAPTERS = [cursor, codex, opencode, claude, pi];
9
10
 
10
11
  export const GLOBAL_AGENT_IDS = ADAPTERS.map((adapter) => adapter.id);
11
12
 
@@ -2,8 +2,9 @@ import cursor from "./cursor.js";
2
2
  import codex from "./codex.js";
3
3
  import claude from "./claude.js";
4
4
  import opencode from "./opencode.js";
5
+ import pi from "./pi.js";
5
6
 
6
- const EXECUTION_ADAPTERS = [cursor, codex, claude, opencode];
7
+ const EXECUTION_ADAPTERS = [cursor, codex, claude, opencode, pi];
7
8
 
8
9
  export const EXECUTION_ADAPTER_IDS = EXECUTION_ADAPTERS.map((adapter) => adapter.id);
9
10
 
@@ -0,0 +1,183 @@
1
+ import { createExecutionAdapter, parseNdjsonLine } from "./create-execution-adapter.js";
2
+ import { isExecutableAvailable, probeCommand } from "../../cli-probe.js";
3
+
4
+ const EXECUTABLE = "pi";
5
+ const READ_ONLY_TOOLS = "read,grep,find,ls";
6
+
7
+ const LIFECYCLE_KINDS = new Set([
8
+ "session",
9
+ "agent_start",
10
+ "agent_end",
11
+ "turn_start",
12
+ "message_start",
13
+ "message_update",
14
+ "compaction_start",
15
+ "compaction_end",
16
+ "auto_retry_start",
17
+ "auto_retry_end",
18
+ "queue_update",
19
+ "summarization_retry_scheduled",
20
+ "summarization_retry_attempt_start",
21
+ "summarization_retry_finished"
22
+ ]);
23
+
24
+ export function buildPiPermissionsArgs(permissions = []) {
25
+ if (!Array.isArray(permissions) || permissions.length === 0) return [];
26
+
27
+ const normalized = [...new Set(permissions.map((entry) => String(entry).toLowerCase()))];
28
+ if (normalized.length === 1 && normalized[0] === "read-only") {
29
+ return ["--tools", READ_ONLY_TOOLS];
30
+ }
31
+
32
+ throw new Error(
33
+ `Pi permissions only support "read-only" in Kairo 0.6.0 (got: ${permissions.join(", ")}). `
34
+ + "Other aliases are rejected and are never translated to --approve."
35
+ );
36
+ }
37
+
38
+ export function buildPiLaunch({ task, cwd, model, permissions = [], env = process.env } = {}) {
39
+ const args = [
40
+ "--mode",
41
+ "json",
42
+ "--no-session",
43
+ ...buildPiPermissionsArgs(permissions)
44
+ ];
45
+
46
+ if (model) {
47
+ args.push("--model", model);
48
+ }
49
+
50
+ args.push(task);
51
+
52
+ return {
53
+ command: EXECUTABLE,
54
+ args,
55
+ cwd,
56
+ env
57
+ };
58
+ }
59
+
60
+ export function checkPiAvailability(context = {}) {
61
+ const env = context.env ?? process.env;
62
+ const probe = context.probeImpl ?? probeCommand;
63
+ const availableCheck = context.isAvailableImpl ?? isExecutableAvailable;
64
+
65
+ if (!availableCheck(EXECUTABLE, { env })) {
66
+ return {
67
+ available: false,
68
+ compatible: false,
69
+ launchable: false,
70
+ reason: 'Pi CLI "pi" is not on PATH.'
71
+ };
72
+ }
73
+
74
+ const help = probe(EXECUTABLE, ["--help"], { env, timeoutMs: 5000 });
75
+ const text = `${help.stdout ?? ""}\n${help.stderr ?? ""}`;
76
+ const hasMode = /--mode\b/.test(text);
77
+ const hasNoSession = /--no-session\b/.test(text);
78
+
79
+ if (!help.ok || !hasMode || !hasNoSession) {
80
+ return {
81
+ available: true,
82
+ compatible: false,
83
+ launchable: false,
84
+ reason: "Pi CLI is missing required --mode / --no-session flags for auditable JSON runs."
85
+ };
86
+ }
87
+
88
+ return {
89
+ available: true,
90
+ compatible: true,
91
+ launchable: true,
92
+ reason: null
93
+ };
94
+ }
95
+
96
+ export function parsePiEventLine(line) {
97
+ const parsed = parseNdjsonLine(line);
98
+ if (!parsed || typeof parsed !== "object") return null;
99
+
100
+ if (parsed.type === "tool_execution_start") {
101
+ return {
102
+ type: "tool_call",
103
+ tool_name: parsed.toolName ?? "unknown",
104
+ status: "started",
105
+ id: parsed.toolCallId ?? null
106
+ };
107
+ }
108
+
109
+ if (parsed.type === "tool_execution_end") {
110
+ return {
111
+ type: "tool_result",
112
+ tool_name: parsed.toolName ?? "unknown",
113
+ status: parsed.isError ? "error" : "completed",
114
+ id: parsed.toolCallId ?? null
115
+ };
116
+ }
117
+
118
+ if (parsed.type === "tool_execution_update") {
119
+ return null;
120
+ }
121
+
122
+ if (parsed.type === "message_end") {
123
+ if (parsed.message?.role !== "assistant") return null;
124
+ const content = Array.isArray(parsed.message.content)
125
+ ? parsed.message.content
126
+ .filter((block) => block?.type === "text")
127
+ .map((block) => block.text ?? "")
128
+ .join("")
129
+ : "";
130
+ return {
131
+ type: "assistant",
132
+ role: "assistant",
133
+ content
134
+ };
135
+ }
136
+
137
+ if (parsed.type === "turn_end") {
138
+ const usage = parsed.message?.usage;
139
+ if (usage && typeof usage === "object") {
140
+ const cost = typeof usage.cost === "number"
141
+ ? usage.cost
142
+ : typeof usage.cost?.total === "number" ? usage.cost.total : null;
143
+ return {
144
+ type: "usage",
145
+ inputTokens: usage.input ?? usage.inputTokens ?? usage.input_tokens ?? null,
146
+ outputTokens: usage.output ?? usage.outputTokens ?? usage.output_tokens ?? null,
147
+ totalTokens: usage.totalTokens ?? usage.total ?? usage.total_tokens ?? null,
148
+ cost
149
+ };
150
+ }
151
+ return { type: "system", kind: "turn_end" };
152
+ }
153
+
154
+ if (LIFECYCLE_KINDS.has(parsed.type)) {
155
+ return {
156
+ type: "system",
157
+ kind: parsed.type,
158
+ ...(parsed.reason ? { reason: parsed.reason } : {}),
159
+ ...(parsed.attempt != null ? { attempt: parsed.attempt } : {})
160
+ };
161
+ }
162
+
163
+ return {
164
+ type: "system",
165
+ kind: parsed.type ?? "unknown"
166
+ };
167
+ }
168
+
169
+ export default createExecutionAdapter({
170
+ id: "pi",
171
+ label: "Pi",
172
+ executable: EXECUTABLE,
173
+ capabilities: {
174
+ structuredEvents: true,
175
+ tokens: true,
176
+ diff: false,
177
+ cancel: true,
178
+ transcript: true
179
+ },
180
+ checkAvailability: checkPiAvailability,
181
+ buildLaunch: buildPiLaunch,
182
+ parseEventLine: parsePiEventLine
183
+ });
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile } from "node:fs/promises";
3
3
  import { runPaths } from "../paths.js";
4
+ import { writeAtomicJson } from "./write-atomic-json.js";
4
5
 
5
6
  function cancelSignalPath(homeDir, runId) {
6
7
  return `${runPaths(homeDir, runId).runDir}/cancel.signal.json`;
@@ -9,7 +10,7 @@ function cancelSignalPath(homeDir, runId) {
9
10
  export async function writeCancelSignal(homeDir, runId, payload) {
10
11
  const { runDir } = runPaths(homeDir, runId);
11
12
  await mkdir(runDir, { recursive: true });
12
- await writeFile(cancelSignalPath(homeDir, runId), `${JSON.stringify(payload, null, 2)}\n`, "utf8");
13
+ await writeAtomicJson(cancelSignalPath(homeDir, runId), payload);
13
14
  }
14
15
 
15
16
  export async function readCancelSignal(homeDir, runId) {
@@ -1,8 +1,9 @@
1
1
  import { existsSync } from "node:fs";
2
- import { appendFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
+ import { appendFile, mkdir, readdir, readFile } from "node:fs/promises";
3
3
  import { harnessHomePaths, runPaths } from "../paths.js";
4
4
  import { RUN_STATES, isActiveRunState } from "./run-types.js";
5
5
  import { createRunEvent } from "./run-events.js";
6
+ import { writeAtomicJson } from "./write-atomic-json.js";
6
7
 
7
8
  const writeLocks = new Map();
8
9
 
@@ -13,7 +14,7 @@ export function getRunsDir(homeDir) {
13
14
  export async function createRunRecord(homeDir, metadata) {
14
15
  const { runDir, statePath } = runPaths(homeDir, metadata.runId);
15
16
  await mkdir(runDir, { recursive: true });
16
- await writeFile(statePath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
17
+ await writeAtomicJson(statePath, metadata);
17
18
  return metadata;
18
19
  }
19
20
 
@@ -34,7 +35,7 @@ export async function writeRunState(homeDir, metadata) {
34
35
  const next = previous.then(async () => {
35
36
  const { statePath, runDir } = runPaths(homeDir, metadata.runId);
36
37
  await mkdir(runDir, { recursive: true });
37
- await writeFile(statePath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
38
+ await writeAtomicJson(statePath, metadata);
38
39
  return metadata;
39
40
  });
40
41
  writeLocks.set(key, next.catch(() => {}));
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile } from "node:fs/promises";
3
3
  import { runPaths } from "../paths.js";
4
+ import { writeAtomicJson } from "./write-atomic-json.js";
4
5
 
5
6
  function lockPath(homeDir, runId) {
6
7
  return `${runPaths(homeDir, runId).runDir}/supervisor.lock.json`;
@@ -9,7 +10,7 @@ function lockPath(homeDir, runId) {
9
10
  export async function writeSupervisorLock(homeDir, runId, lock) {
10
11
  const { runDir } = runPaths(homeDir, runId);
11
12
  await mkdir(runDir, { recursive: true });
12
- await writeFile(lockPath(homeDir, runId), `${JSON.stringify(lock, null, 2)}\n`, "utf8");
13
+ await writeAtomicJson(lockPath(homeDir, runId), lock);
13
14
  }
14
15
 
15
16
  export async function readSupervisorLock(homeDir, runId) {
@@ -0,0 +1,51 @@
1
+ import { open as fsOpen, rename as fsRename, unlink as fsUnlink } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { randomBytes } from "node:crypto";
5
+
6
+ function defaultTempPath(targetPath) {
7
+ const id = randomBytes(8).toString("hex");
8
+ return join(
9
+ dirname(targetPath),
10
+ `.${basename(targetPath)}.${process.pid}.${id}.tmp`
11
+ );
12
+ }
13
+
14
+ /**
15
+ * Atomically replace targetPath with pretty-printed JSON.
16
+ * Creates a unique temp in the same directory (O_EXCL), writes + fsync,
17
+ * renames over the destination, and deletes the temp on any failure.
18
+ */
19
+ export async function writeAtomicJson(targetPath, value, deps = {}) {
20
+ const open = deps.open ?? fsOpen;
21
+ const rename = deps.rename ?? fsRename;
22
+ const unlink = deps.unlink ?? fsUnlink;
23
+ const createTempPath = deps.createTempPath ?? defaultTempPath;
24
+
25
+ const payload = `${JSON.stringify(value, null, 2)}\n`;
26
+ const tempPath = createTempPath(targetPath);
27
+ let handle;
28
+
29
+ try {
30
+ handle = await open(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o644);
31
+ await handle.writeFile(payload, "utf8");
32
+ await handle.sync();
33
+ await handle.close();
34
+ handle = undefined;
35
+ await rename(tempPath, targetPath);
36
+ } catch (error) {
37
+ if (handle) {
38
+ try {
39
+ await handle.close();
40
+ } catch {
41
+ // Best-effort close before temp cleanup.
42
+ }
43
+ }
44
+ try {
45
+ await unlink(tempPath);
46
+ } catch {
47
+ // Temp may not exist yet or already renamed.
48
+ }
49
+ throw error;
50
+ }
51
+ }