@trim21/personal-pi-extensions 0.0.310 → 0.0.313

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.310",
3
+ "version": "0.0.313",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -13,8 +13,9 @@
13
13
  * - grep
14
14
  * - find
15
15
  * - ls
16
- * model: claude-haiku-4-5 # optional
17
- * thinkingLevel: high # optional; applied as "model:high"
16
+ * provider: openai # optional; overrides the global default
17
+ * model: claude-haiku-4-5 # optional; overrides the global default
18
+ * thinkingLevel: high # optional; overrides the global default
18
19
  * ---
19
20
  * System prompt for the agent goes here.
20
21
  *
@@ -22,22 +23,33 @@
22
23
  * (missing name/description, wrong field types) are skipped. If `tools` is
23
24
  * omitted, the subagent runs with the read-only default toolset from the
24
25
  * spawn-agent config (read/grep/find/ls) unless overridden there.
26
+ *
27
+ * Global defaults for provider/model/thinkingLevel come from
28
+ * `~/.pi/agent/spawn-agent.json` (see loadSpawnAgentConfig); frontmatter
29
+ * fields take precedence over them, which in turn take precedence over the
30
+ * top-level defaultProvider/defaultModel/defaultThinkingLevel in pi's
31
+ * settings.json.
25
32
  */
26
33
 
27
34
  import { readdirSync, readFileSync } from "node:fs";
28
35
  import { join } from "node:path";
29
36
 
30
37
  import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
31
- import { Type } from "typebox";
38
+ import { type Static, Type } from "typebox";
32
39
  import { Value } from "typebox/value";
33
40
 
34
- /** Valid thinking levels, mirroring pi's ThinkingLevel type. */
35
- const THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
41
+ /**
42
+ * Valid thinking levels, mirroring pi's CLI --thinking validation
43
+ * (VALID_THINKING_LEVELS). "off" disables thinking; "max" is deliberately
44
+ * excluded — pi's CLI layer does not accept it.
45
+ */
46
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
36
47
 
37
48
  const agentFrontmatterSchema = Type.Object({
38
49
  name: Type.String({ minLength: 1 }),
39
50
  description: Type.String({ minLength: 1 }),
40
51
  tools: Type.Optional(Type.Array(Type.String())),
52
+ provider: Type.Optional(Type.String()),
41
53
  model: Type.Optional(Type.String()),
42
54
  thinkingLevel: Type.Optional(Type.Union(THINKING_LEVELS.map((level) => Type.Literal(level)))),
43
55
  });
@@ -55,13 +67,25 @@ export interface AgentConfig {
55
67
  description: string;
56
68
  /** Toolset from the frontmatter; undefined means "use the config default". */
57
69
  tools?: string[];
70
+ provider?: string;
58
71
  model?: string;
59
- /** Thinking level, applied as a ":level" suffix on the model id. */
72
+ /** Thinking level, applied via --thinking. */
60
73
  thinkingLevel?: (typeof THINKING_LEVELS)[number];
61
74
  systemPrompt: string;
62
75
  filePath: string;
63
76
  }
64
77
 
