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