@gavin-hu/my-pi-agent 0.13.1 → 0.15.0

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 (237) hide show
  1. package/README.md +66 -41
  2. package/README.zh-CN.md +53 -36
  3. package/extensions/bytefly/README.md +321 -186
  4. package/extensions/bytefly/block/component.ts +7 -15
  5. package/extensions/bytefly/block/register.ts +6 -1
  6. package/extensions/bytefly/doctor/config.ts +4 -1
  7. package/extensions/bytefly/doctor/register.ts +24 -5
  8. package/extensions/bytefly/doctor/tools.ts +4 -0
  9. package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
  10. package/extensions/bytefly/footer/config.ts +10 -6
  11. package/extensions/bytefly/footer/price/config.ts +3 -2
  12. package/extensions/bytefly/footer/price/price.ts +5 -3
  13. package/extensions/bytefly/footer/price/rates.ts +63 -26
  14. package/extensions/bytefly/footer/render/status.ts +3 -3
  15. package/extensions/bytefly/header/component.ts +33 -10
  16. package/extensions/bytefly/header/config.ts +22 -0
  17. package/extensions/bytefly/header/register.ts +2 -1
  18. package/extensions/bytefly/index.ts +6 -6
  19. package/extensions/bytefly/notice/component.ts +8 -3
  20. package/extensions/bytefly/notify/config.ts +7 -12
  21. package/extensions/bytefly/notify/dispatch.ts +13 -5
  22. package/extensions/bytefly/panel/commands.ts +22 -2
  23. package/extensions/bytefly/panel/format.ts +17 -21
  24. package/extensions/bytefly/panel/settings-runtime.ts +86 -22
  25. package/extensions/bytefly/panel/settings.ts +279 -46
  26. package/extensions/bytefly/panel/themes.ts +32 -0
  27. package/extensions/bytefly/panel/tui/editing.ts +77 -0
  28. package/extensions/bytefly/panel/tui/entries.ts +24 -0
  29. package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
  30. package/extensions/bytefly/panel/tui/frame.ts +121 -4
  31. package/extensions/bytefly/panel/tui/settings-view.ts +98 -13
  32. package/extensions/bytefly/panel/tui.ts +176 -166
  33. package/extensions/bytefly/picker/discovery.ts +1 -0
  34. package/extensions/bytefly/picker/types.ts +2 -0
  35. package/extensions/bytefly/separator/component.ts +17 -6
  36. package/extensions/bytefly/separator/config.ts +15 -0
  37. package/extensions/bytefly/separator/format.ts +60 -6
  38. package/extensions/bytefly/separator/register.ts +68 -9
  39. package/extensions/bytefly/separator/types.ts +8 -0
  40. package/extensions/bytefly/update/cache.ts +58 -19
  41. package/extensions/bytefly/update/check.ts +35 -0
  42. package/extensions/bytefly/update/format.ts +76 -35
  43. package/extensions/bytefly/update/register.ts +144 -55
  44. package/extensions/bytefly/update/state.ts +4 -2
  45. package/extensions/bytefly/update/suppress.ts +66 -0
  46. package/extensions/bytefly/update/types.ts +15 -0
  47. package/extensions/bytefly/working/config.ts +4 -5
  48. package/extensions/bytefly/working/format.ts +24 -7
  49. package/extensions/bytefly/working/register.ts +34 -3
  50. package/extensions/check/README.md +253 -0
  51. package/extensions/check/commands.ts +128 -0
  52. package/extensions/check/config.ts +39 -0
  53. package/extensions/check/detect.ts +269 -0
  54. package/extensions/check/failures.ts +230 -0
  55. package/extensions/check/format.ts +135 -0
  56. package/extensions/check/index.ts +26 -0
  57. package/extensions/check/render.ts +185 -0
  58. package/extensions/check/runner.ts +176 -0
  59. package/extensions/check/runtime.ts +244 -0
  60. package/extensions/check/source.ts +80 -0
  61. package/extensions/check/tools.ts +173 -0
  62. package/extensions/check/tui.ts +195 -0
  63. package/extensions/check/types.ts +99 -0
  64. package/extensions/document/README.md +1 -1
  65. package/extensions/document/extract/pdf.ts +8 -16
  66. package/extensions/document/formats.ts +2 -2
  67. package/extensions/file-browse/README.md +66 -32
  68. package/extensions/file-browse/client.ts +95 -32
  69. package/extensions/file-browse/config.ts +9 -9
  70. package/extensions/file-browse/git.ts +37 -47
  71. package/extensions/file-browse/html.ts +39 -58
  72. package/extensions/file-browse/markdown.ts +110 -92
  73. package/extensions/file-browse/raw-html.ts +478 -0
  74. package/extensions/file-browse/router.ts +32 -70
  75. package/extensions/file-browse/server.ts +18 -56
  76. package/extensions/file-browse/urls.ts +80 -0
  77. package/extensions/goal/README.md +103 -47
  78. package/extensions/goal/clarify-parse.ts +4 -9
  79. package/extensions/goal/clarify.ts +27 -4
  80. package/extensions/goal/commands.ts +24 -11
  81. package/extensions/goal/format.ts +41 -10
  82. package/extensions/goal/index.ts +39 -16
  83. package/extensions/goal/plan-route.ts +51 -0
  84. package/extensions/goal/schema.ts +6 -23
  85. package/extensions/goal/state.ts +39 -57
  86. package/extensions/goal/tools.ts +26 -20
  87. package/extensions/goal/types.ts +11 -1
  88. package/extensions/job/README.md +20 -6
  89. package/extensions/job/format.ts +18 -68
  90. package/extensions/job/index.ts +26 -41
  91. package/extensions/job/log-writer.ts +208 -0
  92. package/extensions/job/logs.ts +3 -2
  93. package/extensions/job/process.ts +6 -99
  94. package/extensions/job/runtime.ts +42 -181
  95. package/extensions/job/schema.ts +1 -4
  96. package/extensions/job/tools.ts +1 -15
  97. package/extensions/job/tui.ts +117 -307
  98. package/extensions/job/ui.ts +7 -33
  99. package/extensions/memory/README.md +3 -1
  100. package/extensions/memory/errors.ts +5 -6
  101. package/extensions/memory/tui.ts +14 -24
  102. package/extensions/plan/README.md +106 -18
  103. package/extensions/plan/commands.ts +9 -5
  104. package/extensions/plan/format.ts +87 -0
  105. package/extensions/plan/index.ts +31 -15
  106. package/extensions/plan/plans.ts +2 -3
  107. package/extensions/plan/policy.ts +0 -6
  108. package/extensions/plan/runtime.ts +4 -2
  109. package/extensions/plan/steps.ts +37 -8
  110. package/extensions/plan/todos.ts +44 -3
  111. package/extensions/plan/tools/enter.ts +94 -0
  112. package/extensions/plan/tools/exit.ts +296 -0
  113. package/extensions/plan/tools/nested.ts +21 -0
  114. package/extensions/plan/tools/steps.ts +136 -0
  115. package/extensions/plan/tools/todo-seed.ts +76 -0
  116. package/extensions/plan/tools/write.ts +127 -0
  117. package/extensions/plan/tools.ts +18 -703
  118. package/extensions/plan/web-client.ts +6 -22
  119. package/extensions/plan/web-page.ts +19 -30
  120. package/extensions/plan/web-router.ts +34 -71
  121. package/extensions/plan/web.ts +13 -40
  122. package/extensions/question/answers.ts +3 -8
  123. package/extensions/question/tui.ts +6 -6
  124. package/extensions/rewind/README.md +95 -22
  125. package/extensions/rewind/checklist.ts +185 -0
  126. package/extensions/rewind/commands.ts +8 -0
  127. package/extensions/rewind/config.ts +3 -11
  128. package/extensions/rewind/format.ts +220 -2
  129. package/extensions/rewind/git.ts +74 -7
  130. package/extensions/rewind/hunk-picker.ts +114 -0
  131. package/extensions/rewind/patch.ts +88 -0
  132. package/extensions/rewind/path-picker.ts +67 -100
  133. package/extensions/rewind/paths.ts +93 -19
  134. package/extensions/rewind/restore.ts +53 -10
  135. package/extensions/rewind/review-tui.ts +320 -0
  136. package/extensions/rewind/review.ts +397 -0
  137. package/extensions/rewind/rewind-tui.ts +4 -3
  138. package/extensions/rewind/runtime.ts +94 -17
  139. package/extensions/subagent/README.md +118 -8
  140. package/extensions/subagent/background-tui.ts +208 -0
  141. package/extensions/subagent/background.ts +470 -0
  142. package/extensions/subagent/commands.ts +80 -0
  143. package/extensions/subagent/config.ts +62 -0
  144. package/extensions/subagent/format.ts +24 -1
  145. package/extensions/subagent/index.ts +116 -1
  146. package/extensions/subagent/orchestrate.ts +16 -3
  147. package/extensions/subagent/render.ts +25 -0
  148. package/extensions/subagent/run.ts +113 -49
  149. package/extensions/subagent/schema.ts +12 -0
  150. package/extensions/subagent/transcripts.ts +202 -0
  151. package/extensions/subagent/types.ts +23 -0
  152. package/extensions/subagent/ui.ts +50 -0
  153. package/extensions/todo/README.md +18 -13
  154. package/extensions/todo/format.ts +3 -2
  155. package/extensions/todo/runtime.ts +3 -2
  156. package/extensions/todo/schema.ts +8 -84
  157. package/extensions/todo/state.ts +5 -46
  158. package/extensions/todo/tools.ts +2 -1
  159. package/extensions/todo/types.ts +6 -15
  160. package/extensions/watch/README.md +16 -6
  161. package/extensions/watch/child.ts +150 -0
  162. package/extensions/watch/format.ts +8 -48
  163. package/extensions/watch/index.ts +42 -58
  164. package/extensions/watch/runtime.ts +49 -128
  165. package/extensions/watch/schema.ts +1 -4
  166. package/extensions/watch/tools.ts +1 -11
  167. package/extensions/watch/tui.ts +103 -273
  168. package/extensions/watch/ui.ts +5 -32
  169. package/extensions/web-access/README.md +1 -1
  170. package/extensions/web-access/fetch/page.ts +1 -1
  171. package/extensions/web-access/fetch/tool.ts +6 -2
  172. package/extensions/web-access/search/search.ts +2 -5
  173. package/extensions/wechat/README.md +3 -1
  174. package/extensions/wechat/client.ts +23 -0
  175. package/extensions/wechat/commands.ts +57 -42
  176. package/extensions/wechat/deliver.ts +5 -9
  177. package/extensions/wechat/errors.ts +3 -6
  178. package/extensions/wechat/inbound.ts +2 -5
  179. package/extensions/wechat/index.ts +2 -8
  180. package/extensions/wechat/outbound.ts +2 -4
  181. package/extensions/wechat/peer-worker.ts +3 -8
  182. package/extensions/wechat/poll.ts +10 -9
  183. package/extensions/wechat/presence.ts +35 -62
  184. package/extensions/wechat/reply-pipeline.ts +3 -1
  185. package/extensions/wechat/status-screen.ts +1 -1
  186. package/extensions/wechat/timer.ts +57 -0
  187. package/extensions/worktree/config.ts +8 -20
  188. package/extensions/worktree/enter.ts +7 -12
  189. package/extensions/worktree/git.ts +38 -69
  190. package/extensions/worktree/index.ts +5 -4
  191. package/extensions/worktree/registry.ts +7 -7
  192. package/extensions/worktree/root-tools.ts +2 -2
  193. package/extensions/worktree/status.ts +3 -2
  194. package/lib/README.md +22 -8
  195. package/lib/config.ts +83 -12
  196. package/lib/errors.ts +12 -0
  197. package/lib/format.ts +56 -0
  198. package/lib/git/read.ts +8 -0
  199. package/lib/goal-state.ts +23 -8
  200. package/lib/http/headers.ts +54 -0
  201. package/lib/http/index.ts +13 -0
  202. package/lib/http/response.ts +46 -0
  203. package/lib/http/router.ts +52 -0
  204. package/lib/http/server.ts +91 -0
  205. package/lib/lines.ts +77 -0
  206. package/lib/pdf.ts +82 -0
  207. package/lib/plan-entry.ts +71 -0
  208. package/lib/plan-state.ts +34 -0
  209. package/lib/report.ts +99 -0
  210. package/lib/requirements.ts +25 -19
  211. package/lib/spawn.ts +170 -0
  212. package/lib/todo-state.ts +110 -23
  213. package/lib/tool-names.ts +7 -0
  214. package/lib/tool-result.ts +33 -0
  215. package/lib/tui/README.md +68 -3
  216. package/lib/tui/chrome.ts +28 -4
  217. package/lib/tui/columns.ts +6 -1
  218. package/lib/tui/confirm.ts +65 -0
  219. package/lib/tui/dock-screen.ts +410 -0
  220. package/lib/tui/glyphs.ts +2 -1
  221. package/lib/tui/indent.ts +1 -1
  222. package/lib/tui/index.ts +2 -0
  223. package/lib/tui/rows.ts +21 -0
  224. package/lib/tui/status-controller.ts +35 -1
  225. package/lib/tui/tool.ts +59 -1
  226. package/lib/web/components.ts +20 -1
  227. package/lib/web/markup.ts +32 -0
  228. package/lib/web/runtime.ts +50 -0
  229. package/package.json +23 -5
  230. package/skills/init/SKILL.md +165 -0
  231. package/skills/init/references/agents-md-quality.md +103 -0
  232. package/skills/init/references/interview-questions.md +81 -0
  233. package/skills/refactor/SKILL.md +141 -0
  234. package/themes/bytefly-dark.json +2 -3
  235. package/themes/bytefly-light.json +2 -3
  236. package/extensions/watch/process.ts +0 -63
  237. package/extensions/web-access/fetch/pdf.ts +0 -45
