@preapexis/piexis 0.1.2 → 0.2.4

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.
Files changed (154) hide show
  1. package/.github/workflows/publish.yml +35 -0
  2. package/AGENTS.md +21 -41
  3. package/README.md +75 -142
  4. package/extensions/agents.ts +205 -0
  5. package/extensions/ask-question.ts +54 -95
  6. package/extensions/recommended-tools.ts +62 -0
  7. package/extensions/ui.ts +204 -58
  8. package/lib/agent-config.ts +55 -0
  9. package/lib/agents.ts +208 -0
  10. package/lib/question-model.ts +98 -0
  11. package/lib/question-ui.ts +214 -0
  12. package/lib/recommended-tools.ts +119 -0
  13. package/lib/subagent-runner.ts +146 -0
  14. package/lib/ui-tips.json +41 -0
  15. package/package.json +9 -12
  16. package/prompts/init.md +10 -2
  17. package/prompts/summary.md +7 -6
  18. package/schemas/agent.schema.json +48 -0
  19. package/scripts/release.mjs +32 -0
  20. package/skills/{claude-api/LICENSE.txt → caveman/LICENSE} +2 -2
  21. package/skills/caveman/LICENSE-MIT +21 -0
  22. package/skills/caveman/NOTICE +10 -0
  23. package/skills/caveman/SKILL.md +89 -0
  24. package/skills/find-skills/SKILL.md +141 -0
  25. package/skills/ponytail/LICENSE +21 -0
  26. package/skills/ponytail/NOTICE +9 -0
  27. package/skills/ponytail/SKILL.md +52 -0
  28. package/skills-lock.json +6 -0
  29. package/tests/agents.test.mjs +605 -0
  30. package/tests/ask-question-pty.test.mjs +16 -0
  31. package/tests/ask-question.test.mjs +269 -0
  32. package/tests/fixtures/agents-provider.ts +55 -0
  33. package/tests/fixtures/ask-question-pty.py +91 -0
  34. package/tests/fixtures/ask-question-terminal.mjs +69 -0
  35. package/tests/fixtures/recommended-tools-terminal.mjs +103 -0
  36. package/tests/init-workflow.test.mjs +31 -0
  37. package/tests/recommended-tools-pty.test.mjs +16 -0
  38. package/tests/recommended-tools.test.mjs +423 -0
  39. package/tests/resources.test.mjs +66 -0
  40. package/tests/skills-routing.test.mjs +7 -4
  41. package/tests/ui-tip-reload.test.mjs +70 -0
  42. package/tests/ui.test.mjs +450 -0
  43. package/tsconfig.json +1 -0
  44. package/.github/workflows/npm-publish.yml +0 -63
  45. package/PERMISSION_MODES_PLAN.md +0 -279
  46. package/WHAT_YOU_GET.md +0 -153
  47. package/eval/README.md +0 -72
  48. package/eval/permission-cases.json +0 -28
  49. package/eval/permissions.mjs +0 -103
  50. package/extensions/background-tasks.ts +0 -433
  51. package/extensions/background-worker.ts +0 -50
  52. package/extensions/glob.ts +0 -32
  53. package/extensions/mode.ts +0 -397
  54. package/lib/bash-approvals.ts +0 -113
  55. package/lib/bash-policy.ts +0 -164
  56. package/lib/mode-policy.ts +0 -174
  57. package/lib/path-policy.ts +0 -182
  58. package/lib/permission-classifier.ts +0 -128
  59. package/lib/permission-config.ts +0 -91
  60. package/lib/permission-denials.ts +0 -32
  61. package/lib/permission-intent.ts +0 -128
  62. package/lib/permission-taxonomy.ts +0 -15
  63. package/lib/task-broker.ts +0 -65
  64. package/lib/task-checkout.ts +0 -39
  65. package/lib/task-git.ts +0 -44
  66. package/lib/task-patch.ts +0 -106
  67. package/lib/task-permissions.ts +0 -51
  68. package/lib/task-report.ts +0 -62
  69. package/lib/task-worktree-policy.ts +0 -56
  70. package/skills/claude-api/SKILL.md +0 -572
  71. package/skills/claude-api/csharp/claude-api/README.md +0 -362
  72. package/skills/claude-api/csharp/claude-api/batches.md +0 -14
  73. package/skills/claude-api/csharp/claude-api/files-api.md +0 -25
  74. package/skills/claude-api/csharp/claude-api/streaming.md +0 -28
  75. package/skills/claude-api/csharp/claude-api/tool-use.md +0 -164
  76. package/skills/claude-api/curl/examples.md +0 -256
  77. package/skills/claude-api/curl/managed-agents.md +0 -371
  78. package/skills/claude-api/go/claude-api/README.md +0 -188
  79. package/skills/claude-api/go/claude-api/files-api.md +0 -23
  80. package/skills/claude-api/go/claude-api/streaming.md +0 -43
  81. package/skills/claude-api/go/claude-api/tool-use.md +0 -220
  82. package/skills/claude-api/go/managed-agents/README.md +0 -564
  83. package/skills/claude-api/java/claude-api/README.md +0 -238
  84. package/skills/claude-api/java/claude-api/files-api.md +0 -27
  85. package/skills/claude-api/java/claude-api/streaming.md +0 -24
  86. package/skills/claude-api/java/claude-api/tool-use.md +0 -228
  87. package/skills/claude-api/java/managed-agents/README.md +0 -443
  88. package/skills/claude-api/php/claude-api/README.md +0 -174
  89. package/skills/claude-api/php/claude-api/batches.md +0 -15
  90. package/skills/claude-api/php/claude-api/files-api.md +0 -13
  91. package/skills/claude-api/php/claude-api/streaming.md +0 -27
  92. package/skills/claude-api/php/claude-api/tool-use.md +0 -253
  93. package/skills/claude-api/php/managed-agents/README.md +0 -443
  94. package/skills/claude-api/python/claude-api/README.md +0 -565
  95. package/skills/claude-api/python/claude-api/batches.md +0 -198
  96. package/skills/claude-api/python/claude-api/files-api.md +0 -170
  97. package/skills/claude-api/python/claude-api/sdk-upgrade.md +0 -286
  98. package/skills/claude-api/python/claude-api/streaming.md +0 -179
  99. package/skills/claude-api/python/claude-api/tool-use.md +0 -627
  100. package/skills/claude-api/python/managed-agents/README.md +0 -335
  101. package/skills/claude-api/ruby/claude-api/README.md +0 -134
  102. package/skills/claude-api/ruby/claude-api/streaming.md +0 -16
  103. package/skills/claude-api/ruby/claude-api/tool-use.md +0 -41
  104. package/skills/claude-api/ruby/managed-agents/README.md +0 -394
  105. package/skills/claude-api/shared/admin-api.md +0 -179
  106. package/skills/claude-api/shared/agent-design.md +0 -101
  107. package/skills/claude-api/shared/anthropic-cli.md +0 -269
  108. package/skills/claude-api/shared/claude-platform-on-aws.md +0 -59
  109. package/skills/claude-api/shared/cost-optimization.md +0 -233
  110. package/skills/claude-api/shared/error-codes.md +0 -262
  111. package/skills/claude-api/shared/live-sources.md +0 -166
  112. package/skills/claude-api/shared/managed-agents-api-reference.md +0 -455
  113. package/skills/claude-api/shared/managed-agents-client-patterns.md +0 -220
  114. package/skills/claude-api/shared/managed-agents-core.md +0 -359
  115. package/skills/claude-api/shared/managed-agents-environments.md +0 -226
  116. package/skills/claude-api/shared/managed-agents-events.md +0 -290
  117. package/skills/claude-api/shared/managed-agents-memory.md +0 -201
  118. package/skills/claude-api/shared/managed-agents-multiagent.md +0 -260
  119. package/skills/claude-api/shared/managed-agents-onboarding.md +0 -82
  120. package/skills/claude-api/shared/managed-agents-outcomes.md +0 -108
  121. package/skills/claude-api/shared/managed-agents-overview.md +0 -77
  122. package/skills/claude-api/shared/managed-agents-scheduled-deployments.md +0 -156
  123. package/skills/claude-api/shared/managed-agents-self-hosted-sandboxes.md +0 -282
  124. package/skills/claude-api/shared/managed-agents-tools.md +0 -504
  125. package/skills/claude-api/shared/managed-agents-webhooks.md +0 -143
  126. package/skills/claude-api/shared/model-migration.md +0 -1872
  127. package/skills/claude-api/shared/models.md +0 -142
  128. package/skills/claude-api/shared/platform-availability.md +0 -53
  129. package/skills/claude-api/shared/prompt-audit.md +0 -223
  130. package/skills/claude-api/shared/prompt-caching.md +0 -292
  131. package/skills/claude-api/shared/token-counting.md +0 -56
  132. package/skills/claude-api/shared/tool-use-concepts.md +0 -526
  133. package/skills/claude-api/typescript/claude-api/README.md +0 -405
  134. package/skills/claude-api/typescript/claude-api/batches.md +0 -106
  135. package/skills/claude-api/typescript/claude-api/files-api.md +0 -98
  136. package/skills/claude-api/typescript/claude-api/streaming.md +0 -178
  137. package/skills/claude-api/typescript/claude-api/tool-use.md +0 -586
  138. package/skills/claude-api/typescript/managed-agents/README.md +0 -359
  139. package/tests/auto-permissions.test.mjs +0 -315
  140. package/tests/background-tasks.test.mjs +0 -27
  141. package/tests/background-worker.test.mjs +0 -60
  142. package/tests/bash-permissions.test.mjs +0 -276
  143. package/tests/glob.test.mjs +0 -31
  144. package/tests/mode.test.mjs +0 -62
  145. package/tests/permission-evaluation.test.mjs +0 -70
  146. package/tests/permission-foundation.test.mjs +0 -194
  147. package/tests/permission-policy.test.mjs +0 -158
  148. package/tests/runtime/README.md +0 -51
  149. package/tests/runtime/harness.mjs +0 -190
  150. package/tests/runtime/permissions.test.mjs +0 -192
  151. package/tests/runtime/tui.test.mjs +0 -60
  152. package/tests/task-integration.test.mjs +0 -324
  153. package/tests/task-permissions.test.mjs +0 -178
  154. package/tests/task-worktree-policy.test.mjs +0 -53