78
+ /**
79
+ * Global defaults resolved for subagents. Fields are optional: an absent
80
+ * field means "not configured", and applyAgentDefaults leaves the frontmatter
81
+ * value (or its absence) untouched.
82
+ */
83
+ export interface SpawnAgentDefaults {
84
+ provider?: string;
85
+ model?: string;
86
+ thinkingLevel?: (typeof THINKING_LEVELS)[number];
87
+ }
88
+
65
89
  export function discoverAgents(dir = join(getAgentDir(), "agents")): AgentConfig[] {
66
90
  let entries;
67
91
  try {
@@ -91,6 +115,7 @@ export function discoverAgents(dir = join(getAgentDir(), "agents")): AgentConfig
91
115
  name: fm.name,
92
116
  description: fm.description,
93
117
  tools: fm.tools,
118
+ provider: fm.provider,
94
119
  model: fm.model,
95
120
  thinkingLevel: fm.thinkingLevel,
96
121
  systemPrompt: body,
@@ -100,6 +125,92 @@ export function discoverAgents(dir = join(getAgentDir(), "agents")): AgentConfig
100
125
  return agents;
101
126
  }
102
127
 
128
+ /**
129
+ * Load the global subagent defaults from `~/.pi/agent/spawn-agent.json`,
130
+ * falling back per-field to the top-level defaultProvider/defaultModel/
131
+ * defaultThinkingLevel in pi's settings.json (same pattern as the vision and
132
+ * session-name extensions). Returns undefined when the spawn-agent.json file
133
+ * is missing, broken, or empty — in that case applyAgentDefaults leaves agents
134
+ * untouched and the subagent inherits pi's own defaults.
135
+ */
136
+ export function loadSpawnAgentConfig(
137
+ spawnAgentPath: string,
138
+ settingsPath: string,
139
+ ): SpawnAgentDefaults | undefined {
140
+ let raw: unknown;
141
+ try {
142
+ raw = JSON.parse(readFileSync(spawnAgentPath, "utf8"));
143
+ } catch {
144
+ return undefined; // 文件缺失或 JSON 损坏
145
+ }
146
+ let config: SpawnAgentDefaults;
147
+ try {
148
+ config = Value.Parse(spawnAgentConfigSchema, raw);
149
+ } catch {
150
+ return undefined; // 结构不符(非 object / thinkingLevel 非法等)
151
+ }
152
+ if (!config.provider && !config.model && !config.thinkingLevel) return undefined;
153
+
154
+ let fallback: SpawnAgentSettingsFallback | undefined;
155
+ try {
156
+ fallback = Value.Parse(
157
+ spawnAgentSettingsFallbackSchema,
158
+ JSON.parse(readFileSync(settingsPath, "utf8")),
159
+ );
160
+ } catch {
161
+ // settings.json 缺失或损坏:只用 spawn-agent.json 自身的字段
162
+ }
163
+ return {
164
+ provider: nonEmpty(config.provider) ?? nonEmpty(fallback?.defaultProvider),
165
+ model: nonEmpty(config.model) ?? nonEmpty(fallback?.defaultModel),
166
+ thinkingLevel:
167
+ config.thinkingLevel ??
168
+ (isThinkingLevel(fallback?.defaultThinkingLevel) ? fallback.defaultThinkingLevel : undefined),
169
+ };
170
+ }
171
+
172
+ /** spawn-agent.json 的 schema;thinkingLevel 限定与 frontmatter 相同的合法集合 */
173
+ const spawnAgentConfigSchema = Type.Object({
174
+ provider: Type.Optional(Type.String()),
175
+ model: Type.Optional(Type.String()),
176
+ thinkingLevel: Type.Optional(Type.Union(THINKING_LEVELS.map((level) => Type.Literal(level)))),
177
+ });
178
+
179
+ /** pi settings.json 顶层的兜底字段;thinkingLevel 用宽松字符串,解析后再过滤 */
180
+ const spawnAgentSettingsFallbackSchema = Type.Object({
181
+ defaultProvider: Type.Optional(Type.String()),
182
+ defaultModel: Type.Optional(Type.String()),
183
+ defaultThinkingLevel: Type.Optional(Type.String()),
184
+ });
185
+
186
+ type SpawnAgentSettingsFallback = Static<typeof spawnAgentSettingsFallbackSchema>;
187
+
188
+ function nonEmpty(value: string | undefined): string | undefined {
189
+ return value?.trim() || undefined;
190
+ }
191
+
192
+ function isThinkingLevel(value: string | undefined): value is (typeof THINKING_LEVELS)[number] {
193
+ return value !== undefined && (THINKING_LEVELS as readonly string[]).includes(value);
194
+ }
195
+
196
+ /**
197
+ * Merge global defaults into discovered agents, field by field: a frontmatter
198
+ * value always wins; otherwise the global default is used. Returns the input
199
+ * array unchanged when there are no defaults.
200
+ */
201
+ export function applyAgentDefaults(
202
+ agents: AgentConfig[],
203
+ defaults: SpawnAgentDefaults | undefined,
204
+ ): AgentConfig[] {
205
+ if (!defaults) return agents;
206
+ return agents.map((agent) => ({
207
+ ...agent,
208
+ provider: agent.provider ?? defaults.provider,
209
+ model: agent.model ?? defaults.model,
210
+ thinkingLevel: agent.thinkingLevel ?? defaults.thinkingLevel,
211
+ }));
212
+ }
213
+
103
214
  export function formatAgentList(agents: AgentConfig[]): string {
104
215
  if (agents.length === 0) return "none";
105
216
  return agents.map((a) => `${a.name}: ${a.description}`).join("; ");
@@ -34,6 +34,7 @@ import {
34
34
  type AgentSessionEvent,
35
35
  type ExtensionAPI,
36
36
  type ExtensionUIContext,
37
+ getAgentDir,
37
38
  type RpcExtensionUIRequest,
38
39
  type RpcExtensionUIResponse,
39
40
  truncateTail,
@@ -43,7 +44,13 @@ import {
43
44
  import { Type } from "typebox";
44
45
 
45
46
  import { type ToolPendant } from "./lib/pendant.js";
46
- import { type AgentConfig, discoverAgents, formatAgentList } from "./spawn-agent-agents.js";
47
+ import {
48
+ type AgentConfig,
49
+ applyAgentDefaults,
50
+ discoverAgents,
51
+ formatAgentList,
52
+ loadSpawnAgentConfig,
53
+ } from "./spawn-agent-agents.js";
47
54
 
48
55
  // ── constants ────────────────────────────────────────────────────────────────
49
56
 
@@ -55,6 +62,13 @@ const DEFAULT_TOOLS = ["read", "grep", "find", "ls"];
55
62
  const MAX_PROGRESS_LINES = 5;
56
63
  /** Progress line content (without the `tool:` / `text:` prefix) is capped at 21 chars; longer text is folded to the first/last 9 chars joined by ` … `. */
57
64
  const MAX_PROGRESS_CHARS_PER_LINE = 21;
65
+ /** stderr 采集上限:防止子代理崩溃循环输出撑爆内存;保留尾部(错误信息通常在尾部)。 */
66
+ const MAX_STDERR_CAPTURE_BYTES = 64 * 1024;
67
+ /** 错误消息里 stderr 的展示上限。 */
68
+ const MAX_STDERR_ERROR_BYTES = 4 * 1024;
69
+ /** 全局默认配置:~/.pi/agent/spawn-agent.json,字段可被 frontmatter 覆盖。 */
70
+ const SPAWN_AGENT_CONFIG_PATH = join(getAgentDir(), "spawn-agent.json");
71
+ const SETTINGS_PATH = join(getAgentDir(), "settings.json");
58
72
 
59
73
  /**
60
74
  * Tool → extension override map: when a subagent's frontmatter enables a
@@ -133,6 +147,26 @@ function getFinalOutput(messages: AgentMessage[]): string {
133
147
  return "";
134
148
  }
135
149
 
150
+ /**
151
+ * 组装失败消息,按来源分行(error/stderr/output)让父模型能分辨信息出处;
152
+ * stderr 截断到尾部(错误信息通常在最后)。全空时保底 "(no output)",
153
+ * 避免只回一个 exit code。
154
+ */
155
+ export function formatSubagentError(result: SubagentDetails): { reason: string; message: string } {
156
+ const reason =
157
+ result.stopReason ?? (result.exitCode === 0 ? "failed" : `exit ${result.exitCode}`);
158
+ const parts: string[] = [];
159
+ if (result.errorMessage) parts.push(`error: ${result.errorMessage}`);
160
+ const stderr = truncateTail(result.stderr, { maxBytes: MAX_STDERR_ERROR_BYTES });
161
+ if (stderr.content.trim()) {
162
+ const truncatedMark = stderr.truncated ? "\n[stderr truncated]" : "";
163
+ parts.push(`stderr: ${stderr.content.trim()}${truncatedMark}`);
164
+ }
165
+ const output = getFinalOutput(result.messages);
166
+ if (output) parts.push(`output: ${output}`);
167
+ return { reason, message: parts.length > 0 ? parts.join("\n") : "(no output)" };
168
+ }
169
+
136
170
  /**
137
171
  * Fold over-long progress line content: keep the first/last 9 chars joined by
138
172
  * ` … ` (space, ellipsis, space), so the folded line never exceeds
@@ -219,13 +253,15 @@ export function buildSubagentArgs(
219
253
  // per-tool overrides) run inside the subagent.
220
254
  const args: string[] = ["--mode", "rpc", "--no-session", "--no-extensions"];
221
255
 
222
- // Thinking level rides on the model shorthand ("model:level"); it cannot be
223
- // set without a model, so a level without a model is ignored.
224
- const model =
225
- agent.model !== undefined && agent.thinkingLevel !== undefined
226
- ? `${agent.model}:${agent.thinkingLevel}`
227
- : agent.model;
228
- if (model) args.push("--model", model);
256
+ // provider/model/thinkingLevel 已由 applyAgentDefaults 合并进 agent。
257
+ // --provider 只在 model 不含 "/" 前缀时传:带前缀的 model(如
258
+ // "openai/gpt-4o")由 pi 自己解析 provider,显式传 provider 会冲突。
259
+ if (agent.model && !agent.model.includes("/") && agent.provider) {
260
+ args.push("--provider", agent.provider);
261
+ }
262
+ if (agent.model) args.push("--model", agent.model);
263
+ // --thinking 独立传参;pi 支持 "off" 显式关闭思考。
264
+ if (agent.thinkingLevel) args.push("--thinking", agent.thinkingLevel);
229
265
  // Read-only default unless the agent explicitly declares a toolset.
230
266
  const tools = agent.tools ?? DEFAULT_TOOLS;
231
267
  // Load the opencode override for each built-in tool the agent declares
@@ -514,7 +550,7 @@ export async function runAgent(
514
550
  });
515
551
 
516
552
  proc.stderr.on("data", (data: Buffer) => {
517
- result.stderr += data.toString();
553
+ result.stderr = (result.stderr + data.toString()).slice(-MAX_STDERR_CAPTURE_BYTES);
518
554
  });
519
555
 
520
556
  proc.stdin.on("error", (error) => {
@@ -524,13 +560,23 @@ export async function runAgent(
524
560
  sendRpc({ type: "prompt", message: `Task: ${task}` });
525
561
 
526
562
  const exitCode = await new Promise<number>((resolve) => {
527
- proc.on("close", (code) => {
563
+ proc.on("close", (code, childSignal) => {
528
564
  if (buffer.trim()) processLine(buffer);
529
- resolve(code ?? 0);
565
+ // 被信号终止时 code null,不能算 0——否则中断会被误判为成功
566
+ resolve(code ?? (childSignal ? 1 : 0));
567
+ });
568
+ proc.on("error", (error) => {
569
+ // spawn 失败(如 pi 命令不存在)时 error 先于 close 触发;
570
+ // 记录真实错误,而不是只留一个 exit code。
571
+ result.errorMessage = error.message;
572
+ result.stopReason ??= "error";
573
+ resolve(1);
530
574
  });
531
- proc.on("error", () => resolve(1));
532
575
 
533
576
  const kill = () => {
577
+ // abort 可能发生在子代理产生任何结果之前;标记 aborted 让上层
578
+ // 识别中断(已有 stopReason 则保留,避免误报)。
579
+ result.stopReason ??= "aborted";
534
580
  proc.kill("SIGTERM");
535
581
  setTimeout(() => {
536
582
  if (!proc.killed) proc.kill("SIGKILL");
@@ -599,9 +645,12 @@ export default function spawnAgent(pi: ExtensionAPI) {
599
645
 
600
646
  // Discover the available subagent types once at extension startup. The
601
647
  // extension owns this discovery: the model never has to guess agent names
602
- // or read the agent directory itself. Editing ~/.pi/agent/agents/*.md
603
- // requires /reload to take effect.
604
- const agents = discoverAgents();
648
+ // or read the agent directory itself. Editing ~/.pi/agent/agents/*.md or
649
+ // ~/.pi/agent/spawn-agent.json requires /reload to take effect.
650
+ const agents = applyAgentDefaults(
651
+ discoverAgents(),
652
+ loadSpawnAgentConfig(SPAWN_AGENT_CONFIG_PATH, SETTINGS_PATH),
653
+ );
605
654
  const agentListSection = agents.length > 0 ? formatAgentListSection(agents) : null;
606
655
 
607
656
  if (agentListSection) {
@@ -661,13 +710,10 @@ export default function spawnAgent(pi: ExtensionAPI) {
661
710
  const isError =
662
711
  result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
663
712
  if (isError) {
664
- const reason =
665
- result.stopReason ?? (result.exitCode === 0 ? "failed" : `exit ${result.exitCode}`);
666
- const message =
667
- result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
713
+ const { reason, message } = formatSubagentError(result);
668
714
  return {
669
715
  content: [
670
- { type: "text", text: `Subagent "${result.agent}" failed (${reason}): ${message}` },
716
+ { type: "text", text: `Subagent "${result.agent}" failed (${reason}):\n${message}` },
671
717
  ],
672
718
  details: {
673
719
  ...result,
package/src/talk/index.ts CHANGED
@@ -11,7 +11,6 @@
11
11
  import * as fs from "node:fs";
12
12
  import * as os from "node:os";
13
13
  import * as path from "node:path";
14
- import { fileURLToPath } from "node:url";
15
14
 
16
15
  import {
17
16
  type ExtensionAPI,
@@ -35,9 +34,6 @@ const NOTIFY_TYPE = "talk:notify";
35
34
 
36
35
  const ASK_TIMEOUT_MS = 30 * 60 * 1000;
37
36
 
38
- /** Guide the model to the multi-agent workflow skill shipped with this package. */
39
- const SKILL_PATH = fileURLToPath(new URL("skills/multi-agent-dev/SKILL.md", import.meta.url));
40
-
41
37
  function toolResult(text: string) {
42
38
  return { content: [{ type: "text" as const, text }], details: {} };
43
39
  }
@@ -187,14 +183,6 @@ export default function talk(pi: ExtensionAPI) {
187
183
  pi.on("agent_start", () => core.setWorking());
188
184
  pi.on("agent_end", () => core.setIdle());
189
185
  pi.on("agent_settled", () => core.setIdle());
190
- pi.on("before_agent_start", (event) => {
191
- // One-line nudge: before coordinating with other pi agents, read the
192
- // shipped workflow skill. Skipped when the skill file is absent.
193
- if (!fs.existsSync(SKILL_PATH)) return;
194
- return {
195
- systemPrompt: `${event.systemPrompt}\n\nBefore multi-agent collaboration, read ${SKILL_PATH} to understand the talk workflow.`,
196
- };
197
- });
198
186
  pi.on("session_info_changed", () => {
199
187
  // A name set explicitly via `--name` wins over pi's session title;
200
188
  // otherwise follow pi's session name.