@preapexis/piexis 0.2.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 (149) hide show
  1. package/AGENTS.md +21 -41
  2. package/README.md +73 -139
  3. package/extensions/agents.ts +205 -0
  4. package/extensions/ask-question.ts +54 -95
  5. package/extensions/recommended-tools.ts +62 -0
  6. package/extensions/ui.ts +204 -58
  7. package/lib/agent-config.ts +55 -0
  8. package/lib/agents.ts +208 -0
  9. package/lib/question-model.ts +98 -0
  10. package/lib/question-ui.ts +214 -0
  11. package/lib/recommended-tools.ts +119 -0
  12. package/lib/subagent-runner.ts +146 -0
  13. package/lib/ui-tips.json +41 -0
  14. package/package.json +5 -13
  15. package/prompts/init.md +10 -2
  16. package/prompts/summary.md +7 -6
  17. package/schemas/agent.schema.json +48 -0
  18. package/skills/{claude-api/LICENSE.txt → caveman/LICENSE} +2 -2
  19. package/skills/caveman/LICENSE-MIT +21 -0
  20. package/skills/caveman/NOTICE +10 -0
  21. package/skills/caveman/SKILL.md +89 -0
  22. package/skills/find-skills/SKILL.md +141 -0
  23. package/skills/ponytail/LICENSE +21 -0
  24. package/skills/ponytail/NOTICE +9 -0
  25. package/skills/ponytail/SKILL.md +52 -0
  26. package/skills-lock.json +6 -0
  27. package/tests/agents.test.mjs +605 -0
  28. package/tests/ask-question-pty.test.mjs +16 -0
  29. package/tests/ask-question.test.mjs +269 -0
  30. package/tests/fixtures/agents-provider.ts +55 -0
  31. package/tests/fixtures/ask-question-pty.py +91 -0
  32. package/tests/fixtures/ask-question-terminal.mjs +69 -0
  33. package/tests/fixtures/recommended-tools-terminal.mjs +103 -0
  34. package/tests/init-workflow.test.mjs +31 -0
  35. package/tests/recommended-tools-pty.test.mjs +16 -0
  36. package/tests/recommended-tools.test.mjs +423 -0
  37. package/tests/resources.test.mjs +66 -0
  38. package/tests/skills-routing.test.mjs +7 -4
  39. package/tests/ui-tip-reload.test.mjs +70 -0
  40. package/tests/ui.test.mjs +450 -0
  41. package/tsconfig.json +1 -0
  42. package/eval/README.md +0 -72
  43. package/eval/permission-cases.json +0 -28
  44. package/eval/permissions.mjs +0 -103
  45. package/extensions/background-tasks.ts +0 -433
  46. package/extensions/background-worker.ts +0 -50
  47. package/extensions/glob.ts +0 -32
  48. package/extensions/mode.ts +0 -397
  49. package/lib/bash-approvals.ts +0 -113
  50. package/lib/bash-policy.ts +0 -164
  51. package/lib/mode-policy.ts +0 -174
  52. package/lib/path-policy.ts +0 -182
  53. package/lib/permission-classifier.ts +0 -128
  54. package/lib/permission-config.ts +0 -91
  55. package/lib/permission-denials.ts +0 -32
  56. package/lib/permission-intent.ts +0 -128
  57. package/lib/permission-taxonomy.ts +0 -15
  58. package/lib/task-broker.ts +0 -65
  59. package/lib/task-checkout.ts +0 -39
  60. package/lib/task-git.ts +0 -44
  61. package/lib/task-patch.ts +0 -106
  62. package/lib/task-permissions.ts +0 -51
  63. package/lib/task-report.ts +0 -62
  64. package/lib/task-worktree-policy.ts +0 -56
  65. package/skills/claude-api/SKILL.md +0 -572
  66. package/skills/claude-api/csharp/claude-api/README.md +0 -362
  67. package/skills/claude-api/csharp/claude-api/batches.md +0 -14
  68. package/skills/claude-api/csharp/claude-api/files-api.md +0 -25
  69. package/skills/claude-api/csharp/claude-api/streaming.md +0 -28
  70. package/skills/claude-api/csharp/claude-api/tool-use.md +0 -164
  71. package/skills/claude-api/curl/examples.md +0 -256
  72. package/skills/claude-api/curl/managed-agents.md +0 -371
  73. package/skills/claude-api/go/claude-api/README.md +0 -188
  74. package/skills/claude-api/go/claude-api/files-api.md +0 -23
  75. package/skills/claude-api/go/claude-api/streaming.md +0 -43
  76. package/skills/claude-api/go/claude-api/tool-use.md +0 -220
  77. package/skills/claude-api/go/managed-agents/README.md +0 -564
  78. package/skills/claude-api/java/claude-api/README.md +0 -238
  79. package/skills/claude-api/java/claude-api/files-api.md +0 -27
  80. package/skills/claude-api/java/claude-api/streaming.md +0 -24
  81. package/skills/claude-api/java/claude-api/tool-use.md +0 -228
  82. package/skills/claude-api/java/managed-agents/README.md +0 -443
  83. package/skills/claude-api/php/claude-api/README.md +0 -174
  84. package/skills/claude-api/php/claude-api/batches.md +0 -15
  85. package/skills/claude-api/php/claude-api/files-api.md +0 -13
  86. package/skills/claude-api/php/claude-api/streaming.md +0 -27
  87. package/skills/claude-api/php/claude-api/tool-use.md +0 -253
  88. package/skills/claude-api/php/managed-agents/README.md +0 -443
  89. package/skills/claude-api/python/claude-api/README.md +0 -565
  90. package/skills/claude-api/python/claude-api/batches.md +0 -198
  91. package/skills/claude-api/python/claude-api/files-api.md +0 -170
  92. package/skills/claude-api/python/claude-api/sdk-upgrade.md +0 -286
  93. package/skills/claude-api/python/claude-api/streaming.md +0 -179
  94. package/skills/claude-api/python/claude-api/tool-use.md +0 -627
  95. package/skills/claude-api/python/managed-agents/README.md +0 -335
  96. package/skills/claude-api/ruby/claude-api/README.md +0 -134
  97. package/skills/claude-api/ruby/claude-api/streaming.md +0 -16
  98. package/skills/claude-api/ruby/claude-api/tool-use.md +0 -41
  99. package/skills/claude-api/ruby/managed-agents/README.md +0 -394
  100. package/skills/claude-api/shared/admin-api.md +0 -179
  101. package/skills/claude-api/shared/agent-design.md +0 -101
  102. package/skills/claude-api/shared/anthropic-cli.md +0 -269
  103. package/skills/claude-api/shared/claude-platform-on-aws.md +0 -59
  104. package/skills/claude-api/shared/cost-optimization.md +0 -233
  105. package/skills/claude-api/shared/error-codes.md +0 -262
  106. package/skills/claude-api/shared/live-sources.md +0 -166
  107. package/skills/claude-api/shared/managed-agents-api-reference.md +0 -455
  108. package/skills/claude-api/shared/managed-agents-client-patterns.md +0 -220
  109. package/skills/claude-api/shared/managed-agents-core.md +0 -359
  110. package/skills/claude-api/shared/managed-agents-environments.md +0 -226
  111. package/skills/claude-api/shared/managed-agents-events.md +0 -290
  112. package/skills/claude-api/shared/managed-agents-memory.md +0 -201
  113. package/skills/claude-api/shared/managed-agents-multiagent.md +0 -260
  114. package/skills/claude-api/shared/managed-agents-onboarding.md +0 -82
  115. package/skills/claude-api/shared/managed-agents-outcomes.md +0 -108
  116. package/skills/claude-api/shared/managed-agents-overview.md +0 -77
  117. package/skills/claude-api/shared/managed-agents-scheduled-deployments.md +0 -156
  118. package/skills/claude-api/shared/managed-agents-self-hosted-sandboxes.md +0 -282
  119. package/skills/claude-api/shared/managed-agents-tools.md +0 -504
  120. package/skills/claude-api/shared/managed-agents-webhooks.md +0 -143
  121. package/skills/claude-api/shared/model-migration.md +0 -1872
  122. package/skills/claude-api/shared/models.md +0 -142
  123. package/skills/claude-api/shared/platform-availability.md +0 -53
  124. package/skills/claude-api/shared/prompt-audit.md +0 -223
  125. package/skills/claude-api/shared/prompt-caching.md +0 -292
  126. package/skills/claude-api/shared/token-counting.md +0 -56
  127. package/skills/claude-api/shared/tool-use-concepts.md +0 -526
  128. package/skills/claude-api/typescript/claude-api/README.md +0 -405
  129. package/skills/claude-api/typescript/claude-api/batches.md +0 -106
  130. package/skills/claude-api/typescript/claude-api/files-api.md +0 -98
  131. package/skills/claude-api/typescript/claude-api/streaming.md +0 -178
  132. package/skills/claude-api/typescript/claude-api/tool-use.md +0 -586
  133. package/skills/claude-api/typescript/managed-agents/README.md +0 -359
  134. package/tests/auto-permissions.test.mjs +0 -315
  135. package/tests/background-tasks.test.mjs +0 -27
  136. package/tests/background-worker.test.mjs +0 -60
  137. package/tests/bash-permissions.test.mjs +0 -276
  138. package/tests/glob.test.mjs +0 -31
  139. package/tests/mode.test.mjs +0 -62
  140. package/tests/permission-evaluation.test.mjs +0 -70
  141. package/tests/permission-foundation.test.mjs +0 -194
  142. package/tests/permission-policy.test.mjs +0 -158
  143. package/tests/runtime/README.md +0 -51
  144. package/tests/runtime/harness.mjs +0 -190
  145. package/tests/runtime/permissions.test.mjs +0 -192
  146. package/tests/runtime/tui.test.mjs +0 -60
  147. package/tests/task-integration.test.mjs +0 -324
  148. package/tests/task-permissions.test.mjs +0 -178
  149. package/tests/task-worktree-policy.test.mjs +0 -53