package/lib/pdf.ts ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The optional `unpdf` seam: lazy load, module-shape handling, and text
3
+ * extraction, shared by `web-access` and `document`.
4
+ *
5
+ * `unpdf` bundles a server-friendly build of PDF.js, so it is imported lazily:
6
+ * an extension registers on every session, and a parser should load only when a
7
+ * read actually needs it. `web-access` treats the package as optional and
8
+ * reports {@link pdfGuidance} when it is absent; `document` ships it as a
9
+ * dependency. Both get the same loader, the same merged-pages call, and the same
10
+ * result unwrapping, so a PDF reads the same way through either surface.
11
+ *
12
+ * Value-only: no module state, and no import of `unpdf` until a caller asks.
13
+ */
14
+
15
+ /** Install command for the optional PDF extractor package. */
16
+ const UNPDF_INSTALL = "npm i unpdf";
17
+
18
+ /** Model-facing guidance when the optional `unpdf` package is absent. */
19
+ export function pdfGuidance(): string {
20
+ return `PDF extraction requires the optional 'unpdf' package (${UNPDF_INSTALL}).`;
21
+ }
22
+
23
+ /** Raised when `unpdf` is missing or does not expose `extractText`. */
24
+ export class PdfUnavailableError extends Error {
25
+ constructor(message: string) {
26
+ super(message);
27
+ this.name = "PdfUnavailableError";
28
+ }
29
+ }
30
+
31
+ /** Extract merged page text from PDF bytes. */
32
+ export type PdfExtractor = (bytes: Uint8Array) => Promise<string>;
33
+
34
+ /** Pull the merged text out of `unpdf`'s `{ totalPages, text }` result. Pure. */
35
+ export function pdfTextFromResult(result: unknown): string {
36
+ if (typeof result === "string") return result;
37
+ const text = (result as { text?: unknown } | undefined)?.text;
38
+ return typeof text === "string" ? text : "";
39
+ }
40
+
41
+ /** The `extractText` function of an imported `unpdf` module, when it exposes one. */
42
+ function extractTextOf(module: unknown): ((bytes: Uint8Array, options?: unknown) => Promise<unknown>) | undefined {
43
+ if (typeof module !== "object" || module === null) return undefined;
44
+ const named = (module as { extractText?: unknown }).extractText;
45
+ if (typeof named === "function") return named as (bytes: Uint8Array, options?: unknown) => Promise<unknown>;
46
+ const fallback = (module as { default?: unknown }).default;
47
+ if (typeof fallback !== "object" || fallback === null) return undefined;
48
+ const nested = (fallback as { extractText?: unknown }).extractText;
49
+ if (typeof nested !== "function") return undefined;
50
+ return nested as (bytes: Uint8Array, options?: unknown) => Promise<unknown>;
51
+ }
52
+
53
+ /**
54
+ * Build an extractor from an imported `unpdf` module.
55
+ *
56
+ * `unpdf` exports `extractText` directly; the default-export fallback tolerates
57
+ * a build that wraps it. Anything else is a broken install, reported with the
58
+ * same error the missing-package path uses.
59
+ */
60
+ export function pdfExtractorFromModule(module: unknown): PdfExtractor {
61
+ const extractText = extractTextOf(module);
62
+ if (!extractText) throw new PdfUnavailableError("The installed 'unpdf' package does not export extractText.");
63
+ return async (bytes) => pdfTextFromResult(await extractText(bytes, { mergePages: true }));
64
+ }
65
+
66
+ /** Load `unpdf` lazily, or throw {@link PdfUnavailableError} with install guidance. */
67
+ export async function loadPdfExtractor(): Promise<PdfExtractor> {
68
+ let module: unknown;
69
+ try {
70
+ module = await import("unpdf");
71
+ } catch {
72
+ throw new PdfUnavailableError(pdfGuidance());
73
+ }
74
+ return pdfExtractorFromModule(module);
75
+ }
76
+
77
+ /** Extract merged page text from PDF bytes, using `extractor` when given. */
78
+ export async function extractPdfText(bytes: Uint8Array, extractor?: PdfExtractor): Promise<string> {
79
+ if (extractor) return extractor(bytes);
80
+ const loaded = await loadPdfExtractor();
81
+ return loaded(bytes);
82
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Plan-mode entry, shared by the extension that owns the mode and the one that
3
+ * offers it as a phase of the goal.
4
+ *
5
+ * Plan mode has one owner: `plan` gates the tools, persists the flag, and draws
6
+ * the footer. A tool context can reach it through `ctx.executeTool`, but a
7
+ * command context cannot (`ExtensionCommandContext` has no `executeTool`), so the
8
+ * request travels on `pi.events` as an in-process request/response. The caller
9
+ * asks the user — with the consent sentence below — and `plan` enables without a
10
+ * second dialog, so the session never asks twice for one decision.
11
+ *
12
+ * The consent sentence and the read-only summary live here because two
13
+ * extensions show them: one copy each is how a trust guarantee drifts apart.
14
+ * Value-only, as `lib/` must be: the state is the caller's context and a closure,
15
+ * never a module variable.
16
+ */
17
+
18
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
19
+ import { isPlanModeEnabled } from "./plan-state.ts";
20
+ import { PLAN_ENTER_TOOL } from "./tool-names.ts";
21
+
22
+ /** EventBus channel on which a caller asks `plan` to enable plan mode. */
23
+ export const PLAN_ENTER_REQUEST = "my-pi-agent/plan-mode/enter";
24
+
25
+ /** One clause describing the read-only guarantee, reused across prompts. */
26
+ export const READ_ONLY_SUMMARY =
27
+ "write and edit are disabled, bash is limited to read-only git commands, and other mutating tools are blocked; " +
28
+ "subagent delegation is forced read-only, and the only file write is write_plan (under .pi/plans, or the agent " +
29
+ "directory outside a repository)";
30
+
31
+ /**
32
+ * The consent question, as the user sees it. Shown by `enter_plan_mode` and by
33
+ * any caller that offers plan mode itself, so both describe the same guarantee.
34
+ */
35
+ export const PLAN_CONSENT =
36
+ `Plan mode is read-only exploration: ${READ_ONLY_SUMMARY}. ` +
37
+ "Enter plan mode to investigate and propose an approach before making changes?";
38
+
39
+ /** What a caller sends on {@link PLAN_ENTER_REQUEST}. */
40
+ export interface PlanEnterRequest {
41
+ /** The caller's context; `plan` uses it for the footer status. */
42
+ ctx: ExtensionContext;
43
+ /** Called once, synchronously, with whether plan mode is on. */
44
+ respond(entered: boolean): void;
45
+ }
46
+
47
+ /**
48
+ * Ask `plan` to turn plan mode on, and report whether it is on afterwards.
49
+ *
50
+ * Resolves `true` without asking when the branch is already planning, and
51
+ * `false` when the `plan` tool is not registered — so `goal` loaded alone keeps
52
+ * working and nothing waits on a listener that is not there. The listener answers
53
+ * synchronously; the microtask is the fallback that guarantees resolution.
54
+ */
55
+ export function requestPlanMode(
56
+ pi: Pick<ExtensionAPI, "events" | "getAllTools">,
57
+ ctx: ExtensionContext,
58
+ ): Promise<boolean> {
59
+ if (isPlanModeEnabled(ctx.sessionManager.getBranch())) return Promise.resolve(true);
60
+ if (!pi.getAllTools().some((tool) => tool.name === PLAN_ENTER_TOOL)) return Promise.resolve(false);
61
+ return new Promise<boolean>((resolve) => {
62
+ let settled = false;
63
+ const respond = (entered: boolean): void => {
64
+ if (settled) return;
65
+ settled = true;
66
+ resolve(entered);
67
+ };
68
+ pi.events.emit(PLAN_ENTER_REQUEST, { ctx, respond } satisfies PlanEnterRequest);
69
+ queueMicrotask(() => respond(false));
70
+ });
71
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The plan-mode flag as the session branch carries it.
3
+ *
4
+ * Plan mode persists one custom entry (`plan-mode`) whose `enabled` field is the
5
+ * last word on the branch. `plan` writes it; `rewind` reads it through this
6
+ * module so `/review` cannot write while planning, without importing another
7
+ * extension's modules.
8
+ *
9
+ * Value-only: a constant and a pure replay over untrusted branch entries.
10
+ */
11
+
12
+ /** Custom-entry type plan mode persists. */
13
+ export const PLAN_MODE_ENTRY_TYPE = "plan-mode";
14
+
15
+ /**
16
+ * Whether the branch's last valid plan-mode entry is enabled.
17
+ *
18
+ * Branch data is untrusted: an entry with the wrong type, a malformed `data`, or
19
+ * a non-boolean `enabled` is skipped rather than read as false. The last valid
20
+ * entry wins, so a later disable turns it off and a `/tree` restore follows the
21
+ * branch it lands on.
22
+ */
23
+ export function isPlanModeEnabled(entries: readonly unknown[]): boolean {
24
+ let enabled = false;
25
+ for (const raw of entries) {
26
+ if (!raw || typeof raw !== "object") continue;
27
+ const entry = raw as { type?: unknown; customType?: unknown; data?: unknown };
28
+ if (entry.type !== "custom" || entry.customType !== PLAN_MODE_ENTRY_TYPE) continue;
29
+ if (!entry.data || typeof entry.data !== "object") continue;
30
+ const value = (entry.data as { enabled?: unknown }).enabled;
31
+ if (typeof value === "boolean") enabled = value;
32
+ }
33
+ return enabled;
34
+ }
package/lib/report.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The pending-report coordinator: how a live feature tells the model what
3
+ * happened without interrupting it.
4
+ *
5
+ * A watcher match and a finished job follow one rule — report at the next turn,
6
+ * and raise at most one triggered turn when the work opted into `wake`. The two
7
+ * traps are subtle enough to have been re-derived once per extension:
8
+ *
9
+ * - a wake is only safe while the agent is idle, so an outcome that arrives
10
+ * mid-turn must be queued rather than sent;
11
+ * - between the triggered send and the `agent_start` it causes, the agent still
12
+ * looks idle, so a second outcome in that window must not trigger a second
13
+ * turn. `waking` closes that window.
14
+ *
15
+ * Delivery is one shared transcript notice per burst, and a burst is marked
16
+ * reported only after the send succeeded, so a throw leaves it for the next
17
+ * turn. Value-only: every flag lives in the returned closure, never in a module
18
+ * variable, because each extension gets its own module instance.
19
+ */
20
+
21
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
22
+ import { pruneStaleNotices, type NoticeMessage } from "./notice.ts";
23
+
24
+ export interface PendingReporterOptions<T> {
25
+ /** Send a notice and ask the host to start one turn for it. */
26
+ wake(notice: NoticeMessage): void;
27
+ /** The outcomes not yet reported, oldest first. Does not mark them. */
28
+ pending(): T[];
29
+ /** Mark outcomes as reported, once the send has succeeded. */
30
+ markReported(items: T[]): void;
31
+ /** Build the notice for one burst: model text plus renderer details. */
32
+ buildNotice(items: T[]): NoticeMessage;
33
+ /** The notice's source, so only its own stale entries are pruned. */
34
+ source: string;
35
+ /** Runs after a wake turn was successfully requested, for an extra announcement. */
36
+ onWoke?(items: T[]): void;
37
+ }
38
+
39
+ /** The handlers an extension wires into its lifecycle hooks. */
40
+ export interface PendingReporter {
41
+ /** An outcome arrived: wake a turn if the agent is idle, else leave it queued. */
42
+ report(): void;
43
+ /** A new session starts idle. */
44
+ sessionStart(): void;
45
+ /** A turn began: outcomes queue up, and none of them triggers a turn. */
46
+ agentStart(): void;
47
+ /** The turn settled: a later outcome may wake again. */
48
+ agentSettled(): void;
49
+ /** Report what is queued at the start of a turn, without triggering one. */
50
+ beforeAgentStart(): { message: NoticeMessage } | undefined;
51
+ /** Drop this source's older notices from the outgoing context. */
52
+ filterContext(messages: readonly AgentMessage[]): { messages: AgentMessage[] } | undefined;
53
+ }
54
+
55
+ export function createPendingReporter<T>(options: PendingReporterOptions<T>): PendingReporter {
56
+ const { wake, pending, markReported, buildNotice, source, onWoke } = options;
57
+ let busy = false;
58
+ let waking = false;
59
+
60
+ return {
61
+ report: () => {
62
+ if (busy || waking) return;
63
+ const items = pending();
64
+ if (items.length === 0) return;
65
+ try {
66
+ wake(buildNotice(items));
67
+ } catch {
68
+ // Reporting is best-effort; the next turn reports the same items.
69
+ return;
70
+ }
71
+ // Mark only after the send succeeded, so a throw changes nothing.
72
+ markReported(items);
73
+ waking = true;
74
+ onWoke?.(items);
75
+ },
76
+ sessionStart: () => {
77
+ busy = false;
78
+ waking = false;
79
+ },
80
+ agentStart: () => {
81
+ busy = true;
82
+ },
83
+ agentSettled: () => {
84
+ busy = false;
85
+ waking = false;
86
+ },
87
+ beforeAgentStart: () => {
88
+ const items = pending();
89
+ if (items.length === 0) return undefined;
90
+ const notice = buildNotice(items);
91
+ markReported(items);
92
+ return { message: notice };
93
+ },
94
+ filterContext: (messages) => {
95
+ const pruned = pruneStaleNotices(messages, source);
96
+ return pruned ? { messages: pruned } : undefined;
97
+ },
98
+ };
99
+ }
@@ -17,6 +17,7 @@
17
17
  import { join } from "node:path";
18
18
  import { configSection, configValueAt, type JsonReadResult, SHARED_CONFIG_FILE } from "./config.ts";
19
19
  import type { WebSearchProviderEvent } from "./events.ts";
20
+ import { pdfGuidance } from "./pdf.ts";
20
21
  import { getPiInvocation, type PiInvocation } from "./pi-invocation.ts";
21
22
 
22
23
  /** How a check came out. Only `missing` is worth interrupting a session for. */
@@ -167,19 +168,6 @@ export interface ExtensionReport {
167
168
  findings: Finding[];
168
169
  }
169
170
 
170
- // --- Install guidance -------------------------------------------------------
171
- //
172
- // Moved here from the extensions that raise it, so `doctor` and the tool error
173
- // say the same thing. Formatting only: no extension-specific logic.
174
-
175
- /** Install command for the optional PDF extractor package. */
176
- const UNPDF_INSTALL = "npm i unpdf";
177
-
178
- /** Model-facing guidance when the optional `unpdf` package is absent. */
179
- export function pdfGuidance(): string {
180
- return `PDF extraction requires the optional 'unpdf' package (${UNPDF_INSTALL}).`;
181
- }
182
-
183
171
  // --- The table --------------------------------------------------------------
184
172
 
185
173
  /**
@@ -224,6 +212,9 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
224
212
  },
225
213
  todo: {},
226
214
  goal: {},
215
+ check: {
216
+ notes: ["Runs the project's detected command through a shell; a stack with no detector needs `check.command`."],
217
+ },
227
218
  plan: {
228
219
  optional: [gitCheck("project-local `.pi/plans` (otherwise plans land in the agent directory)")],
229
220
  notes: ["Needs an interactive UI to enter plan mode."],
@@ -769,19 +760,34 @@ export async function probeExtension(extension: string, seams: ProbeSeams): Prom
769
760
  return { extension, enabled, findings };
770
761
  }
771
762
 
763
+ /** Probes in flight at once: enough to hide spawn latency, not a burst. */
764
+ export const PROBE_CONCURRENCY = 4;
765
+
772
766
  /**
773
- * Probe every declared extension, or just `names`, in declaration order.
767
+ * Probe every declared extension, or just `names`, keeping declaration order.
774
768
  *
775
- * Sequential on purpose: the caller runs this on a session-start budget, and a
776
- * burst of parallel probes would trade a few milliseconds for a burst of child
777
- * processes.
769
+ * The caller is a user-facing report (`run_doctor`) or the panel's refresh, so
770
+ * the probes run a few at a time: a serial pass pays the sum of every spawn and
771
+ * read, while one `Promise.all` over the whole table starts a burst of child
772
+ * processes. The startup check does not come through here; it probes only the
773
+ * hard requirements, all at once (see `probeRequired`).
778
774
  */
779
775
  export async function probeAll(
780
776
  seams: ProbeSeams,
781
777
  names: readonly string[] = Object.keys(REQUIREMENTS),
782
778
  ): Promise<ExtensionReport[]> {
783
- const reports: ExtensionReport[] = [];
784
- for (const name of names) reports.push(await probeExtension(name, seams));
779
+ const reports = new Array<ExtensionReport>(names.length);
780
+ let cursor = 0;
781
+ /** Take the next name until the queue is empty; each worker keeps its slot. */
782
+ const drain = async (): Promise<void> => {
783
+ while (cursor < names.length) {
784
+ const index = cursor;
785
+ cursor += 1;
786
+ reports[index] = await probeExtension(names[index], seams);
787
+ }
788
+ };
789
+ const workers = Array.from({ length: Math.min(PROBE_CONCURRENCY, names.length) }, () => drain());
790
+ await Promise.all(workers);
785
791
  return reports;
786
792
  }
787
793
 
package/lib/spawn.ts ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * The process seam shared by the extensions that run a shell command.
3
+ *
4
+ * `job`, `watch`, and `check` all start a command in its own process group,
5
+ * stream or discard its output, and kill the whole tree — a signal to the POSIX
6
+ * group, or `taskkill /T` on Windows. Only the policy on top differs (durable
7
+ * jobs, a tailed watcher, a bounded check), so the seam lives here.
8
+ *
9
+ * IO is split from the decision: {@link spawnOptions} and {@link killPlan} are
10
+ * pure and testable on every platform, while {@link defaultSpawn} and
11
+ * {@link defaultKillTree} execute them. Value-only, like every `lib/` module:
12
+ * no module state, and each caller keeps its own children.
13
+ */
14
+
15
+ import { spawn as nodeSpawn, spawnSync } from "node:child_process";
16
+ import type { Readable } from "node:stream";
17
+
18
+ /**
19
+ * The writable end of a child's stdin pipe.
20
+ *
21
+ * Only the operations a caller needs, so tests can hand the runtime a recording
22
+ * double instead of a real socket.
23
+ */
24
+ export interface StdinSink {
25
+ write(chunk: string): boolean;
26
+ end(): void;
27
+ destroy?(): void;
28
+ on(event: "error" | "close", listener: (error?: Error) => void): unknown;
29
+ }
30
+
31
+ /** The subset of a spawned child the extensions use. */
32
+ export interface SpawnedProcess {
33
+ pid?: number;
34
+ stdout: Readable | null;
35
+ stderr: Readable | null;
36
+ /** Present only when the child was spawned with `stdin: true`. */
37
+ stdin?: StdinSink | null;
38
+ on(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
39
+ on(event: "error", listener: (error: Error) => void): unknown;
40
+ kill(signal?: NodeJS.Signals): boolean;
41
+ /** Detach from the parent's event loop, so a child cannot hold Pi open. */
42
+ unref?(): void;
43
+ }
44
+
45
+ export interface SpawnOptions {
46
+ cwd: string;
47
+ env?: NodeJS.ProcessEnv;
48
+ /** Give the child a stdin pipe instead of a closed stdin (no immediate EOF). */
49
+ stdin?: boolean;
50
+ /** The command writes its own output to a log; keep no parent pipe. */
51
+ detachOutput?: boolean;
52
+ }
53
+
54
+ export type SpawnFn = (command: string, options: SpawnOptions) => SpawnedProcess;
55
+
56
+ /** Send `signal` to a pid's process tree, escalating to SIGKILL when `force`. */
57
+ export type KillTreeFn = (pid: number, signal: NodeJS.Signals, force?: boolean) => void;
58
+
59
+ /** The `stdio` triple a spawn needs: stdin, stdout, stderr. */
60
+ export type SpawnStdio = ["pipe" | "ignore", "pipe" | "ignore", "pipe" | "ignore"];
61
+
62
+ /** The `child_process.spawn` options this seam produces. */
63
+ export interface NodeSpawnOptions {
64
+ cwd: string;
65
+ shell: boolean;
66
+ detached: boolean;
67
+ stdio: SpawnStdio;
68
+ env?: NodeJS.ProcessEnv;
69
+ }
70
+
71
+ /**
72
+ * The spawn options for one command: a shell in its own process group, so a
73
+ * signal can reach the shell and its descendants together.
74
+ *
75
+ * Stdin stays closed unless asked for, because a closed stdin gives the child
76
+ * EOF — a `read` exits instead of blocking forever. `detachOutput` keeps no
77
+ * parent pipe, for a child that writes its own log file.
78
+ */
79
+ export function spawnOptions(options: SpawnOptions, platform: NodeJS.Platform = process.platform): NodeSpawnOptions {
80
+ return {
81
+ cwd: options.cwd,
82
+ shell: true,
83
+ detached: platform !== "win32",
84
+ stdio: [
85
+ options.stdin ? "pipe" : "ignore",
86
+ options.detachOutput ? "ignore" : "pipe",
87
+ options.detachOutput ? "ignore" : "pipe",
88
+ ],
89
+ env: options.env,
90
+ };
91
+ }
92
+
93
+ /** What killing a pid's tree means on this platform. */
94
+ export interface KillPlan {
95
+ /** A program to run instead of signalling (Windows has no process group). */
96
+ command?: { file: string; args: string[] };
97
+ /**
98
+ * Pids to signal in order: the process group first, then the pid itself for a
99
+ * child that is not a group leader (a reattached pid, for example).
100
+ */
101
+ targets: number[];
102
+ }
103
+
104
+ /** The kill plan for `pid`: `taskkill /T` on Windows, group-then-pid otherwise. */
105
+ export function killPlan(pid: number, force: boolean, platform: NodeJS.Platform = process.platform): KillPlan {
106
+ if (platform === "win32") {
107
+ const args = ["/pid", String(pid), "/T", ...(force ? ["/F"] : [])];
108
+ return { command: { file: "taskkill", args }, targets: [] };
109
+ }
110
+ return { targets: [-pid, pid] };
111
+ }
112
+
113
+ /** A signal-0 probe; injectable so the EPERM branch is testable. */
114
+ export type KillProbe = (pid: number, signal: number) => unknown;
115
+
116
+ /** Whether a pid is alive on this host. EPERM means it exists but is not ours. */
117
+ export function isPidAlive(pid: number, probe: KillProbe = process.kill): boolean {
118
+ if (!Number.isFinite(pid) || pid <= 0) return false;
119
+ try {
120
+ probe(pid, 0);
121
+ return true;
122
+ } catch (error) {
123
+ return (error as NodeJS.ErrnoException).code === "EPERM";
124
+ }
125
+ }
126
+
127
+ /** The host seams {@link applyKillPlan} runs through. */
128
+ export interface KillSeams {
129
+ /** Signal one pid; throws when the pid is gone or not ours. */
130
+ kill(pid: number, signal: NodeJS.Signals): unknown;
131
+ /** Run a program (the Windows `taskkill`). */
132
+ run(file: string, args: string[]): unknown;
133
+ }
134
+
135
+ /**
136
+ * Execute a {@link KillPlan}: run the command, or signal each target in order.
137
+ *
138
+ * The group attempt may answer `ESRCH` for a pid that is not a group leader, and
139
+ * the final attempt is the fallback, so its failure means "already gone" — a
140
+ * kill of a dead process is not an error a caller can act on.
141
+ */
142
+ export function applyKillPlan(plan: KillPlan, signal: NodeJS.Signals, seams: KillSeams): void {
143
+ if (plan.command) {
144
+ seams.run(plan.command.file, plan.command.args);
145
+ return;
146
+ }
147
+ for (const [index, target] of plan.targets.entries()) {
148
+ const isFallback = index === plan.targets.length - 1;
149
+ try {
150
+ seams.kill(target, signal);
151
+ return;
152
+ } catch (error) {
153
+ if (!isFallback && (error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
154
+ }
155
+ }
156
+ }
157
+
158
+ /** The real seams, so a caller needs no adapter. */
159
+ const processSeams: KillSeams = {
160
+ kill: (pid, signal) => process.kill(pid, signal),
161
+ run: (file, args) => spawnSync(file, args, { stdio: "ignore" }),
162
+ };
163
+
164
+ /** Default spawn: a shell command in its own process group, output piped. */
165
+ export const defaultSpawn: SpawnFn = (command, options) => nodeSpawn(command, spawnOptions(options));
166
+
167
+ /** Default tree kill: process group on POSIX, `taskkill /T` on Windows. */
168
+ export const defaultKillTree: KillTreeFn = (pid, signal, force = false) => {
169
+ applyKillPlan(killPlan(pid, force), signal, processSeams);
170
+ };