@preapexis/piexis 0.2.2 → 0.2.5

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/README.md +85 -131
  2. package/apps/server/src/index.ts +97 -0
  3. package/apps/server/src/session-manager.ts +95 -0
  4. package/apps/server/src/workspace.ts +39 -0
  5. package/{tsconfig.json → apps/server/tsconfig.json} +3 -3
  6. package/apps/web/dist/assets/index-D4n3_lAw.js +10 -0
  7. package/apps/web/dist/assets/index-igiK0FPY.css +1 -0
  8. package/apps/web/dist/index.html +3 -0
  9. package/bin/piexis-web.mjs +24 -0
  10. package/extensions/agents.ts +205 -0
  11. package/extensions/ask-question.ts +54 -95
  12. package/extensions/recommended-tools.ts +62 -0
  13. package/extensions/ui.ts +204 -58
  14. package/extensions/webui.ts +45 -0
  15. package/lib/agent-config.ts +55 -0
  16. package/lib/agents.ts +208 -0
  17. package/lib/question-model.ts +98 -0
  18. package/lib/question-ui.ts +214 -0
  19. package/lib/recommended-tools.ts +119 -0
  20. package/lib/subagent-runner.ts +146 -0
  21. package/lib/ui-tips.json +41 -0
  22. package/package.json +41 -14
  23. package/packages/pi-integration/src/rpc-client.ts +99 -0
  24. package/packages/shared/src/index.ts +27 -0
  25. package/prompts/init.md +10 -2
  26. package/prompts/summary.md +7 -6
  27. package/schemas/agent.schema.json +48 -0
  28. package/skills/{claude-api/LICENSE.txt → caveman/LICENSE} +2 -2
  29. package/skills/caveman/LICENSE-MIT +21 -0
  30. package/skills/caveman/NOTICE +10 -0
  31. package/skills/caveman/SKILL.md +89 -0
  32. package/skills/find-skills/SKILL.md +141 -0
  33. package/skills/ponytail/LICENSE +21 -0
  34. package/skills/ponytail/NOTICE +9 -0
  35. package/skills/ponytail/SKILL.md +52 -0
  36. package/themes/midnight.json +81 -0
  37. package/.github/workflows/publish.yml +0 -35
  38. package/AGENTS.md +0 -58
  39. package/eval/README.md +0 -72
  40. package/eval/permission-cases.json +0 -28
  41. package/eval/permissions.mjs +0 -103
  42. package/extensions/background-tasks.ts +0 -433
  43. package/extensions/background-worker.ts +0 -50
  44. package/extensions/glob.ts +0 -32
  45. package/extensions/mode.ts +0 -397
  46. package/lib/bash-approvals.ts +0 -113
  47. package/lib/bash-policy.ts +0 -164
  48. package/lib/mode-policy.ts +0 -174
  49. package/lib/path-policy.ts +0 -182
  50. package/lib/permission-classifier.ts +0 -128
  51. package/lib/permission-config.ts +0 -91
  52. package/lib/permission-denials.ts +0 -32
  53. package/lib/permission-intent.ts +0 -128
  54. package/lib/permission-taxonomy.ts +0 -15
  55. package/lib/task-broker.ts +0 -65
  56. package/lib/task-checkout.ts +0 -39
  57. package/lib/task-git.ts +0 -44
  58. package/lib/task-patch.ts +0 -106
  59. package/lib/task-permissions.ts +0 -51
  60. package/lib/task-report.ts +0 -62
  61. package/lib/task-worktree-policy.ts +0 -56
  62. package/scripts/release.mjs +0 -32
  63. package/skills/claude-api/SKILL.md +0 -572
  64. package/skills/claude-api/csharp/claude-api/README.md +0 -362
  65. package/skills/claude-api/csharp/claude-api/batches.md +0 -14
  66. package/skills/claude-api/csharp/claude-api/files-api.md +0 -25
  67. package/skills/claude-api/csharp/claude-api/streaming.md +0 -28
  68. package/skills/claude-api/csharp/claude-api/tool-use.md +0 -164
  69. package/skills/claude-api/curl/examples.md +0 -256
  70. package/skills/claude-api/curl/managed-agents.md +0 -371
  71. package/skills/claude-api/go/claude-api/README.md +0 -188
  72. package/skills/claude-api/go/claude-api/files-api.md +0 -23
  73. package/skills/claude-api/go/claude-api/streaming.md +0 -43
  74. package/skills/claude-api/go/claude-api/tool-use.md +0 -220
  75. package/skills/claude-api/go/managed-agents/README.md +0 -564
  76. package/skills/claude-api/java/claude-api/README.md +0 -238
  77. package/skills/claude-api/java/claude-api/files-api.md +0 -27
  78. package/skills/claude-api/java/claude-api/streaming.md +0 -24
  79. package/skills/claude-api/java/claude-api/tool-use.md +0 -228
  80. package/skills/claude-api/java/managed-agents/README.md +0 -443
  81. package/skills/claude-api/php/claude-api/README.md +0 -174
  82. package/skills/claude-api/php/claude-api/batches.md +0 -15
  83. package/skills/claude-api/php/claude-api/files-api.md +0 -13
  84. package/skills/claude-api/php/claude-api/streaming.md +0 -27
  85. package/skills/claude-api/php/claude-api/tool-use.md +0 -253
  86. package/skills/claude-api/php/managed-agents/README.md +0 -443
  87. package/skills/claude-api/python/claude-api/README.md +0 -565
  88. package/skills/claude-api/python/claude-api/batches.md +0 -198
  89. package/skills/claude-api/python/claude-api/files-api.md +0 -170
  90. package/skills/claude-api/python/claude-api/sdk-upgrade.md +0 -286
  91. package/skills/claude-api/python/claude-api/streaming.md +0 -179
  92. package/skills/claude-api/python/claude-api/tool-use.md +0 -627
  93. package/skills/claude-api/python/managed-agents/README.md +0 -335
  94. package/skills/claude-api/ruby/claude-api/README.md +0 -134
  95. package/skills/claude-api/ruby/claude-api/streaming.md +0 -16
  96. package/skills/claude-api/ruby/claude-api/tool-use.md +0 -41
  97. package/skills/claude-api/ruby/managed-agents/README.md +0 -394
  98. package/skills/claude-api/shared/admin-api.md +0 -179
  99. package/skills/claude-api/shared/agent-design.md +0 -101
  100. package/skills/claude-api/shared/anthropic-cli.md +0 -269
  101. package/skills/claude-api/shared/claude-platform-on-aws.md +0 -59
  102. package/skills/claude-api/shared/cost-optimization.md +0 -233
  103. package/skills/claude-api/shared/error-codes.md +0 -262
  104. package/skills/claude-api/shared/live-sources.md +0 -166
  105. package/skills/claude-api/shared/managed-agents-api-reference.md +0 -455
  106. package/skills/claude-api/shared/managed-agents-client-patterns.md +0 -220
  107. package/skills/claude-api/shared/managed-agents-core.md +0 -359
  108. package/skills/claude-api/shared/managed-agents-environments.md +0 -226
  109. package/skills/claude-api/shared/managed-agents-events.md +0 -290
  110. package/skills/claude-api/shared/managed-agents-memory.md +0 -201
  111. package/skills/claude-api/shared/managed-agents-multiagent.md +0 -260
  112. package/skills/claude-api/shared/managed-agents-onboarding.md +0 -82
  113. package/skills/claude-api/shared/managed-agents-outcomes.md +0 -108
  114. package/skills/claude-api/shared/managed-agents-overview.md +0 -77
  115. package/skills/claude-api/shared/managed-agents-scheduled-deployments.md +0 -156
  116. package/skills/claude-api/shared/managed-agents-self-hosted-sandboxes.md +0 -282
  117. package/skills/claude-api/shared/managed-agents-tools.md +0 -504
  118. package/skills/claude-api/shared/managed-agents-webhooks.md +0 -143
  119. package/skills/claude-api/shared/model-migration.md +0 -1872
  120. package/skills/claude-api/shared/models.md +0 -142
  121. package/skills/claude-api/shared/platform-availability.md +0 -53
  122. package/skills/claude-api/shared/prompt-audit.md +0 -223
  123. package/skills/claude-api/shared/prompt-caching.md +0 -292
  124. package/skills/claude-api/shared/token-counting.md +0 -56
  125. package/skills/claude-api/shared/tool-use-concepts.md +0 -526
  126. package/skills/claude-api/typescript/claude-api/README.md +0 -405
  127. package/skills/claude-api/typescript/claude-api/batches.md +0 -106
  128. package/skills/claude-api/typescript/claude-api/files-api.md +0 -98
  129. package/skills/claude-api/typescript/claude-api/streaming.md +0 -178
  130. package/skills/claude-api/typescript/claude-api/tool-use.md +0 -586
  131. package/skills/claude-api/typescript/managed-agents/README.md +0 -359
  132. package/skills-lock.json +0 -359
  133. package/tests/auto-permissions.test.mjs +0 -315
  134. package/tests/background-tasks.test.mjs +0 -27
  135. package/tests/background-worker.test.mjs +0 -60
  136. package/tests/bash-permissions.test.mjs +0 -276
  137. package/tests/glob.test.mjs +0 -31
  138. package/tests/mode.test.mjs +0 -62
  139. package/tests/permission-evaluation.test.mjs +0 -70
  140. package/tests/permission-foundation.test.mjs +0 -194
  141. package/tests/permission-policy.test.mjs +0 -158
  142. package/tests/runtime/README.md +0 -51
  143. package/tests/runtime/harness.mjs +0 -190
  144. package/tests/runtime/permissions.test.mjs +0 -192
  145. package/tests/runtime/tui.test.mjs +0 -60
  146. package/tests/skills-routing.test.mjs +0 -51
  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,24 @@