@@ -0,0 +1,605 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, readFile, readdir, rm, stat, symlink, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { execFile } from "node:child_process";
7
+ import { promisify } from "node:util";
8
+ import { fileURLToPath } from "node:url";
9
+ import agentsExtension from "../extensions/agents.ts";
10
+ import uiExtension from "../extensions/ui.ts";
11
+ import { matchesKey, visibleWidth } from "@earendil-works/pi-tui";
12
+ import { agentNames, childResourceArgs, initializeAgentConfig, isSmallTask, loadAgentConfig, parseAgentConfig, routeSubagent, subagentTools } from "../lib/agents.ts";
13
+ import { agentConfigSchemaUrl } from "../lib/agent-config.ts";
14
+ import { emptyUsage, limitOutput, piInvocation, runSubagent } from "../lib/subagent-runner.ts";
15
+
16
+ async function fixture(work) {
17
+ const root = await mkdtemp(join(tmpdir(), "piexis-agents-"));
18
+ const cwd = join(root, "project"), agentDir = join(root, "agent");
19
+ await mkdir(join(cwd, ".pi"), { recursive: true });
20
+ await mkdir(agentDir);
21
+ const cli = join(root, "cli.mjs");
22
+ await writeFile(cli, `
23
+ let task = "";
24
+ for await (const chunk of process.stdin) task += chunk;
25
+ const usage = { input: 10, output: 5, cacheRead: 2, cacheWrite: 1, totalTokens: 18,
26
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.01 } };
27
+ const message = { role: "assistant", content: [{ type: "text", text: JSON.stringify({ task, args: process.argv.slice(2), depth: process.env.PIEXIS_SUBAGENT_DEPTH }) }], usage, stopReason: "stop" };
28
+ process.stdout.write(JSON.stringify({ type: "message_end", message }) + "\\n");
29
+ `);
30
+ try { await work({ root, cwd, agentDir, cli }); }
31
+ finally { await rm(root, { recursive: true, force: true }); }
32
+ }
33
+
34
+ async function environment(f, work) {
35
+ const argv = process.argv, dir = process.env.PI_CODING_AGENT_DIR, depth = process.env.PIEXIS_SUBAGENT_DEPTH;
36
+ process.argv = [process.execPath, f.cli];
37
+ process.env.PI_CODING_AGENT_DIR = f.agentDir;
38
+ delete process.env.PIEXIS_SUBAGENT_DEPTH;
39
+ try { await work(); }
40
+ finally {
41
+ process.argv = argv;
42
+ for (const [key, value] of [["PI_CODING_AGENT_DIR", dir], ["PIEXIS_SUBAGENT_DEPTH", depth]]) {
43
+ if (value === undefined) delete process.env[key]; else process.env[key] = value;
44
+ }
45
+ }
46
+ }
47
+
48
+ function harness(f, flag) {
49
+ const handlers = new Map(), commands = new Map(), shortcuts = new Map(), statuses = new Map(), notifications = [], states = [];
50
+ let tool, active = ["read", "bash", "edit", "write", "grep", "find", "ls", "AskQuestion", "subagent"], mainModel, effort;
51
+ const pi = {
52
+ on(name, handler) { handlers.set(name, handler); }, registerFlag() {}, getFlag: () => flag,
53
+ registerCommand(name, command) { commands.set(name, command); }, registerTool(value) { tool = value; },
54
+ registerShortcut(key, shortcut) { shortcuts.set(key, shortcut); },
55
+ getActiveTools: () => active, setActiveTools(value) { active = value; },
56
+ getThinkingLevel: () => "high", appendEntry(type, data) { states.push({ type: "custom", customType: type, data }); },
57
+ async setModel(model) {
58
+ if (model.id === "unauthenticated") return false;
59
+ mainModel = model; return true;
60
+ },
61
+ setThinkingLevel(value) { effort = value; },
62
+ };
63
+ agentsExtension(pi);
64
+ const ctx = {
65
+ cwd: f.cwd, hasUI: true, mode: "json", model: { provider: "test", id: "current" }, thinkingLevel: "high",
66
+ isProjectTrusted: () => true, isIdle: () => true, waitForIdle: async () => {},
67
+ sessionManager: { getBranch: () => states, getEntries: () => states },
68
+ modelRegistry: { find: (provider, id) => provider === "test" && id !== "missing" ? { provider, id } : undefined,
69
+ hasConfiguredAuth: (model) => model.id !== "unauthenticated" },
70
+ ui: { setStatus(key, value) { statuses.set(key, value); }, notify(...args) { notifications.push(args); }, select: async () => undefined },
71
+ };
72
+ return { ctx, handlers, commands, shortcuts, statuses, notifications, states, get tool() { return tool; },
73
+ get active() { return active; }, get mainModel() { return mainModel; }, get effort() { return effort; },
74
+ start: () => handlers.get("session_start")({}, ctx),
75
+ execute: (params, signal, update) => tool.execute("call", params, signal, update, ctx),
76
+ };
77
+ }
78
+
79
+ const current = "test/current";
80
+ test("validates configuration and rejects typos, bad models and efforts", () => {
81
+ assert.deepEqual(parseAgentConfig({}), {});
82
+ for (const bad of [null, [], { model: "bare-id" }, { model: "test/has space" }, { smallModel: "test/model" },
83
+ { subagent_variant_overrides: [] }, { subagent_variant_overrides: { worker: {} } },
84
+ { subagent_variant_overrides: { code: { effort: "huge" } } }, { subagent_variant_overrides: { code: { tools: [] } } }]) {
85
+ assert.throws(() => parseAgentConfig(bad));
86
+ }
87
+ assert.equal(parseAgentConfig({ subagent_model: "provider/org/model", delegate_exploration: true }).delegate_exploration, true);
88
+ assert.throws(() => parseAgentConfig({ delegate_exploration: "true" }));
89
+ });
90
+
91
+ test("trusted project config merges fields within overrides without writing settings", async () => {
92
+ await fixture(async (f) => {
93
+ const global = { model: "test/main", small_model: "test/small", subagent_variant_overrides: { code: { model: "test/code", effort: "high" }, debug: { effort: "high" } } };
94
+ const local = { subagent_model: "test/worker", subagent_variant_overrides: { code: { effort: "low" } } };
95
+ await writeFile(join(f.agentDir, "agents.json"), JSON.stringify(global));
96
+ await writeFile(join(f.cwd, ".pi", "agents.json"), JSON.stringify(local));
97
+ const config = await loadAgentConfig(f.cwd, f.agentDir, true);
98
+ assert.deepEqual(config.subagent_variant_overrides.code, { model: "test/code", effort: "low" });
99
+ assert.deepEqual(config.subagent_variant_overrides.debug, { effort: "high" });
100
+ assert.equal(config.small_model, "test/small");
101
+ assert.equal(config.subagent_model, "test/worker");
102
+ assert.deepEqual(await loadAgentConfig(f.cwd, f.agentDir, false), global);
103
+ await writeFile(join(f.cwd, ".pi", "agents.json"), "{bad");
104
+ await assert.rejects(loadAgentConfig(f.cwd, f.agentDir, true), /agents.json/);
105
+ assert.deepEqual(await loadAgentConfig(f.cwd, f.agentDir, false), global);
106
+ await assert.rejects(readFile(join(f.cwd, ".pi", "settings.json")), { code: "ENOENT" });
107
+ });
108
+ });
109
+
110
+ test("singular agent.json is preferred per scope, with legacy agents.json fallback", async () => {
111
+ await fixture(async (f) => {
112
+ await writeFile(join(f.agentDir, "agents.json"), '{"model":"test/legacy"}');
113
+ await writeFile(join(f.agentDir, "agent.json"), '{"model":"test/main"}');
114
+ await writeFile(join(f.cwd, ".pi", "agents.json"), '{"subagent_model":"test/worker"}');
115
+ assert.equal((await loadAgentConfig(f.cwd, f.agentDir, true)).model, "test/main");
116
+ assert.equal((await loadAgentConfig(f.cwd, f.agentDir, true)).subagent_model, "test/worker");
117
+ await writeFile(join(f.cwd, ".pi", "agent.json"), '{"subagent_model":"test/local"}');
118
+ assert.equal((await loadAgentConfig(f.cwd, f.agentDir, true)).subagent_model, "test/local");
119
+ assert.equal((await loadAgentConfig(f.cwd, f.agentDir, false)).subagent_model, undefined);
120
+ await writeFile(join(f.cwd, ".pi", "agent.json"), "{bad");
121
+ await assert.rejects(loadAgentConfig(f.cwd, f.agentDir, true), /agent.json/);
122
+ });
123
+ });
124
+
125
+ test("opt-in agent scaffold uses the active model, restrictive permissions and atomic create-only writes", async () => {
126
+ await fixture(async (f) => {
127
+ const created = await Promise.all([initializeAgentConfig(f.cwd, f.agentDir, "test/current"), initializeAgentConfig(f.cwd, f.agentDir, "test/other")]);
128
+ assert.deepEqual(created, [true, false]);
129
+ const path = join(f.cwd, ".pi", "agent.json");
130
+ assert.deepEqual(JSON.parse(await readFile(path, "utf8")), {
131
+ $schema: agentConfigSchemaUrl, model: "test/current", small_model: "test/current", subagent_model: "test/current", delegate_exploration: false,
132
+ subagent_variant_overrides: {
133
+ plan: { model: "test/current", effort: "high" },
134
+ orchestrator: { model: "test/current", effort: "high" },
135
+ debug: { model: "test/current", effort: "high" },
136
+ ask: { model: "test/current", effort: "low" },
137
+ code: { model: "test/current", effort: "medium" },
138
+ }
139
+ });
140
+ assert.equal((await stat(path)).mode & 0o777, 0o600);
141
+ assert.deepEqual(await readdir(join(f.cwd, ".pi")), ["agent.json"]);
142
+ assert.equal((await loadAgentConfig(f.cwd, f.agentDir, true)).model, "test/current");
143
+ });
144
+ });
145
+
146
+ test("agent-config command fills only missing project settings", async () => {
147
+ await fixture(async (f) => environment(f, async () => {
148
+ await writeFile(join(f.cwd, ".pi", "agent.json"), JSON.stringify({ model: "test/custom", delegate_exploration: true }));
149
+ const h = harness(f);
150
+ await h.commands.get("agent-config").handler("", h.ctx);
151
+ const config = JSON.parse(await readFile(join(f.cwd, ".pi", "agent.json"), "utf8"));
152
+ assert.equal(config.$schema, agentConfigSchemaUrl);
153
+ assert.equal(config.model, "test/custom");
154
+ assert.equal(config.delegate_exploration, true);
155
+ assert.equal(config.small_model, "test/custom");
156
+ assert.equal(config.subagent_model, "test/custom");
157
+ assert.equal(config.subagent_variant_overrides.code.model, "test/custom");
158
+ assert.match(h.notifications.at(-1)[0], /Added missing/);
159
+ await h.commands.get("agent-config").handler("", h.ctx);
160
+ assert.match(h.notifications.at(-1)[0], /already complete/);
161
+ }));
162
+ });
163
+
164
+ test("agent scaffold preserves inherited user configuration", async () => {
165
+ await fixture(async (f) => {
166
+ const inherited = { model: "test/main", small_model: "test/small", subagent_model: "test/worker", delegate_exploration: true, subagent_variant_overrides: {
167
+ debug: { effort: "off" }, ask: { model: "test/answer" }, code: { model: "test/code", effort: "max" }
168
+ } };
169
+ const path = join(f.agentDir, "agent.json");
170
+ const original = JSON.stringify(inherited);
171
+ await writeFile(path, original);
172
+ assert.equal(await initializeAgentConfig(f.cwd, f.agentDir, "test/current"), true);
173
+ assert.deepEqual(JSON.parse(await readFile(join(f.cwd, ".pi", "agent.json"), "utf8")), {
174
+ $schema: agentConfigSchemaUrl,
175
+ ...inherited,
176
+ subagent_variant_overrides: {
177
+ plan: { model: "test/worker", effort: "high" },
178
+ orchestrator: { model: "test/worker", effort: "high" },
179
+ debug: { model: "test/worker", effort: "off" },
180
+ ask: { model: "test/answer", effort: "low" },
181
+ code: { model: "test/code", effort: "max" },
182
+ },
183
+ });
184
+ assert.equal(await readFile(path, "utf8"), original);
185
+ });
186
+ });
187
+
188
+ test("agent scaffold never replaces existing, malformed, legacy or non-regular files", async () => {
189
+ for (const name of ["agent.json", "agents.json"]) {
190
+ for (const value of ['{"model":"test/custom"}', "{bad", "directory", "symlink"]) {
191
+ await fixture(async (f) => {
192
+ const path = join(f.cwd, ".pi", name);
193
+ const target = join(f.root, "target.json");
194
+ if (value === "directory") await mkdir(path);
195
+ else if (value === "symlink") { await writeFile(target, "do not change"); await symlink(target, path); }
196
+ else await writeFile(path, value);
197
+ assert.equal(await initializeAgentConfig(f.cwd, f.agentDir, "test/current"), false);
198
+ if (value === "symlink") assert.equal(await readFile(target, "utf8"), "do not change");
199
+ else if (value !== "directory") assert.equal(await readFile(path, "utf8"), value);
200
+ assert.deepEqual(await readdir(join(f.cwd, ".pi")), [name]);
201
+ });
202
+ }
203
+ }
204
+ });
205
+
206
+ test("missing models, invalid inherited configuration and cancellation do not create an agent file", async () => {
207
+ await fixture(async (f) => {
208
+ await assert.rejects(initializeAgentConfig(f.cwd, f.agentDir, undefined), /No active model/);
209
+ await assert.rejects(initializeAgentConfig(f.cwd, f.agentDir, "bare-id"), /provider\/model/);
210
+ const controller = new AbortController(); controller.abort();
211
+ await assert.rejects(initializeAgentConfig(f.cwd, f.agentDir, "test/current", controller.signal), { name: "AbortError" });
212
+ await writeFile(join(f.agentDir, "agent.json"), "{bad");
213
+ await assert.rejects(initializeAgentConfig(f.cwd, f.agentDir, "test/current"), /agent.json/);
214
+ assert.deepEqual(await readdir(join(f.cwd, ".pi")), []);
215
+ });
216
+ });
217
+
218
+ test("automatic small routing is conservative and variant overrides take precedence", () => {
219
+ const config = { model: "test/main", small_model: "test/small", subagent_model: "test/worker" };
220
+ for (const task of ["Locate the auth entry point", "What does parseConfig do?", "Summarize README.md"]) {
221
+ const route = routeSubagent(config, "ask", task, current, "high");
222
+ assert.deepEqual(route, { model: "test/small", effort: "low", reason: "automatic small task" });
223
+ }
224
+ assert.equal(isSmallTask("code", "Fix a typo in README.md"), true);
225
+ for (const [agent, task] of [["ask", "Explain the security architecture"], ["ask", "Summarize the entire repo"], ["code", "Implement login"], ["debug", "Fix typo"], ["plan", "List files"], ["orchestrator", "Find config"], ["ask", "List " + "x".repeat(401)]]) {
226
+ assert.equal(routeSubagent(config, agent, task, current, "high").model, "test/worker");
227
+ }
228
+ assert.equal(routeSubagent({}, "code", "task", current, "high").model, current);
229
+ assert.equal(routeSubagent({ model: "test/main" }, "code", "task", current, "high").model, current);
230
+ const overrides = { ...config, subagent_variant_overrides: { ask: { model: "test/ask", effort: "max" }, code: { effort: "high" } } };
231
+ assert.deepEqual(routeSubagent(overrides, "ask", "Locate config", current, "off"), { model: "test/ask", effort: "max", reason: "variant override" });
232
+ assert.equal(routeSubagent(overrides, "code", "Fix a typo", current, "off").effort, "high");
233
+ assert.deepEqual(routeSubagent(overrides, "ask", "Explore the configuration", current, "high", true), {
234
+ model: "test/small", effort: "low", reason: "configured exploration small model",
235
+ });
236
+ assert.throws(() => routeSubagent({}, "ask", "Explore", current, "high", true), /requires small_model/);
237
+ });
238
+
239
+ test("child tools never add inactive tools and resource flags preserve explicit extensions", () => {
240
+ const active = ["read", "bash", "write", "AskQuestion", "subagent", "custom"];
241
+ assert.deepEqual(subagentTools("ask", active, 1), ["read"]);
242
+ assert.deepEqual(subagentTools("plan", active, 1), ["read"]);
243
+ assert.deepEqual(subagentTools("plan", active, 1, true), ["read", "subagent"]);
244
+ assert.deepEqual(subagentTools("ask", active, 2, true), ["read"]);
245
+ assert.deepEqual(subagentTools("orchestrator", active, 1), ["read", "bash", "write", "subagent", "custom"]);
246
+ assert.deepEqual(subagentTools("code", active, 2), ["read", "bash", "write", "custom"]);
247
+ assert.deepEqual(childResourceArgs(["--model", "test/main", "--no-extensions", "-e", "gate.ts", "--no-mcp", "--api-key", "private", "--", "-e", "bad.ts"]), ["--no-extensions", "-e", "gate.ts", "--no-mcp"]);
248
+ });
249
+
250
+ test("all five profiles execute as child agents with visible routing and preserved loadout", async () => {
251
+ await fixture(async (f) => environment(f, async () => {
252
+ await writeFile(join(f.agentDir, "agents.json"), JSON.stringify({ small_model: "test/small", subagent_model: "test/worker", subagent_variant_overrides: { plan: { model: "test/planner", effort: "max" } } }));
253
+ process.argv.push("--no-extensions", "-e", "gate.ts");
254
+ const h = harness(f);
255
+ await h.start();
256
+ assert.equal(h.tool.executionMode, "sequential");
257
+ for (const agent of agentNames) {
258
+ const updates = [];
259
+ const result = await h.execute({ agent, task: agent === "ask" ? "Locate the config" : "Complete this task" }, undefined, (value) => updates.push(value));
260
+ assert.equal(result.isError, false);
261
+ const output = JSON.parse(result.details.output);
262
+ assert.equal(output.depth, "1");
263
+ assert.match(output.task, /^Task:/);
264
+ assert.ok(output.args.includes("--approve"));
265
+ assert.ok(output.args.includes("gate.ts"));
266
+ assert.equal(output.args[output.args.indexOf("--agent") + 1], agent);
267
+ const tools = output.args[output.args.indexOf("--tools") + 1].split(",");
268
+ assert.ok(!tools.includes("AskQuestion"));
269
+ if (["plan", "ask"].includes(agent)) {
270
+ assert.ok(!tools.includes("bash") && !tools.includes("subagent"));
271
+ assert.ok(output.args.includes("mcp__*"));
272
+ }
273
+ assert.equal(result.details.model, agent === "ask" ? "test/small" : agent === "plan" ? "test/planner" : "test/worker");
274
+ assert.equal(result.details.effort, agent === "ask" ? "low" : agent === "plan" ? "max" : "high");
275
+ assert.equal(result.usage.input, 10);
276
+ assert.match(result.content[0].text, new RegExp(result.details.reason));
277
+ assert.ok(updates.length > 0);
278
+ }
279
+ }));
280
+ });
281
+
282
+ test("configured exploration always uses the small model and stays read-only", async () => {
283
+ await fixture(async (f) => environment(f, async () => {
284
+ await writeFile(join(f.agentDir, "agent.json"), JSON.stringify({
285
+ small_model: "test/small", delegate_exploration: true,
286
+ subagent_variant_overrides: { ask: { model: "test/ask", effort: "max" } },
287
+ }));
288
+ const h = harness(f);
289
+ await h.start();
290
+ assert.match(h.handlers.get("before_agent_start")({ systemPrompt: "base" }).systemPrompt, /purpose: explore/);
291
+ const result = await h.execute({ agent: "ask", purpose: "explore", task: "Locate configuration loading" });
292
+ assert.equal(result.details.model, "test/small");
293
+ assert.equal(result.details.effort, "low");
294
+ assert.equal(result.details.reason, "configured exploration small model");
295
+ assert.deepEqual(h.ctx.model, { provider: "test", id: "current" });
296
+ const output = JSON.parse(result.details.output);
297
+ const tools = output.args[output.args.indexOf("--tools") + 1].split(",");
298
+ assert.ok(!tools.includes("subagent") && !tools.includes("bash"));
299
+ await assert.rejects(h.execute({ agent: "plan", purpose: "explore", task: "Locate configuration loading" }), /requires agent: ask/);
300
+ await writeFile(join(f.agentDir, "agent.json"), JSON.stringify({ small_model: "test/small", delegate_exploration: false }));
301
+ await assert.rejects(h.execute({ agent: "ask", purpose: "explore", task: "Locate configuration loading" }), /Enable delegate_exploration/);
302
+ await writeFile(join(f.agentDir, "agent.json"), JSON.stringify({ delegate_exploration: true }));
303
+ await assert.rejects(h.execute({ agent: "ask", purpose: "explore", task: "Locate configuration loading" }), /requires small_model/);
304
+ }));
305
+ });
306
+
307
+ test("profile commands cancel cleanly, inject instructions, persist and restore branch state", async () => {
308
+ await fixture(async (f) => environment(f, async () => {
309
+ const h = harness(f);
310
+ await h.start();
311
+ const command = h.commands.get("agent");
312
+ const prompt = () => h.handlers.get("before_agent_start")({ systemPrompt: "base" }, h.ctx).systemPrompt;
313
+ await command.handler("", h.ctx);
314
+ assert.equal(h.states.length, 0);
315
+ await command.handler("plan", h.ctx);
316
+ assert.match(prompt(), /base[\s\S]*profile: plan/);
317
+ await assert.rejects(h.execute({ agent: "code", task: "Make changes" }), /only to plan or ask/);
318
+ await command.handler("nonsense", h.ctx);
319
+ assert.match(prompt(), /profile: plan/);
320
+ await command.handler("debug", h.ctx);
321
+ assert.match(prompt(), /trace its cause/);
322
+ h.states.pop();
323
+ h.handlers.get("session_tree")({}, h.ctx);
324
+ assert.match(prompt(), /profile: plan/);
325
+ h.states.length = 0;
326
+ h.handlers.get("session_tree")({}, h.ctx);
327
+ assert.match(prompt(), /profile: code/);
328
+ }));
329
+ });
330
+
331
+ test("Alt+A cycles all profiles, persists the selection and respects busy/non-TUI sessions", async () => {
332
+ await fixture(async (f) => environment(f, async () => {
333
+ const h = harness(f);
334
+ h.ctx.mode = "tui";
335
+ await h.start();
336
+ assert.equal(h.shortcuts.size, 1);
337
+ const shortcut = h.shortcuts.get("alt+a");
338
+ assert.ok(shortcut);
339
+ assert.ok(matchesKey("\u001ba", "alt+a"));
340
+ assert.equal(h.statuses.get("piexis-agent"), "agent:code");
341
+ for (const name of agentNames) {
342
+ await shortcut.handler(h.ctx);
343
+ assert.equal(h.statuses.get("piexis-agent"), `agent:${name}`);
344
+ assert.equal(h.states.at(-1).data.agent, name);
345
+ assert.match(h.handlers.get("before_agent_start")({ systemPrompt: "base" }).systemPrompt, new RegExp(`profile: ${name}`));
346
+ }
347
+ const count = h.states.length;
348
+ h.ctx.isIdle = () => false;
349
+ await shortcut.handler(h.ctx);
350
+ assert.equal(h.states.length, count);
351
+ assert.equal(h.notifications.at(-1)[1], "warning");
352
+ h.ctx.isIdle = () => true;
353
+ for (const mode of ["json", "rpc", "print"]) {
354
+ h.ctx.mode = mode;
355
+ await shortcut.handler(h.ctx);
356
+ assert.equal(h.states.length, count);
357
+ }
358
+ h.ctx.mode = "tui";
359
+ await h.commands.get("agent").handler("debug", h.ctx);
360
+ await shortcut.handler(h.ctx);
361
+ assert.equal(h.statuses.get("piexis-agent"), "agent:ask");
362
+ h.states.pop();
363
+ h.handlers.get("session_tree")({}, h.ctx);
364
+ assert.equal(h.statuses.get("piexis-agent"), "agent:debug");
365
+ const resumed = harness(f);
366
+ resumed.states.push(...h.states);
367
+ await resumed.start();
368
+ assert.equal(resumed.statuses.get("piexis-agent"), "agent:debug");
369
+ }));
370
+ });
371
+
372
+ test("commands, picker and cycling apply merged profile models and effort", async () => {
373
+ await fixture(async (f) => environment(f, async () => {
374
+ await writeFile(join(f.agentDir, "agent.json"), JSON.stringify({
375
+ model: "test/main", small_model: "test/small", subagent_model: "test/worker",
376
+ subagent_variant_overrides: { plan: { model: "test/planner", effort: "high" }, debug: { effort: "max" } },
377
+ }));
378
+ await writeFile(join(f.cwd, ".pi", "agent.json"), JSON.stringify({
379
+ subagent_variant_overrides: { plan: { effort: "low" }, ask: { model: "test/answer", effort: "off" } },
380
+ }));
381
+ const h = harness(f);
382
+ h.ctx.mode = "tui";
383
+ await h.start();
384
+ await h.shortcuts.get("alt+a").handler(h.ctx);
385
+ assert.equal(h.mainModel.id, "planner");
386
+ assert.equal(h.effort, "low");
387
+ assert.equal(h.statuses.get("piexis-agent"), "agent:plan");
388
+ await h.commands.get("agent").handler("debug", h.ctx);
389
+ assert.equal(h.mainModel.id, "main");
390
+ assert.equal(h.effort, "max");
391
+ h.ctx.ui.select = async () => "ask";
392
+ await h.commands.get("agent").handler("", h.ctx);
393
+ assert.equal(h.mainModel.id, "answer");
394
+ assert.equal(h.effort, "off");
395
+ h.ctx.isProjectTrusted = () => false;
396
+ await h.commands.get("agent").handler("plan", h.ctx);
397
+ assert.equal(h.mainModel.id, "planner");
398
+ assert.equal(h.effort, "high");
399
+ }));
400
+ });
401
+
402
+ test("unconfigured profile fields leave current settings alone", async () => {
403
+ await fixture(async (f) => environment(f, async () => {
404
+ const h = harness(f);
405
+ await h.commands.get("agent").handler("plan", h.ctx);
406
+ assert.equal(h.mainModel, undefined);
407
+ assert.equal(h.effort, undefined);
408
+ await writeFile(join(f.agentDir, "agent.json"), JSON.stringify({
409
+ small_model: "test/small", subagent_model: "test/worker",
410
+ subagent_variant_overrides: { ask: { effort: "low" }, code: { model: "test/coder" } },
411
+ }));
412
+ await h.commands.get("agent").handler("ask", h.ctx);
413
+ assert.equal(h.mainModel, undefined);
414
+ assert.equal(h.effort, "low");
415
+ await h.commands.get("agent").handler("code", h.ctx);
416
+ assert.equal(h.mainModel.id, "coder");
417
+ assert.equal(h.effort, "low");
418
+ }));
419
+ });
420
+
421
+ test("failed profile switches preserve model, effort and profile and release the switch guard", async () => {
422
+ await fixture(async (f) => environment(f, async () => {
423
+ const h = harness(f);
424
+ h.ctx.mode = "tui";
425
+ await h.start();
426
+ await writeFile(join(f.agentDir, "agent.json"), JSON.stringify({
427
+ subagent_variant_overrides: { code: { model: "test/coder", effort: "medium" } },
428
+ }));
429
+ await h.commands.get("agent").handler("code", h.ctx);
430
+ const count = h.states.length;
431
+ for (const value of ["{bad", ...["missing", "unauthenticated"].map((id) => JSON.stringify({
432
+ subagent_variant_overrides: { plan: { model: `test/${id}`, effort: "max" } },
433
+ }))]) {
434
+ await writeFile(join(f.agentDir, "agent.json"), value);
435
+ await h.shortcuts.get("alt+a").handler(h.ctx);
436
+ assert.equal(h.mainModel.id, "coder");
437
+ assert.equal(h.effort, "medium");
438
+ assert.equal(h.statuses.get("piexis-agent"), "agent:code");
439
+ assert.equal(h.states.length, count);
440
+ assert.equal(h.notifications.at(-1)[1], "warning");
441
+ }
442
+ await writeFile(join(f.agentDir, "agent.json"), "{}");
443
+ await Promise.all([h.shortcuts.get("alt+a").handler(h.ctx), h.commands.get("agent").handler("ask", h.ctx)]);
444
+ assert.equal(h.states.length, count + 1);
445
+ assert.equal(h.statuses.get("piexis-agent"), "agent:plan");
446
+ await h.commands.get("agent").handler("ask", h.ctx);
447
+ assert.equal(h.statuses.get("piexis-agent"), "agent:ask");
448
+ }));
449
+ });
450
+
451
+ test("footer reads live agent status after commands, cycling and tree restoration without losing width bounds", async () => {
452
+ await fixture(async (f) => environment(f, async () => {
453
+ const h = harness(f);
454
+ h.ctx.mode = "tui";
455
+ h.ctx.getContextUsage = () => undefined;
456
+ const uiHandlers = new Map();
457
+ const theme = { fg: (_color, text) => `\u001b[36m${text}\u001b[39m`, bold: (text) => `\u001b[1m${text}\u001b[22m` };
458
+ let footer;
459
+ Object.assign(h.ctx.ui, {
460
+ setTitle() {}, setHeader() {},
461
+ setFooter(make) { footer = make({ requestRender() {} }, theme, {
462
+ getGitBranch: () => "main", getExtensionStatuses: () => h.statuses, onBranchChange: () => () => {},
463
+ }); },
464
+ });
465
+ uiExtension({ on(name, handler) { uiHandlers.set(name, handler); }, getThinkingLevel: () => "high" });
466
+ await uiHandlers.get("session_start")({}, h.ctx);
467
+ try {
468
+ assert.doesNotMatch(footer.render(160).join("\n"), /agent:/);
469
+ await h.start();
470
+ assert.match(footer.render(160).join("\n"), /agent:code/);
471
+ assert.match(footer.render(160).join("\n"), /Alt\+A/);
472
+ for (const name of agentNames) {
473
+ await h.shortcuts.get("alt+a").handler(h.ctx);
474
+ for (const width of [40, 60, 80, 160]) {
475
+ const rows = footer.render(width);
476
+ assert.match(rows.join("\n"), new RegExp(`agent:${name}`));
477
+ for (const row of rows) assert.ok(visibleWidth(row) <= width);
478
+ }
479
+ }
480
+ await h.commands.get("agent").handler("debug", h.ctx);
481
+ assert.match(footer.render(160).join("\n"), /agent:debug/);
482
+ h.states.pop();
483
+ h.handlers.get("session_tree")({}, h.ctx);
484
+ assert.match(footer.render(160).join("\n"), /agent:code/);
485
+ h.ctx.model = { id: "研究🚀".repeat(100) };
486
+ for (let width = 0; width <= 160; width++) {
487
+ for (const row of footer.render(width)) assert.ok(visibleWidth(row) <= width);
488
+ }
489
+ assert.match(footer.render(40).join("\n"), /agent:code/);
490
+ h.statuses.clear();
491
+ assert.doesNotMatch(footer.render(160).join("\n"), /agent:|Alt\+A/);
492
+ } finally { footer.dispose(); }
493
+ }));
494
+ });
495
+
496
+ test("configured main model initializes only new sessions, not CLI overrides or resumes", async () => {
497
+ await fixture(async (f) => environment(f, async () => {
498
+ await writeFile(join(f.agentDir, "agents.json"), '{"model":"test/main"}');
499
+ const h = harness(f, "ask");
500
+ await h.start();
501
+ assert.equal(h.mainModel.id, "main");
502
+ assert.match(h.handlers.get("before_agent_start")({ systemPrompt: "base" }).systemPrompt, /profile: ask/);
503
+ process.argv.push("--model", "test/explicit");
504
+ const explicit = harness(f);
505
+ await explicit.start();
506
+ assert.equal(explicit.mainModel, undefined);
507
+ process.argv.length -= 2;
508
+ const resumed = harness(f);
509
+ resumed.states.push({ type: "message" });
510
+ await resumed.start();
511
+ assert.equal(resumed.mainModel, undefined);
512
+ }));
513
+ });
514
+
515
+ test("invalid requests, unavailable models and depth limits fail without spawning", async () => {
516
+ await fixture(async (f) => environment(f, async () => {
517
+ const h = harness(f);
518
+ for (const params of [{ agent: "worker", task: "task" }, { agent: "code", task: " " }]) await assert.rejects(h.execute(params));
519
+ for (const id of ["missing", "unauthenticated"]) {
520
+ await writeFile(join(f.agentDir, "agents.json"), JSON.stringify({ subagent_model: `test/${id}` }));
521
+ await assert.rejects(h.execute({ agent: "code", task: "task" }), /unavailable/);
522
+ }
523
+ process.env.PIEXIS_SUBAGENT_DEPTH = "2";
524
+ const nested = harness(f);
525
+ await nested.start();
526
+ assert.ok(!nested.active.includes("subagent"));
527
+ await assert.rejects(nested.execute({ agent: "orchestrator", task: "task" }), /depth limit/);
528
+ }));
529
+ });
530
+
531
+ test("runner handles UTF-8 framing, multiple text blocks, tool usage, retries and failed responses", async () => {
532
+ await fixture(async (f) => {
533
+ const usage = emptyUsage(); usage.input = 4; usage.totalTokens = 4;
534
+ const events = [
535
+ { type: "message_end", message: { role: "assistant", content: [], stopReason: "error", errorMessage: "temporary", usage } },
536
+ { type: "message_end", message: { role: "toolResult", usage } },
537
+ { type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "你好\u2028🌍" }, { type: "text", text: "done" }], stopReason: "stop", usage } },
538
+ ];
539
+ await writeFile(f.cli, `const data = Buffer.from(${JSON.stringify(events.map((event) => JSON.stringify(event)).join("\n"))}); for (const byte of data) process.stdout.write(Buffer.from([byte]));`);
540
+ const run = () => runSubagent({ invocation: { command: process.execPath, args: [f.cli] }, args: [], cwd: f.cwd, depth: 1, task: "task", signal: new AbortController().signal });
541
+ const result = await run();
542
+ assert.equal(result.output, "你好\u2028🌍\ndone");
543
+ assert.equal(result.error, undefined);
544
+ assert.equal(result.usage.input, 12);
545
+ await writeFile(f.cli, `console.log(${JSON.stringify(JSON.stringify({ type: "message_end", message: { role: "assistant", content: [], stopReason: "error", errorMessage: "provider failed" } }))});`);
546
+ const failed = await run();
547
+ assert.equal(failed.exitCode, 0);
548
+ assert.equal(failed.error, "provider failed");
549
+ await writeFile(f.cli, 'process.stderr.write("startup failed"); process.exitCode = 3;');
550
+ assert.equal((await run()).exitCode, 3);
551
+ await writeFile(f.cli, 'console.log("not json");');
552
+ await assert.rejects(run(), /Invalid JSON/);
553
+ assert.match(limitOutput("x".repeat(60_000)), /truncated/);
554
+ });
555
+ });
556
+
557
+ test("real Pi CLI delegates to automatic and explicit small models without network or credentials", { timeout: 30_000 }, async () => {
558
+ await fixture(async (f) => {
559
+ for (const mode of ["lookup", "orchestrator", "exploration"]) {
560
+ const orchestrate = mode === "orchestrator";
561
+ const exploration = mode === "exploration";
562
+ await writeFile(join(f.agentDir, "agents.json"), JSON.stringify({ small_model: "piexis-test/small", delegate_exploration: true }));
563
+ const invocation = piInvocation();
564
+ const args = [...invocation.args, "--offline", "--no-session", "--mode", "json", "--no-extensions", "--no-skills", "--no-context-files", "--no-prompt-templates",
565
+ "-e", fileURLToPath(new URL("../extensions/agents.ts", import.meta.url)),
566
+ "-e", fileURLToPath(new URL("./fixtures/agents-provider.ts", import.meta.url)),
567
+ "--model", "piexis-test/main", "--tools", "subagent,read", "--", orchestrate ? "Use orchestrator for a lookup" : exploration ? "Delegate exploration" : "Delegate a lookup"];
568
+ const executionPromise = promisify(execFile)(invocation.command, args, {
569
+ cwd: f.cwd, env: { ...process.env, PI_CODING_AGENT_DIR: f.agentDir, PIEXIS_SUBAGENT_DEPTH: "0", PI_OFFLINE: "1" },
570
+ timeout: 25_000, maxBuffer: 4 * 1024 * 1024,
571
+ });
572
+ executionPromise.child.stdin.end();
573
+ const { stdout } = await executionPromise;
574
+ const events = stdout.trim().split("\n").map((line) => JSON.parse(line));
575
+ const execution = events.find((event) => event.type === "tool_execution_end" && event.toolName === "subagent");
576
+ assert.ok(execution, stdout);
577
+ assert.equal(execution.isError, false, JSON.stringify(execution));
578
+ assert.equal(execution.result.details.model, orchestrate ? "piexis-test/main" : "piexis-test/small");
579
+ assert.equal(execution.result.details.reason, orchestrate ? "default subagent" : exploration ? "configured exploration small model" : "automatic small task");
580
+ if (orchestrate) assert.match(execution.result.details.output, /child:small;depth:2/);
581
+ else assert.equal(execution.result.details.output, "child:small;depth:1");
582
+ assert.equal(execution.result.usage.input, orchestrate ? 30 : 10);
583
+ assert.ok(events.some((event) => event.type === "message_end" && event.message.content?.some((part) => part.text === "parent completed")));
584
+ }
585
+ });
586
+ });
587
+
588
+ test("abort and shutdown terminate running children; pre-aborted calls never start", { timeout: 10_000 }, async () => {
589
+ await fixture(async (f) => environment(f, async () => {
590
+ await writeFile(f.cli, 'process.on("SIGTERM", () => {}); console.log(JSON.stringify({type:"tool_execution_start",toolName:"read"})); setInterval(() => {}, 1000);');
591
+ const h = harness(f);
592
+ const pre = new AbortController(); pre.abort();
593
+ await assert.rejects(h.execute({ agent: "code", task: "task" }, pre.signal), { name: "AbortError" });
594
+ for (const shutdown of [false, true]) {
595
+ const controller = new AbortController();
596
+ let ready;
597
+ const started = new Promise((resolve) => { ready = resolve; });
598
+ const pending = h.execute({ agent: "code", task: "task" }, controller.signal, ready);
599
+ const rejected = assert.rejects(pending, /aborted/);
600
+ await started;
601
+ if (shutdown) await h.handlers.get("session_shutdown")(); else controller.abort();
602
+ await rejected;
603
+ }
604
+ }));
605
+ });
@@ -0,0 +1,16 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
3
+ import { fileURLToPath } from "node:url";
4
+ import test from "node:test";
5
+
6
+ const python = spawnSync("python3", ["-c", "import pty, termios"], { encoding: "utf8" });
7
+ const driver = fileURLToPath(new URL("fixtures/ask-question-pty.py", import.meta.url));
8
+ const fixture = fileURLToPath(new URL("fixtures/ask-question-terminal.mjs", import.meta.url));
9
+
10
+ for (const scenario of ["single", "multi", "text", "other", "navigation", "cancel", "ctrl-c", "abort"]) {
11
+ test(`real terminal: ${scenario}, focus restoration and cleanup`, { skip: python.status !== 0 ? "Python/Unix PTY support unavailable" : false }, () => {
12
+ const result = spawnSync("python3", [driver, fixture, scenario, process.execPath], { encoding: "utf8", timeout: 12000 });
13
+ assert.equal(result.status, 0, result.stderr + result.stdout);
14
+ assert.equal(JSON.parse(result.stdout).terminalRestored, true);
15
+ });
16
+ }