@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,205 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { resolve } from "node:path";
3
+ import { Type } from "typebox";
4
+ import { Key } from "@earendil-works/pi-tui";
5
+ import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import * as piSdk from "@earendil-works/pi-coding-agent";
7
+ import {
8
+ agentNames, childResourceArgs, completeProjectAgentConfig, isAgentName, loadAgentConfig, profiles, routeSubagent, subagentTools,
9
+ type AgentName, type Effort,
10
+ } from "../lib/agents.ts";
11
+ import { limitOutput, piInvocation, runSubagent } from "../lib/subagent-runner.ts";
12
+
13
+ const AgentSchema = Type.Union(agentNames.map((name) => Type.Literal(name)));
14
+ const parameters = Type.Object({
15
+ agent: AgentSchema,
16
+ purpose: Type.Optional(Type.Literal("explore", { description: "Use the configured small_model for read-only codebase exploration; available only for ask when delegate_exploration is enabled." })),
17
+ task: Type.String({ minLength: 1, maxLength: 50_000, description: "Self-contained task including needed context; conversation history is not inherited." }),
18
+ });
19
+ const stateType = "piexis-agent";
20
+
21
+ export default function agentsExtension(pi: ExtensionAPI) {
22
+ let active: AgentName = "code";
23
+ let explorationEnabled = false;
24
+ let switching = false;
25
+ const depthText = process.env.PIEXIS_SUBAGENT_DEPTH ?? "0";
26
+ const depth = /^[0-2]$/.test(depthText) ? Number(depthText) : 2;
27
+ const jobs = new Map<AbortController, Promise<unknown>>();
28
+ const extensionPath = fileURLToPath(import.meta.url);
29
+
30
+ pi.registerFlag("agent", { description: `Agent profile: ${agentNames.join(", ")}`, type: "string" });
31
+
32
+ function status(ctx: ExtensionContext) {
33
+ if (ctx.hasUI) ctx.ui.setStatus("piexis-agent", `agent:${active}`);
34
+ }
35
+
36
+ async function activate(name: AgentName, ctx: ExtensionContext) {
37
+ if (switching || !ctx.isIdle() || jobs.size > 0) {
38
+ ctx.ui.notify("Wait for the current agent to finish before switching profiles.", "warning");
39
+ return;
40
+ }
41
+ switching = true;
42
+ try {
43
+ const config = await loadAgentConfig(ctx.cwd, getAgentDir(), ctx.isProjectTrusted());
44
+ const override = config.subagent_variant_overrides?.[name];
45
+ const modelId = override?.model ?? config.model;
46
+ if (modelId) {
47
+ const slash = modelId.indexOf("/");
48
+ const model = ctx.modelRegistry.find(modelId.slice(0, slash), modelId.slice(slash + 1));
49
+ if (!model || !(await pi.setModel(model))) throw new Error(`Configured model unavailable: ${modelId}. Check /model and /login.`);
50
+ }
51
+ if (override?.effort !== undefined) pi.setThinkingLevel(override.effort);
52
+ active = name;
53
+ pi.appendEntry(stateType, { agent: active });
54
+ status(ctx);
55
+ ctx.ui.notify(`Agent: ${active}`, "info");
56
+ } catch (error) {
57
+ ctx.ui.notify(`Agents: ${error instanceof Error ? error.message : String(error)}`, "warning");
58
+ } finally {
59
+ switching = false;
60
+ }
61
+ }
62
+
63
+ function restore(ctx: ExtensionContext, applyFlag = false) {
64
+ active = "code";
65
+ for (const entry of ctx.sessionManager.getBranch()) {
66
+ if (entry.type !== "custom" || entry.customType !== stateType) continue;
67
+ const agent = (entry.data as { agent?: unknown } | undefined)?.agent;
68
+ if (isAgentName(agent)) active = agent;
69
+ }
70
+ const flag = applyFlag ? pi.getFlag("agent") : undefined;
71
+ if (isAgentName(flag)) active = flag;
72
+ else if (flag !== undefined && flag !== "") ctx.ui.notify(`Unknown agent: ${flag}. Choose ${agentNames.join(", ")}.`, "warning");
73
+ status(ctx);
74
+ }
75
+
76
+ pi.on("session_start", async (_event, ctx) => {
77
+ restore(ctx, true);
78
+ try { explorationEnabled = Boolean((await loadAgentConfig(ctx.cwd, getAgentDir(), ctx.isProjectTrusted())).delegate_exploration); }
79
+ catch { explorationEnabled = false; }
80
+ if (depth >= 2) pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "subagent"));
81
+ // Only initialize a new main session; explicit CLI choices and resumed models remain authoritative.
82
+ if (depth !== 0 || ctx.sessionManager.getBranch().some((entry) => entry.type === "message")) return;
83
+ const args = process.argv.slice(2).slice(0, process.argv.indexOf("--") < 0 ? undefined : process.argv.indexOf("--") - 2);
84
+ if (args.some((arg) => arg === "--model" || arg === "--models" || arg.startsWith("--model=") || arg.startsWith("--models="))) return;
85
+ try {
86
+ const config = await loadAgentConfig(ctx.cwd, getAgentDir(), ctx.isProjectTrusted());
87
+ if (config.model) {
88
+ const slash = config.model.indexOf("/");
89
+ const model = ctx.modelRegistry.find(config.model.slice(0, slash), config.model.slice(slash + 1));
90
+ if (!model || !(await pi.setModel(model))) throw new Error(`Configured model unavailable: ${config.model}`);
91
+ }
92
+ } catch (error) { ctx.ui.notify(`Agents: ${error instanceof Error ? error.message : String(error)}`, "warning"); }
93
+ });
94
+ pi.on("session_tree", (_event, ctx) => restore(ctx));
95
+
96
+ pi.registerCommand("agent-config", {
97
+ description: "Fill missing project agent.json settings with defaults without replacing configured values",
98
+ handler: async (_args, ctx) => {
99
+ await ctx.waitForIdle();
100
+ if (!ctx.isProjectTrusted()) { ctx.ui.notify("Agent configuration is available only in trusted projects.", "warning"); return; }
101
+ try {
102
+ const currentModel = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
103
+ const result = await completeProjectAgentConfig(ctx.cwd, getAgentDir(), currentModel);
104
+ const message = result === "created" ? "Created .pi/agent.json with default agent settings."
105
+ : result === "updated" ? "Added missing .pi/agent.json settings; existing values were preserved."
106
+ : "Your project agent configuration is already complete.";
107
+ ctx.ui.notify(message, "info");
108
+ } catch (error) {
109
+ ctx.ui.notify(`Agent configuration was not updated: ${error instanceof Error ? error.message : String(error)}`, "warning");
110
+ }
111
+ },
112
+ });
113
+
114
+ pi.registerCommand("agent", {
115
+ description: "Switch agent profile (plan, orchestrator, debug, ask, code)",
116
+ handler: async (args, ctx) => {
117
+ await ctx.waitForIdle();
118
+ let name: string | undefined = args.trim() || undefined;
119
+ if (!name) {
120
+ if (!ctx.hasUI) { ctx.ui.notify(`Current agent: ${active}. Use /agent <${agentNames.join("|")}>.`, "info"); return; }
121
+ name = await ctx.ui.select(`Agent profile (current: ${active})`, [...agentNames]);
122
+ if (!name) return;
123
+ }
124
+ if (!isAgentName(name)) { ctx.ui.notify(`Unknown agent: ${name}. Choose ${agentNames.join(", ")}.`, "error"); return; }
125
+ await activate(name, ctx);
126
+ },
127
+ });
128
+
129
+ pi.registerShortcut(Key.alt("a"), {
130
+ description: "Cycle agent profile (plan → orchestrator → debug → ask → code)",
131
+ handler: async (ctx) => {
132
+ if (ctx.mode !== "tui") return;
133
+ await activate(agentNames[(agentNames.indexOf(active) + 1) % agentNames.length], ctx);
134
+ },
135
+ });
136
+
137
+ pi.on("before_agent_start", (event) => ({
138
+ systemPrompt: `${event.systemPrompt}\n\nPiexis agent profile: ${active}\n${profiles[active]}\n${depth > 0
139
+ ? "You are a delegated subagent. Work only on the supplied task. If user input is needed, return the question to the parent; no terminal interaction is available. Report changed files, results, and unexecuted checks. If the subagent tool is available, use purpose: explore only for bounded read-only codebase exploration and return concise file paths, line numbers, and uncertainties."
140
+ : explorationEnabled
141
+ ? "All five profiles are callable through subagent. Delegate only when a separate context helps; supply a focused task and all relevant context. For bounded codebase exploration involving discovery, several files, or a summary, delegate to subagent with agent: ask and purpose: explore. It uses the configured small model and must return concise findings with file paths, line numbers, and uncertainties. Keep planning, judgment, edits, and final verification here; do not delegate a trivial single read."
142
+ : "All five profiles are callable through subagent. Delegate only when a separate context helps; supply a focused task and all relevant context."}`,
143
+ }));
144
+
145
+ pi.registerTool({
146
+ name: "subagent",
147
+ label: "Subagent",
148
+ description: "Delegate a self-contained task to plan, orchestrator, debug, ask, or code in an isolated Pi process in this workspace. With delegate_exploration enabled, ask plus purpose: explore always uses the configured small_model with read-only tools. Other small tasks can automatically use small_model; variant overrides win. Model, effort, and routing reason are returned. Calls are sequential to avoid concurrent workspace edits. No sandboxing; child questions return to the parent.",
149
+ executionMode: "sequential",
150
+ parameters,
151
+ async execute(_id, params, signal, onUpdate, ctx) {
152
+ if (depth >= 2) throw new Error("Subagent delegation depth limit (2) reached");
153
+ if (!isAgentName(params.agent) || !params.task.trim() || params.task.length > 50_000) throw new Error("A valid agent and non-empty task of at most 50000 characters are required");
154
+ if ((active === "plan" || active === "ask") && params.agent !== "plan" && params.agent !== "ask") {
155
+ throw new Error(`${active} may delegate only to plan or ask; switch to code, debug, or orchestrator for changes`);
156
+ }
157
+ const exploration = params.purpose === "explore";
158
+ if (exploration && params.agent !== "ask") throw new Error("Exploration delegation requires agent: ask");
159
+ if (!ctx.model) throw new Error("Select a main model before delegating");
160
+ const config = await loadAgentConfig(ctx.cwd, getAgentDir(), ctx.isProjectTrusted());
161
+ if (exploration && !config.delegate_exploration) throw new Error("Enable delegate_exploration in agent.json before delegating exploration");
162
+ const route = routeSubagent(config, params.agent, params.task, `${ctx.model.provider}/${ctx.model.id}`, (ctx.thinkingLevel ?? pi.getThinkingLevel()) as Effort, exploration);
163
+ const slash = route.model.indexOf("/");
164
+ const selected = ctx.modelRegistry.find(route.model.slice(0, slash), route.model.slice(slash + 1));
165
+ if (!selected || !ctx.modelRegistry.hasConfiguredAuth(selected)) throw new Error(`Subagent model unavailable: ${route.model}. Check /model and /login.`);
166
+ const tools = subagentTools(params.agent, pi.getActiveTools(), depth + 1, config.delegate_exploration && !exploration);
167
+ const controller = new AbortController();
168
+ const abort = () => controller.abort(signal?.reason);
169
+ signal?.addEventListener("abort", abort, { once: true });
170
+ if (signal?.aborted) abort();
171
+ const details = { agent: params.agent, ...route };
172
+ const args = ["--mode", "json", "--print", "--no-session", "--model", route.model,
173
+ "--thinking", route.effort, "--agent", params.agent,
174
+ ctx.isProjectTrusted() ? "--approve" : "--no-approve", ...childResourceArgs(process.argv.slice(2)),
175
+ "--extension", resolve(extensionPath), "--tools", tools.join(",")];
176
+ if (tools.length === 0) args.push("--no-tools");
177
+ // Callable MCP tools must not escape a read-only child's explicit built-in loadout.
178
+ if (params.agent === "plan" || params.agent === "ask") {
179
+ if ("createMcpExtension" in piSdk) args.push("--no-mcp");
180
+ args.push("--exclude-tools", "mcp__*");
181
+ }
182
+ const job = runSubagent({ invocation: piInvocation(), args, cwd: ctx.cwd, depth: depth + 1,
183
+ task: params.task, signal: controller.signal,
184
+ onProgress: (text) => onUpdate?.({ content: [{ type: "text", text: `${params.agent} · ${route.model} · ${route.effort} (${route.reason})\n${text}` }], details }),
185
+ });
186
+ jobs.set(controller, job);
187
+ try {
188
+ const result = await job;
189
+ const failed = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted" || Boolean(result.error);
190
+ return {
191
+ content: [{ type: "text" as const, text: `${params.agent} · ${route.model} · ${route.effort} (${route.reason})\n${limitOutput(failed ? result.error || result.stderr || result.output || "Subagent failed" : result.output)}` }],
192
+ details: { ...details, ...result }, usage: result.usage, isError: failed,
193
+ };
194
+ } finally {
195
+ jobs.delete(controller);
196
+ signal?.removeEventListener("abort", abort);
197
+ }
198
+ },
199
+ });
200
+
201
+ pi.on("session_shutdown", async () => {
202
+ for (const controller of jobs.keys()) controller.abort("Session shutdown");
203
+ await Promise.allSettled(jobs.values());
204
+ });
205
+ }
@@ -1,111 +1,70 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { Type } from "typebox";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { AskQuestionParams, normalizeQuestions, outcome, type Result } from "../lib/question-model.ts";
4
+ import { QuestionScreen } from "../lib/question-ui.ts";
3
5
 