1
+ #!/usr/bin/env node
2
+ import { randomBytes } from "node:crypto";
3
+ import { spawn } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+ import path from "node:path";
6
+
7
+ const args = process.argv.slice(2);
8
+ const value = (flag) => { const index = args.indexOf(flag); return index < 0 ? undefined : args[index + 1]; };
9
+ if (args.includes("--help") || args.includes("-h")) {
10
+ console.log("Usage: piexis-web [--workspace <directory>] [--port <port>] [--token <token>]");
11
+ process.exit(0);
12
+ }
13
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
14
+ const workspace = path.resolve(value("--workspace") || process.cwd());
15
+ const port = value("--port") || process.env.PIXIS_PORT || "4317";
16
+ const token = value("--token") || process.env.PIXIS_TOKEN || randomBytes(32).toString("base64url");
17
+ const server = path.join(root, "apps", "server", "src", "index.ts");
18
+ const child = spawn(process.execPath, ["--import", "tsx", server], {
19
+ cwd: workspace,
20
+ env: { ...process.env, PIXIS_WORKSPACE: workspace, PIXIS_PORT: port, PIXIS_TOKEN: token, PIXIS_ORIGIN: `http://127.0.0.1:${port}` },
21
+ stdio: "inherit",
22
+ windowsHide: true
23
+ });
24
+ child.on("exit", (code) => process.exitCode = code ?? 1);
@@ -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
+ }