@@ -0,0 +1,269 @@
1
+ import assert from "node:assert/strict";
2
+ import { EventEmitter, getEventListeners } from "node:events";
3
+ import test from "node:test";
4
+ import { visibleWidth } from "@earendil-works/pi-tui";
5
+ import askQuestion from "../extensions/ask-question.ts";
6
+ import { normalizeQuestions, initialDraft, validateAnswer } from "../lib/question-model.ts";
7
+ import { QuestionScreen } from "../lib/question-ui.ts";
8
+
9
+ const theme = { fg: (_color, text) => text };
10
+ const keys = { up: "\x1b[A", down: "\x1b[B", enter: "\r", escape: "\x1b", tab: "\t", backtab: "\x1b[Z", space: " ", ctrlC: "\x03" };
11
+ const question = (patch = {}) => normalizeQuestions({ questions: [{ question: "Which?", options: [{ id: "a", label: "A", description: "First choice" }, { id: "b", label: "B" }], ...patch }] })[0];
12
+ const draft = (indices, patch = {}) => ({ indices, text: "", custom: false, ...patch });
13
+
14
+ function screen(questions = [question()], signal) {
15
+ const stream = new EventEmitter();
16
+ let completions = 0, result;
17
+ let resolve;
18
+ const promise = new Promise((done) => { resolve = done; });
19
+ const component = new QuestionScreen(questions, { requestRender() {}, terminal: { rows: 24 } }, theme, (value) => {
20
+ completions++;
21
+ result = value;
22
+ resolve(value);
23
+ }, signal, stream);
24
+ component.focused = true;
25
+ return { component, stream, promise, get result() { return result; }, get completions() { return completions; } };
26
+ }
27
+
28
+ function send(ui, ...inputs) { inputs.forEach((key) => ui.component.handleInput(keys[key] ?? key)); }
29
+
30
+ for (const [name, indices] of [["invalid", [999]], ["mixed valid/invalid", [0, 999]], ["malformed", ["1"]], ["fractional", [0.5]], ["negative", [-1]]]) {
31
+ test(`rejects ${name} selections without discarding values`, () => {
32
+ assert.throws(() => validateAnswer(question({ type: "multi" }), draft(indices)), /listed options/);
33
+ });
34
+ }
35
+
36
+ test("single and multi validation, deterministic duplicate removal, min/max limits", () => {
37
+ assert.equal(validateAnswer(question(), draft([1])).answer, "B");
38
+ const multi = question({ type: "multi" });
39
+ assert.deepEqual(validateAnswer(multi, draft([1, 0, 1])).optionIds, ["a", "b"]);
40
+ assert.deepEqual(validateAnswer(multi, draft([1, 0, 1])).answer, ["A", "B"]);
41
+ assert.throws(() => validateAnswer(question(), draft([])), /at least 1/);
42
+ assert.throws(() => validateAnswer(question({ type: "multi", minSelections: 0 }), draft([])), /at least 1/);
43
+ assert.throws(() => validateAnswer(question({ type: "multi", minSelections: 2 }), draft([0])), /at least 2/);
44
+ assert.throws(() => validateAnswer(question({ type: "multi", maxSelections: 1 }), draft([0, 1])), /at most 1/);
45
+ assert.equal(validateAnswer(question({ required: false }), draft([])).answer, "");
46
+ });
47
+
48
+ test("custom text is explicit, untrimmed, Unicode-aware and never parsed as numbers", () => {
49
+ const q = question({ allowOther: true, maxLength: 20 });
50
+ for (const text of ["1, 999", "1, hello", " café 🦊 & <> "]) {
51
+ assert.equal(validateAnswer(q, draft([], { custom: true, text })).answer, text);
52
+ }
53
+ assert.throws(() => validateAnswer(q, draft([], { custom: true, text: " " })), /required/);
54
+ assert.throws(() => validateAnswer(question(), draft([], { custom: true, text: "A" })), /not enabled/);
55
+ assert.throws(() => validateAnswer(q, draft([], { custom: true, text: "x".repeat(21) })), /at most 20/);
56
+ assert.equal(validateAnswer(question({ type: "text", options: [], required: false }), draft([], { custom: true, text: "" })).answer, "");
57
+ assert.equal(validateAnswer(question({ allowOther: true, maxLength: 1 }), draft([], { custom: true, text: "🦊" })).answer, "🦊");
58
+ });
59
+
60
+ test("normalization preserves legacy calls and rejects unusable contracts", () => {
61
+ assert.equal(question({ multiSelect: true }).type, "multi");
62
+ assert.equal(question({ options: [], allowOther: true }).type, "text");
63
+ assert.equal(question({ options: [], multiSelect: true, allowOther: true }).type, "text");
64
+ assert.deepEqual(initialDraft(question({ defaultSelection: "b" })).indices, [1]);
65
+ for (const patch of [
66
+ { options: [] }, { type: "single", options: [] }, { type: "text" },
67
+ { defaultSelection: "unknown" }, { defaultSelection: ["a", "b"] },
68
+ { type: "multi", minSelections: 2, maxSelections: 1 },
69
+ { type: "multi", maxSelections: 3 }, { type: "single", multiSelect: true },
70
+ { options: [{ id: "a", label: "A" }, { id: "a", label: "B" }] }
71
+ ]) assert.throws(() => question(patch));
72
+ assert.throws(() => normalizeQuestions({ questions: [] }));
73
+ assert.throws(() => normalizeQuestions({ questions: [{ ...question(), id: "same" }, { ...question(), id: "same" }] }));
74
+ });
75
+
76
+ test("arrow navigation, description rendering, default focus and Enter", async () => {
77
+ const ui = screen([question({ defaultSelection: "b" })]);
78
+ assert.match(ui.component.render(80).join("\n"), /❯ 2\. B/);
79
+ send(ui, "up");
80
+ assert.match(ui.component.render(80).join("\n"), /❯ 1\. A\n First choice/);
81
+ send(ui, "down", "enter");
82
+ assert.equal((await ui.promise).answers[0].answer, "B");
83
+ assert.equal(ui.stream.listenerCount("end"), 0);
84
+ assert.equal(ui.stream.listenerCount("close"), 0);
85
+ });
86
+
87
+ test("Space toggles multi selections; errors retain screen and allow retry", async () => {
88
+ const ui = screen([question({ type: "multi", minSelections: 2 })]);
89
+ send(ui, "space", "enter");
90
+ assert.equal(ui.result, undefined);
91
+ assert.match(ui.component.render(80).join("\n"), /at least 2/);
92
+ assert.match(ui.component.render(80).join("\n"), /❯ \[x\] 1\. A/);
93
+ send(ui, "space", "space", "down", "space", "enter");
94
+ assert.deepEqual((await ui.promise).answers[0].optionIds, ["a", "b"]);
95
+ const max = screen([question({ type: "multi", maxSelections: 1 })]);
96
+ send(max, "space", "down", "space", "enter");
97
+ assert.match(max.component.render(80).join("\n"), /at most 1/);
98
+ send(max, "space", "enter");
99
+ assert.deepEqual((await max.promise).answers[0].optionIds, ["a"]);
100
+ });
101
+
102
+ test("Other label collisions cannot activate text; Other is enabled only explicitly", async () => {
103
+ for (const allowOther of [false, true]) {
104
+ const ui = screen([question({ allowOther, options: [{ id: "other", label: "Other — type your own answer" }, { id: "__other__", label: "Type something else" }] })]);
105
+ send(ui, "enter");
106
+ assert.equal((await ui.promise).answers[0].custom, false);
107
+ assert.equal(ui.result.answers[0].answer, "Other — type your own answer");
108
+ }
109
+ const ui = screen([question({ allowOther: true })]);
110
+ send(ui, "down", "down", "enter", "enter");
111
+ assert.match(ui.component.render(80).join("\n"), /An answer is required/);
112
+ send(ui, "1, 999 café 🦊", "enter");
113
+ assert.equal((await ui.promise).answers[0].answer, "1, 999 café 🦊");
114
+ assert.equal(ui.result.answers[0].custom, true);
115
+ });
116
+
117
+ test("text required/optional, optional single skip, and maximum text length", async () => {
118
+ const ui = screen([question({ type: "text", options: [], maxLength: 3 })]);
119
+ send(ui, " ", "enter");
120
+ assert.equal(ui.result, undefined);
121
+ send(ui, "abcd", "enter");
122
+ assert.match(ui.component.render(80).join("\n"), /at most 3/);
123
+ send(ui, "escape");
124
+ assert.equal((await ui.promise).status, "cancelled");
125
+ const optional = screen([question({ type: "text", options: [], required: false })]);
126
+ send(optional, "enter");
127
+ assert.equal((await optional.promise).answers[0].answer, "");
128
+ assert.equal(optional.result.status, "answered");
129
+ const skip = screen([question({ required: false })]);
130
+ send(skip, "up", "enter");
131
+ assert.equal((await skip.promise).answers[0].answer, "");
132
+ });
133
+
134
+ test("Tab navigation preserves text and selections; review requires every confirmation", async () => {
135
+ const ui = screen([question(), { ...question({ type: "text", options: [] }), id: "second" }]);
136
+ send(ui, "down", "enter", "hello", "backtab");
137
+ assert.match(ui.component.render(80).join("\n"), /Question 1\/2/);
138
+ send(ui, "tab");
139
+ assert.match(ui.component.render(80).join("\n"), /hello/);
140
+ send(ui, "tab", "enter");
141
+ assert.match(ui.component.render(80).join("\n"), /Confirm this question/);
142
+ send(ui, "enter");
143
+ assert.match(ui.component.render(80).join("\n"), /Review answers/);
144
+ send(ui, "enter");
145
+ assert.deepEqual((await ui.promise).answers.map((a) => a.answer), ["B", "hello"]);
146
+ });
147
+
148
+ test("abort before render, pending abort, both submit/abort races, listener cleanup", async () => {
149
+ for (const order of ["before", "pending", "submit-first", "abort-first"]) {
150
+ const controller = new AbortController();
151
+ if (order === "before") controller.abort();
152
+ const ui = screen(undefined, controller.signal);
153
+ if (order === "submit-first") send(ui, "enter");
154
+ controller.abort();
155
+ send(ui, "enter");
156
+ const result = await ui.promise;
157
+ assert.equal(result.status, order === "submit-first" ? "answered" : "aborted");
158
+ assert.equal(ui.completions, 1);
159
+ assert.equal(getEventListeners(controller.signal, "abort").length, 0);
160
+ assert.equal(ui.stream.listenerCount("end"), 0);
161
+ assert.deepEqual(ui.component.render(80), []);
162
+ }
163
+ });
164
+
165
+ test("Esc/Ctrl+C cancellation and unexpected input closure always settle", async () => {
166
+ for (const key of ["escape", "ctrlC"]) {
167
+ const ui = screen();
168
+ send(ui, key);
169
+ assert.equal((await ui.promise).status, "cancelled");
170
+ }
171
+ for (const event of ["end", "close", "error"]) {
172
+ const ui = screen();
173
+ ui.stream.emit(event, new Error("broken input"));
174
+ assert.equal((await ui.promise).status, "error");
175
+ assert.equal(ui.stream.listenerCount(event), 0);
176
+ }
177
+ });
178
+
179
+ test("narrow terminals, long menus and untrusted labels render safely", () => {
180
+ const ui = screen([question({ question: "Very long question 🦊", options: Array.from({ length: 50 }, (_, i) => ({ label: `Choice ${i}\x1b[2J` })) })]);
181
+ for (const width of [1, 8, 20, 80]) {
182
+ const lines = ui.component.render(width);
183
+ assert.ok(lines.every((line) => visibleWidth(line) <= width));
184
+ assert.ok(lines.length < 24);
185
+ assert.ok(lines.every((line) => !line.includes("\x1b[2J")));
186
+ }
187
+ send(ui, "up");
188
+ assert.match(ui.component.render(80).join("\n"), /❯ 50\. Choice 49/);
189
+ send(ui, "escape");
190
+ });
191
+
192
+ test("render/input failures settle rather than leaving an active question", async () => {
193
+ let result;
194
+ const stream = new EventEmitter();
195
+ const ui = new QuestionScreen([question()], { requestRender() { throw new Error("render unavailable"); }, terminal: { rows: 24 } }, theme, (value) => { result = value; }, undefined, stream);
196
+ ui.handleInput(keys.down);
197
+ assert.equal(result.status, "error");
198
+ assert.equal(stream.listenerCount("end"), 0);
199
+ const badTheme = new QuestionScreen([question()], { requestRender() {}, terminal: { rows: 24 } }, { fg() { throw new Error("bad theme"); } }, (value) => { result = value; }, undefined, stream);
200
+ badTheme.render(80);
201
+ assert.equal(result.status, "error");
202
+ });
203
+
204
+ async function tty(work) {
205
+ const streams = [process.stdin, process.stdout];
206
+ const saved = streams.map((stream) => Object.getOwnPropertyDescriptor(stream, "isTTY"));
207
+ streams.forEach((stream) => Object.defineProperty(stream, "isTTY", { configurable: true, value: true }));
208
+ try { await work(); } finally {
209
+ streams.forEach((stream, i) => saved[i] ? Object.defineProperty(stream, "isTTY", saved[i]) : delete stream.isTTY);
210
+ }
211
+ }
212
+
213
+ function toolHarness() {
214
+ let tool, component, restorations = 0;
215
+ const handlers = new Map();
216
+ askQuestion({ registerTool(value) { tool = value; }, on(name, handler) { handlers.set(name, handler); } });
217
+ const ctx = { mode: "tui", hasUI: true, ui: { custom(factory) {
218
+ return new Promise((resolve) => {
219
+ component = factory({ requestRender() {}, terminal: { rows: 24 } }, theme, {}, (value) => { restorations++; resolve(value); });
220
+ });
221
+ } } };
222
+ const params = { questions: [{ question: "Which?", options: [{ label: "A" }] }] };
223
+ return { tool, ctx, params, handlers, get component() { return component; }, get restorations() { return restorations; } };
224
+ }
225
+
226
+ test("result renderer supports retained legacy sessions and missing details", () => {
227
+ const { tool } = toolHarness();
228
+ const old = { content: [], details: { answers: [{ question: "Which?", answer: "A" }], cancelled: false } };
229
+ assert.match(tool.renderResult(old, {}, theme).render(80).join("\n"), /✓ Which\?: A/);
230
+ assert.match(tool.renderResult({ content: [{ type: "text", text: "Original result" }] }, {}, theme).render(80).join("\n"), /Original result/);
231
+ });
232
+
233
+ test("tool protects non-TTY, legacy calls, concurrency, shutdown and failures", async () => {
234
+ const h = toolHarness();
235
+ assert.equal((await h.tool.execute("id", h.params, undefined, undefined, h.ctx)).details.status, "requires_interaction");
236
+ await tty(async () => {
237
+ for (const patch of [{ hasUI: false }, { mode: "json" }, { mode: "rpc" }]) {
238
+ assert.equal((await h.tool.execute("unavailable", h.params, undefined, undefined, { ...h.ctx, ...patch })).details.status, "requires_interaction");
239
+ }
240
+ const first = h.tool.execute("id", h.params, undefined, undefined, h.ctx);
241
+ const conflict = await h.tool.execute("second", h.params, undefined, undefined, h.ctx);
242
+ assert.equal(conflict.details.status, "error");
243
+ assert.match(conflict.details.error, /already active/);
244
+ h.component.handleInput(keys.enter);
245
+ const result = await first;
246
+ assert.equal(result.details.status, "answered");
247
+ assert.equal(result.details.answers[0].answer, "A");
248
+ assert.deepEqual(JSON.parse(result.content[0].text), result.details);
249
+ assert.equal(h.restorations, 1);
250
+ const pending = h.tool.execute("next", h.params, undefined, undefined, h.ctx);
251
+ await h.handlers.get("session_shutdown")();
252
+ assert.equal((await pending).details.status, "aborted");
253
+ const invalid = await h.tool.execute("bad", { questions: [] }, undefined, undefined, h.ctx);
254
+ assert.equal(invalid.details.status, "error");
255
+ const failed = await h.tool.execute("bad", h.params, undefined, undefined, { ...h.ctx, ui: { custom() { throw new Error("UI unavailable"); } } });
256
+ assert.equal(failed.details.status, "error");
257
+ const controller = new AbortController();
258
+ controller.abort();
259
+ const aborted = await h.tool.execute("aborted", h.params, controller.signal, undefined, h.ctx);
260
+ assert.equal(aborted.details.status, "aborted");
261
+ const runningController = new AbortController();
262
+ const running = h.tool.execute("agent-abort", h.params, runningController.signal, undefined, h.ctx);
263
+ runningController.abort();
264
+ assert.equal((await running).details.status, "aborted");
265
+ const waiting = h.tool.execute("waiting", h.params, undefined, undefined, h.ctx);
266
+ h.component.handleInput(keys.escape);
267
+ assert.equal((await waiting).details.status, "cancelled");
268
+ });
269
+ });
@@ -0,0 +1,55 @@
1
+ // Deterministic, offline provider for the real CLI/subagent integration test.
2
+ import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
3
+
4
+ export default function (pi: any) {
5
+ pi.registerProvider("piexis-test", {
6
+ api: "piexis-test-api",
7
+ apiKey: "disposable-test-key",
8
+ baseUrl: "http://unused.invalid",
9
+ models: ["main", "small"].map((id) => ({
10
+ id, name: id, reasoning: true, input: ["text"], contextWindow: 32000, maxTokens: 1024,
11
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
12
+ })),
13
+ streamSimple(model: any, context: any) {
14
+ const stream = createAssistantMessageEventStream();
15
+ queueMicrotask(() => {
16
+ const last = context.messages.at(-1);
17
+ const depth = Number(process.env.PIEXIS_SUBAGENT_DEPTH ?? 0);
18
+ const delegated = depth > 0;
19
+ const orchestration = !delegated && JSON.stringify(last?.content).includes("orchestrator");
20
+ const exploration = !delegated && JSON.stringify(last?.content).includes("exploration");
21
+ const orchestrator = /Piexis agent profile: orchestrator/.test(context.systemPrompt ?? JSON.stringify(context.messages.filter((message: any) => message.role === "system")));
22
+ const delegate = last?.role === "user" && (!delegated || (depth === 1 && orchestrator));
23
+ const content = delegate
24
+ ? [{ type: "toolCall", id: `delegate-${depth}`, name: "subagent", arguments: {
25
+ agent: orchestration ? "orchestrator" : "ask", ...(exploration ? { purpose: "explore" } : {}),
26
+ task: orchestration ? "Coordinate a configuration lookup" : "Locate the configuration loader",
27
+ } }]
28
+ : [{ type: "text", text: delegated
29
+ ? (orchestrator ? `orchestrated:${JSON.stringify(last?.content)}` : `child:${model.id};depth:${depth}`)
30
+ : "parent completed" }];
31
+ const message = {
32
+ role: "assistant", api: "piexis-test-api", provider: model.provider, model: model.id,
33
+ timestamp: Date.now(), content: [], stopReason: "pending",
34
+ usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, totalTokens: 15,
35
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
36
+ };
37
+ stream.push({ type: "start", partial: message });
38
+ message.content = content as any;
39
+ const block = content[0];
40
+ if (block.type === "toolCall") {
41
+ stream.push({ type: "toolcall_start", contentIndex: 0, partial: message });
42
+ stream.push({ type: "toolcall_end", contentIndex: 0, toolCall: block, partial: message });
43
+ } else {
44
+ stream.push({ type: "text_start", contentIndex: 0, partial: message });
45
+ stream.push({ type: "text_delta", contentIndex: 0, delta: block.text, partial: message });
46
+ stream.push({ type: "text_end", contentIndex: 0, content: block.text, partial: message });
47
+ }
48
+ message.stopReason = block.type === "toolCall" ? "toolUse" : "stop";
49
+ stream.push({ type: "done", reason: message.stopReason, message });
50
+ stream.end();
51
+ });
52
+ return stream;
53
+ },
54
+ });
55
+ }
@@ -0,0 +1,91 @@
1
+ """Drive actual pi-tui keyboard input using only Python's standard library."""
2
+ import errno
3
+ import fcntl
4
+ import json
5
+ import os
6
+ import pty
7
+ import select
8
+ import struct
9
+ import subprocess
10
+ import sys
11
+ import termios
12
+ import time
13
+
14
+ fixture, scenario, node = sys.argv[1:]
15
+ startup = scenario.startswith("setup-")
16
+ ready_text = b"Piexis setup for this project?" if startup else b"Question 1/"
17
+ master, slave = pty.openpty()
18
+ fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 72, 0, 0))
19
+ before = termios.tcgetattr(slave)
20
+ child = subprocess.Popen([node, fixture, scenario], stdin=slave, stdout=slave, stderr=slave,
21
+ env={**os.environ, "TERM": "xterm-256color"})
22
+ output = bytearray()
23
+
24
+ def drain(wait=0.1):
25
+ if select.select([master], [], [], wait)[0]:
26
+ try:
27
+ output.extend(os.read(master, 65536))
28
+ except OSError as error:
29
+ if error.errno != errno.EIO:
30
+ raise
31
+
32
+ actions = {
33
+ "single": [b"\x1b[B", b"\r"],
34
+ "multi": [b"\r", b" ", b"\x1b[B", b" ", b"\r"],
35
+ "text": [b"\r", "\x1b[200~1, 999 café 🦊 & <>\x1b[201~".encode(), b"\r"],
36
+ "other": [b"\x1b[B", b"\x1b[B", b"\r", b"1, 999", b"\r"],
37
+ "navigation": [b"\x1b[B", b"\r", b"hello", b"\x1b[Z", b"\t", b"\r", b"\r"],
38
+ "cancel": [b"\x1b"],
39
+ "ctrl-c": [b"\x03"],
40
+ "abort": [b"!"],
41
+ "setup-enable": [b"\x1b[B", b"\r"],
42
+ "setup-agent-upgrade": [b"\x1b[B", b"\r"],
43
+ "setup-not-now": [b"\r"],
44
+ "setup-disabled": [b"\x1b[B", b"\x1b[B", b"\r"],
45
+ }
46
+ try:
47
+ deadline = time.monotonic() + 8
48
+ while ready_text not in output and child.poll() is None and time.monotonic() < deadline:
49
+ drain()
50
+ if ready_text not in output:
51
+ raise AssertionError("Question did not render: " + output.decode(errors="replace"))
52
+ for action in actions[scenario]:
53
+ os.write(master, action)
54
+ end = time.monotonic() + 0.15
55
+ while time.monotonic() < end:
56
+ drain(0.03)
57
+ while child.poll() is None and time.monotonic() < deadline:
58
+ drain()
59
+ if child.poll() is None:
60
+ raise AssertionError("Terminal interaction did not settle")
61
+ drain()
62
+ assert child.returncode == 0, output.decode(errors="replace")
63
+ assert termios.tcgetattr(slave) == before, "Terminal attributes were not restored"
64
+ assert b"\x1b[?25h" in output, "Cursor was not restored"
65
+ decoded = output.decode(errors="replace")
66
+ result_line = decoded.split("RESULT:")[-1].splitlines()[0]
67
+ result = json.loads(result_line)
68
+ assert result["rawRestored"] and result["listenersRestored"] and result["restored"] == 1
69
+ if scenario in ("single", "navigation"):
70
+ assert result["answers"][0]["optionIds"] == ["b"]
71
+ if scenario == "multi":
72
+ assert "Choose at least 2" in decoded
73
+ assert result["answers"][0]["optionIds"] == ["a", "b"]
74
+ assert "[x]" in decoded
75
+ if scenario == "text":
76
+ assert "An answer is required" in decoded
77
+ assert result["answers"][0]["answer"] == "1, 999 café 🦊 & <>"
78
+ if scenario == "other":
79
+ assert result["answers"][0]["answer"] == "1, 999"
80
+ assert result["answers"][0]["custom"]
81
+ if scenario == "navigation":
82
+ assert result["answers"][1]["answer"] == "hello"
83
+ expected = "aborted" if scenario == "abort" else "cancelled" if scenario in ("cancel", "ctrl-c") else "answered"
84
+ assert result["status"] == expected, result
85
+ print(json.dumps({"scenario": scenario, "status": result["status"], "terminalRestored": True}))
86
+ finally:
87
+ if child.poll() is None:
88
+ child.kill()
89
+ child.wait()
90
+ os.close(master)
91
+ os.close(slave)
@@ -0,0 +1,69 @@
1
+ import assert from "node:assert/strict";
2
+ import { Input, ProcessTerminal, TuiMainScreen } from "@earendil-works/pi-tui";
3
+ import askQuestion from "../../extensions/ask-question.ts";
4
+
5
+ const scenario = process.argv[2];
6
+ const controller = new AbortController();
7
+ let tool;
8
+ askQuestion({ on() {}, registerTool(value) { tool = value; } });
9
+ const terminal = new ProcessTerminal();
10
+ const tui = new TuiMainScreen(terminal, true);
11
+ const editor = new Input();
12
+ const originalRaw = !!process.stdin.isRaw;
13
+ const originalDataListeners = process.stdin.listenerCount("data");
14
+ const originalEndListeners = process.stdin.listenerCount("end");
15
+ const theme = { fg: (_color, text) => text };
16
+ let restored = 0;
17
+
18
+ // Exercise the production component with Pi's real renderer/input transport.
19
+ // This adapter mirrors ctx.ui.custom's editor restoration/completion contract.
20
+ const ctx = { mode: "tui", hasUI: true, ui: { custom(factory) {
21
+ return new Promise((resolve) => {
22
+ let component;
23
+ let closed = false;
24
+ const done = (value) => {
25
+ if (closed) return;
26
+ closed = true;
27
+ tui.clear();
28
+ tui.addChild(editor);
29
+ tui.setFocus(editor);
30
+ tui.requestRender();
31
+ component?.dispose();
32
+ restored++;
33
+ resolve(value);
34
+ };
35
+ component = factory(tui, theme, {}, done);
36
+ if (!closed) {
37
+ tui.clear();
38
+ tui.addChild(component);
39
+ tui.setFocus(component);
40
+ tui.requestRender();
41
+ }
42
+ });
43
+ } } };
44
+ const options = [{ id: "a", label: "JWT Authentication", description: "Stateless tokens" }, { id: "b", label: "OAuth 2.0" }];
45
+ let questions = [{ question: "Authentication method?", options }];
46
+ if (scenario === "multi") questions[0] = { ...questions[0], multiSelect: true, minSelections: 2 };
47
+ if (scenario === "text") questions = [{ question: "Your response?", type: "text", required: true }];
48
+ if (scenario === "other") questions[0].allowOther = true;
49
+ if (scenario === "navigation") questions.push({ question: "Your response?", type: "text" });
50
+ const removeListener = tui.addInputListener((data) => {
51
+ if (data === "!") { controller.abort(); return { consume: true }; }
52
+ });
53
+ tui.addChild(editor);
54
+ tui.setFocus(editor);
55
+ tui.start();
56
+ let details;
57
+ try {
58
+ details = (await tool.execute("pty", { questions }, controller.signal, undefined, ctx)).details;
59
+ assert.equal(restored, 1);
60
+ assert.equal(tui.getFocusedComponent(), editor);
61
+ assert.equal(editor.focused, true);
62
+ assert.equal(process.stdin.listenerCount("end"), originalEndListeners);
63
+ } finally {
64
+ removeListener();
65
+ tui.stop();
66
+ }
67
+ assert.equal(!!process.stdin.isRaw, originalRaw);
68
+ assert.equal(process.stdin.listenerCount("data"), originalDataListeners);
69
+ process.stdout.write(`\nRESULT:${JSON.stringify({ ...details, restored, rawRestored: true, listenersRestored: true })}\n`);
@@ -0,0 +1,103 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { getEventListeners } from "node:events";
6
+ import { ExtensionSelectorComponent, initTheme } from "@earendil-works/pi-coding-agent";
7
+ import { Input, ProcessTerminal, TuiMainScreen } from "@earendil-works/pi-tui";
8
+ import recommendedTools from "../../extensions/recommended-tools.ts";
9
+
10
+ const scenario = process.argv[2];
11
+ const root = await mkdtemp(join(tmpdir(), "piexis-setup-pty-"));
12
+ const cwd = join(root, "project");
13
+ await mkdir(cwd);
14
+ process.env.PI_CODING_AGENT_DIR = join(root, "agent");
15
+ initTheme("dark", false);
16
+ const handlers = new Map();
17
+ let active = ["read", "bash", "edit", "write", "AskQuestion"];
18
+ if (scenario === "setup-agent-upgrade") {
19
+ active.push("grep", "find", "ls");
20
+ await mkdir(join(cwd, ".pi"));
21
+ await writeFile(join(cwd, ".pi", "settings.json"), JSON.stringify({
22
+ defaultTools: ["read", "bash", "edit", "write", "grep", "find", "ls"],
23
+ piexis: { recommendedTools: "enabled" }
24
+ }));
25
+ }
26
+ const available = ["read", "bash", "edit", "write", "grep", "find", "ls"].map((name) => ({ name, sourceInfo: { source: "builtin" } }));
27
+ available.push({ name: "subagent", sourceInfo: { source: "extension" } });
28
+ recommendedTools({
29
+ on(name, handler) { handlers.set(name, handler); },
30
+ getActiveTools: () => active,
31
+ getAllTools: () => available,
32
+ setActiveTools: (tools) => { active = tools; }
33
+ });
34
+ const originalRaw = !!process.stdin.isRaw;
35
+ const originalListeners = process.stdin.listenerCount("data");
36
+ const tui = new TuiMainScreen(new ProcessTerminal(), true);
37
+ const editor = new Input();
38
+ let restored = 0;
39
+ const ctx = { cwd, mode: "tui", hasUI: true, model: { provider: "test", id: "default" }, isProjectTrusted: () => true, ui: {
40
+ notify() {},
41
+ select(title, options, { signal }) {
42
+ return new Promise((resolve) => {
43
+ let closed = false;
44
+ const finish = (value) => {
45
+ if (closed) return;
46
+ closed = true;
47
+ signal.removeEventListener("abort", abort);
48
+ selector.dispose();
49
+ tui.clear();
50
+ tui.addChild(editor);
51
+ tui.setFocus(editor);
52
+ tui.requestRender();
53
+ restored++;
54
+ resolve(value);
55
+ };
56
+ const abort = () => finish(undefined);
57
+ const selector = new ExtensionSelectorComponent(title, options, finish, () => finish(undefined));
58
+ signal.addEventListener("abort", abort, { once: true });
59
+ tui.clear();
60
+ tui.addChild(selector);
61
+ tui.setFocus(selector);
62
+ tui.requestRender();
63
+ if (signal.aborted) abort();
64
+ }).then((value) => {
65
+ assert.equal(getEventListeners(signal, "abort").length, 0);
66
+ return value;
67
+ });
68
+ }
69
+ } };
70
+ tui.addChild(editor);
71
+ tui.setFocus(editor);
72
+ tui.start();
73
+ let saved;
74
+ try {
75
+ await handlers.get("session_start")({}, ctx);
76
+ assert.equal(restored, 1);
77
+ assert.equal(tui.getFocusedComponent(), editor);
78
+ try { saved = JSON.parse(await readFile(join(cwd, ".pi", "settings.json"), "utf8")); }
79
+ catch (error) { if (error.code !== "ENOENT") throw error; }
80
+ if (scenario === "setup-enable" || scenario === "setup-agent-upgrade") {
81
+ assert.equal(saved.piexis.recommendedTools, "enabled");
82
+ assert.deepEqual(active, ["read", "bash", "edit", "write", "AskQuestion", "grep", "find", "ls"]);
83
+ const agentConfig = JSON.parse(await readFile(join(cwd, ".pi", "agent.json"), "utf8"));
84
+ assert.equal(agentConfig.model, "test/default");
85
+ assert.deepEqual(agentConfig.subagent_variant_overrides, {
86
+ plan: { model: "test/default", effort: "high" },
87
+ orchestrator: { model: "test/default", effort: "high" },
88
+ debug: { model: "test/default", effort: "high" },
89
+ ask: { model: "test/default", effort: "low" },
90
+ code: { model: "test/default", effort: "medium" },
91
+ });
92
+ if (scenario === "setup-agent-upgrade") assert.deepEqual(saved.defaultTools, ["read", "bash", "edit", "write", "grep", "find", "ls"]);
93
+ } else if (scenario === "setup-disabled") {
94
+ assert.deepEqual(saved, { piexis: { recommendedTools: "disabled", agentSetup: "disabled" } });
95
+ } else assert.equal(saved, undefined);
96
+ if (scenario !== "setup-enable" && scenario !== "setup-agent-upgrade") await assert.rejects(readFile(join(cwd, ".pi", "agent.json")), { code: "ENOENT" });
97
+ } finally {
98
+ tui.stop();
99
+ await rm(root, { recursive: true, force: true });
100
+ }
101
+ assert.equal(!!process.stdin.isRaw, originalRaw);
102
+ assert.equal(process.stdin.listenerCount("data"), originalListeners);
103
+ process.stdout.write(`\nRESULT:${JSON.stringify({ status: "answered", restored, rawRestored: true, listenersRestored: true, scenario })}\n`);
@@ -0,0 +1,31 @@
1
+ import assert from "node:assert/strict";
2
+ import { access, readFile } from "node:fs/promises";
3
+ import test from "node:test";
4
+
5
+ const root = new URL("../", import.meta.url);
6
+
7
+ test("init writes skill workflow guidance without installing or granting permissions", async () => {
8
+ const prompt = await readFile(new URL("prompts/init.md", root), "utf8");
9
+ assert.match(prompt, /Include an \*\*Agent workflow\*\* section/);
10
+ for (const name of ["caveman", "grilling", "ponytail"]) {
11
+ assert.match(prompt, new RegExp(`\\*\\*${name}:\\*\\*`));
12
+ await access(new URL(`skills/${name}/SKILL.md`, root));
13
+ }
14
+ assert.match(prompt, /Read the matching `SKILL\.md`/);
15
+ assert.match(prompt, /otherwise ask in chat/);
16
+ assert.match(prompt, /Cancellation is not consent/);
17
+ assert.match(prompt, /not runtime enforcement or tool permissions/);
18
+ assert.match(prompt, /do not install anything during `\/init`/);
19
+ assert.match(prompt, /Only create or edit `AGENTS\.md`/);
20
+ });
21
+
22
+ test("bundled third-party skills retain provenance and licenses", async () => {
23
+ for (const name of ["caveman", "ponytail"]) {
24
+ await access(new URL(`skills/${name}/LICENSE`, root));
25
+ const notice = await readFile(new URL(`skills/${name}/NOTICE`, root), "utf8");
26
+ assert.match(notice, /https:\/\/github\.com\//);
27
+ assert.match(notice, /Commit: [a-f0-9]{40}/);
28
+ }
29
+ const caveman = await readFile(new URL("skills/caveman/SKILL.md", root), "utf8");
30
+ assert.doesNotMatch(caveman, /`(?:ultracave|megacave|\/caveman-compress)`/);
31
+ });
@@ -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/recommended-tools-terminal.mjs", import.meta.url));
9
+
10
+ for (const scenario of ["setup-enable", "setup-agent-upgrade", "setup-not-now", "setup-disabled"]) {
11
+ test(`real terminal startup: ${scenario}`, { 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
+ }