4
- const Option = Type.Object({
5
- label: Type.String({ description: "The option shown to the user" }),
6
- description: Type.Optional(Type.String({ description: "Why this option may be useful" }))
7
- });
8
-
9
- const Question = Type.Object({
10
- question: Type.String({ description: "The question to ask" }),
11
- options: Type.Array(Option, { description: "Options the user can choose from" }),
12
- multiSelect: Type.Optional(
13
- Type.Boolean({ description: "Allow the user to choose multiple options" })
14
- ),
15
- allowOther: Type.Optional(
16
- Type.Boolean({ description: "Allow free-form text instead of an option" })
17
- )
18
- });
19
-
20
- const AskQuestionParams = Type.Object({
21
- questions: Type.Array(Question, {
22
- description: "Questions to ask; ask related questions together when possible"
23
- })
24
- });
25
-
26
- type Answer = {
27
- question: string;
28
- answer: string | string[] | null;
29
- };
30
-
31
- /** AskQuestion gives the model a real, interactive way to clarify requirements. */
6
+ /** Pi owns terminal input, rendering, raw mode and editor restoration. */
32
7
  export default function askQuestion(pi: ExtensionAPI) {
8
+ let active: AbortController | undefined;
9
+ pi.on("session_shutdown", async () => { active?.abort(); });
10
+
33
11
  pi.registerTool({
34
12
  name: "AskQuestion",
35
13
  label: "Ask Question",
36
14
  description:
37
- "Ask the user one or more questions and wait for their answers. Use this when requirements, preferences, or a decision need clarification before proceeding. Permission-mode changes remain user-controlled through Pi's mode commands or shortcuts.",
15
+ "Ask the user related clarification questions in the terminal and wait for validated answers. Options may have stable IDs. Supports single, multi, and text questions, custom answers, defaults (option IDs), selection limits, and required/optional answers. Legacy options/multiSelect/allowOther calls remain supported.",
38
16
  parameters: AskQuestionParams,
39
17
  executionMode: "sequential",
40
18
 
41
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
42
- if (ctx.mode !== "tui") {
43
- return {
44
- content: [{ type: "text", text: "AskQuestion requires an interactive TUI." }],
45
- details: { answers: [], cancelled: true }
46
- };
19
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
20
+ const result = (details: Result) => ({
21
+ content: [{ type: "text" as const, text: JSON.stringify(details) }],
22
+ details
23
+ });
24
+ if (signal?.aborted) return result(outcome("aborted"));
25
+ if (ctx.mode !== "tui" || !process.stdin.isTTY || !process.stdout.isTTY || !ctx.hasUI ||
26
+ process.stdin.destroyed || process.stdin.readableEnded || process.stdout.destroyed) {
27
+ return result(outcome("requires_interaction", [], "AskQuestion requires an interactive TUI and terminal input/output."));
47
28
  }
48
-
49
- const answers: Answer[] = [];
50
- for (const item of params.questions) {
51
- if (item.options.length === 0 && item.allowOther !== true) {
52
- return {
53
- content: [{ type: "text", text: "AskQuestion requires options or allowOther=true." }],
54
- details: { answers, cancelled: true }
55
- };
56
- }
57
-
58
- let answer: string | string[] | null;
59
- if (item.multiSelect) {
60
- const choices = item.options.map((option, index) => `${index + 1}. ${option.label}`);
61
- const entered = await ctx.ui.input(
62
- `${item.question}\n${choices.join("\n")}\nEnter option numbers separated by commas:`,
63
- item.allowOther ? "or type a free-form answer" : "e.g. 1, 3"
64
- );
65
- if (entered == null) answer = null;
66
- else {
67
- const selected = entered
68
- .split(",")
69
- .map((value) => Number(value.trim()) - 1)
70
- .filter((index) => Number.isInteger(index) && index >= 0 && index < item.options.length)
71
- .map((index) => item.options[index]!.label);
72
- if (selected.length > 0) answer = selected;
73
- else if (item.allowOther) answer = entered.trim();
74
- else {
75
- return {
76
- content: [{ type: "text", text: "Choose one or more listed option numbers." }],
77
- details: { answers, cancelled: true }
78
- };
79
- }
80
- }
81
- } else if (item.options.length > 0) {
82
- const otherLabel = "Other — type your own answer";
83
- const choices = item.options.map((option) =>
84
- option.description ? `${option.label} — ${option.description}` : option.label
85
- );
86
- if (item.allowOther) choices.push(otherLabel);
87
- const selected = await ctx.ui.select(item.question, choices);
88
- answer = selected ?? null;
89
- if (selected === otherLabel) {
90
- answer = (await ctx.ui.input(item.question, "Type your answer")) ?? null;
91
- } else if (answer != null) {
92
- answer = item.options.find((option) =>
93
- (option.description ? `${option.label} — ${option.description}` : option.label) === answer
94
- )?.label ?? answer;
95
- }
96
- } else {
97
- answer = (await ctx.ui.input(item.question, "Type your answer")) ?? null;
98
- }
99
-
100
- answers.push({ question: item.question, answer });
101
- if (answer == null) break;
29
+ // sequential protects model batches; this guard also protects direct overlapping calls.
30
+ if (active) return result(outcome("error", [], "Another AskQuestion interaction is already active."));
31
+ const controller = new AbortController();
32
+ active = controller;
33
+ const interactionSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
34
+ let screen: QuestionScreen | undefined;
35
+ try {
36
+ const questions = normalizeQuestions(params);
37
+ const details = await ctx.ui.custom<Result>((tui, theme, _keybindings, done) => {
38
+ screen = new QuestionScreen(questions, tui, theme, done, interactionSignal);
39
+ return screen;
40
+ });
41
+ return result(details);
42
+ } catch (error) {
43
+ return result(outcome("error", [], error instanceof Error ? error.message : String(error)));
44
+ } finally {
45
+ screen?.dispose();
46
+ active = undefined;
102
47
  }
48
+ },
103
49
 
104
- const cancelled = answers.some((entry) => entry.answer == null);
105
- return {
106
- content: [{ type: "text", text: JSON.stringify({ answers, cancelled }) }],
107
- details: { answers, cancelled }
108
- };
50
+ renderResult(result, _options, theme) {
51
+ const details = result.details as Result | undefined;
52
+ if (!details) {
53
+ const text = result.content.find((entry) => entry.type === "text");
54
+ return new Text(text?.type === "text" ? text.text : "AskQuestion", 0, 0);
55
+ }
56
+ // Resumed sessions may contain results from the original label-only tool.
57
+ const status = details.status ?? (details.cancelled ? "cancelled" : "answered");
58
+ if (status !== "answered") {
59
+ const text = (details.error ?? `AskQuestion: ${status}`).replace(/[\x00-\x1f\x7f-\x9f]/g, " ");
60
+ return new Text(theme.fg(status === "error" ? "error" : "warning", text), 0, 0);
61
+ }
62
+ const lines = details.answers.map((entry) => {
63
+ const answer = Array.isArray(entry.answer) ? entry.answer.join(", ") : entry.answer;
64
+ const text = `${entry.question}: ${answer}`.replace(/[\x00-\x1f\x7f-\x9f]/g, " ");
65
+ return theme.fg("success", "✓ ") + text;
66
+ });
67
+ return new Text(lines.join("\n"), 0, 0);
109
68
  }
110
69
  });
111
70
  }
@@ -0,0 +1,62 @@
1
+ import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { hasProjectAgentConfig, initializeAgentConfig } from "../lib/agents.ts";
3
+ import { hasToolOverrides, readSetupSettings, saveSetupChoice, setupChoice, toolsToOffer } from "../lib/recommended-tools.ts";
4
+
5
+ /** Offer project-local setup automatically, never silently change tool policy. */
6
+ export default function recommendedTools(pi: ExtensionAPI) {
7
+ const visited = new Set<string>();
8
+ let pending: AbortController | undefined;
9
+ pi.on("session_shutdown", async () => { pending?.abort(); });
10
+
11
+ pi.on("session_start", async (_event, ctx) => {
12
+ if (ctx.mode !== "tui" || !ctx.hasUI || !process.stdin.isTTY || !process.stdout.isTTY ||
13
+ process.stdin.destroyed || process.stdin.readableEnded || process.stdout.destroyed ||
14
+ !ctx.isProjectTrusted() || hasToolOverrides(process.argv.slice(2)) || visited.has(ctx.cwd) || pending) return;
15
+ visited.add(ctx.cwd);
16
+ const controller = new AbortController();
17
+ pending = controller;
18
+ const signal = ctx.signal ? AbortSignal.any([ctx.signal, controller.signal]) : controller.signal;
19
+ const availableTools = () => pi.getAllTools().filter((tool) => tool.sourceInfo.source === "builtin").map((tool) => tool.name);
20
+ try {
21
+ const settings = await readSetupSettings(ctx.cwd);
22
+ const tools = toolsToOffer(settings, availableTools(), pi.getActiveTools());
23
+ const agentsAvailable = () => pi.getAllTools().some((tool) => tool.name === "subagent");
24
+ const setupAgents = agentsAvailable() && setupChoice(settings, "agentSetup") !== "disabled" && !(await hasProjectAgentConfig(ctx.cwd));
25
+ if ((!tools.length && !setupAgents) || signal.aborted) return;
26
+ const choice = await ctx.ui.select(
27
+ `Enable Piexis setup for this project?\n${tools.length ? `${tools.join(" · ")}\nSaves .pi/settings.json and enables tools now.\n` : ""}${setupAgents ? "Creates .pi/agent.json with default agent models." : ""}`,
28
+ ["Not now", "Enable", "Don't ask again"],
29
+ { signal }
30
+ );
31
+ if (signal.aborted || !ctx.isProjectTrusted() || choice === undefined || choice === "Not now") return;
32
+ if (choice !== "Enable" && choice !== "Don't ask again") return;
33
+ if (choice === "Enable" && (tools.some((name) => !availableTools().includes(name)) || (setupAgents && !agentsAvailable()))) {
34
+ ctx.ui.notify("Tool availability changed during setup; no settings were saved.", "warning");
35
+ return;
36
+ }
37
+ const preference = choice === "Enable" ? "enabled" : "disabled";
38
+ await saveSetupChoice(settings, preference, tools, undefined, signal, setupAgents ? preference : undefined);
39
+ if (choice === "Enable" && !signal.aborted) {
40
+ if (tools.length) {
41
+ pi.setActiveTools([...new Set([...pi.getActiveTools(), ...tools])]);
42
+ ctx.ui.notify(`Enabled ${tools.join(", ")} and saved this project's preference.`, "info");
43
+ }
44
+ if (!setupAgents) return;
45
+ try {
46
+ const currentModel = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
47
+ if (await initializeAgentConfig(ctx.cwd, getAgentDir(), currentModel, signal)) {
48
+ ctx.ui.notify("Created .pi/agent.json with default agent models. Edit it to customize models and effort.", "info");
49
+ }
50
+ } catch (error) {
51
+ if (!signal.aborted) ctx.ui.notify(`Agent configuration was not created: ${error instanceof Error ? error.message : String(error)}`, "warning");
52
+ }
53
+ }
54
+ } catch (error) {
55
+ if (!signal.aborted) {
56
+ ctx.ui.notify(`Piexis tool setup skipped: ${error instanceof Error ? error.message : String(error)}`, "warning");
57
+ }
58
+ } finally {
59
+ pending = undefined;
60
+ }
61
+ });
62
+ }