@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,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
+ }