@gavin-hu/my-pi-agent 0.14.0 → 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 (222) hide show
  1. package/README.md +55 -33
  2. package/README.zh-CN.md +44 -27
  3. package/extensions/bytefly/README.md +193 -140
  4. package/extensions/bytefly/block/component.ts +7 -15
  5. package/extensions/bytefly/block/register.ts +6 -1
  6. package/extensions/bytefly/doctor/register.ts +24 -5
  7. package/extensions/bytefly/doctor/tools.ts +4 -0
  8. package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
  9. package/extensions/bytefly/footer/config.ts +10 -6
  10. package/extensions/bytefly/footer/price/price.ts +5 -3
  11. package/extensions/bytefly/footer/price/rates.ts +63 -26
  12. package/extensions/bytefly/footer/render/status.ts +3 -3
  13. package/extensions/bytefly/header/component.ts +3 -2
  14. package/extensions/bytefly/index.ts +3 -3
  15. package/extensions/bytefly/notice/component.ts +8 -3
  16. package/extensions/bytefly/notify/config.ts +6 -11
  17. package/extensions/bytefly/notify/dispatch.ts +13 -5
  18. package/extensions/bytefly/panel/commands.ts +21 -1
  19. package/extensions/bytefly/panel/format.ts +5 -2
  20. package/extensions/bytefly/panel/settings-runtime.ts +26 -6
  21. package/extensions/bytefly/panel/settings.ts +41 -4
  22. package/extensions/bytefly/panel/themes.ts +32 -0
  23. package/extensions/bytefly/panel/tui/editing.ts +77 -0
  24. package/extensions/bytefly/panel/tui/entries.ts +24 -0
  25. package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
  26. package/extensions/bytefly/panel/tui/frame.ts +121 -4
  27. package/extensions/bytefly/panel/tui/settings-view.ts +54 -15
  28. package/extensions/bytefly/panel/tui.ts +94 -152
  29. package/extensions/bytefly/picker/discovery.ts +1 -0
  30. package/extensions/bytefly/picker/types.ts +2 -0
  31. package/extensions/bytefly/separator/component.ts +14 -4
  32. package/extensions/bytefly/update/cache.ts +58 -19
  33. package/extensions/bytefly/update/check.ts +35 -0
  34. package/extensions/bytefly/update/format.ts +76 -35
  35. package/extensions/bytefly/update/register.ts +144 -55
  36. package/extensions/bytefly/update/state.ts +4 -2
  37. package/extensions/bytefly/update/suppress.ts +66 -0
  38. package/extensions/bytefly/update/types.ts +15 -0
  39. package/extensions/check/README.md +253 -0
  40. package/extensions/check/commands.ts +128 -0
  41. package/extensions/check/config.ts +39 -0
  42. package/extensions/check/detect.ts +269 -0
  43. package/extensions/check/failures.ts +230 -0
  44. package/extensions/check/format.ts +135 -0
  45. package/extensions/check/index.ts +26 -0
  46. package/extensions/check/render.ts +185 -0
  47. package/extensions/check/runner.ts +176 -0
  48. package/extensions/check/runtime.ts +244 -0
  49. package/extensions/check/source.ts +80 -0
  50. package/extensions/check/tools.ts +173 -0
  51. package/extensions/check/tui.ts +195 -0
  52. package/extensions/check/types.ts +99 -0
  53. package/extensions/document/README.md +1 -1
  54. package/extensions/document/extract/pdf.ts +8 -16
  55. package/extensions/document/formats.ts +2 -2
  56. package/extensions/file-browse/README.md +66 -32
  57. package/extensions/file-browse/client.ts +95 -32
  58. package/extensions/file-browse/config.ts +9 -9
  59. package/extensions/file-browse/git.ts +37 -47
  60. package/extensions/file-browse/html.ts +39 -58
  61. package/extensions/file-browse/markdown.ts +110 -92
  62. package/extensions/file-browse/raw-html.ts +478 -0
  63. package/extensions/file-browse/router.ts +32 -70
  64. package/extensions/file-browse/server.ts +18 -56
  65. package/extensions/file-browse/urls.ts +80 -0
  66. package/extensions/goal/README.md +103 -47
  67. package/extensions/goal/clarify-parse.ts +4 -9
  68. package/extensions/goal/clarify.ts +27 -4
  69. package/extensions/goal/commands.ts +24 -11
  70. package/extensions/goal/format.ts +41 -10
  71. package/extensions/goal/index.ts +39 -16
  72. package/extensions/goal/plan-route.ts +51 -0
  73. package/extensions/goal/schema.ts +6 -23
  74. package/extensions/goal/state.ts +39 -57
  75. package/extensions/goal/tools.ts +26 -20
  76. package/extensions/goal/types.ts +11 -1
  77. package/extensions/job/README.md +20 -6
  78. package/extensions/job/format.ts +18 -68
  79. package/extensions/job/index.ts +26 -41
  80. package/extensions/job/log-writer.ts +208 -0
  81. package/extensions/job/logs.ts +3 -2
  82. package/extensions/job/process.ts +6 -99
  83. package/extensions/job/runtime.ts +42 -181
  84. package/extensions/job/schema.ts +1 -4
  85. package/extensions/job/tools.ts +1 -15
  86. package/extensions/job/tui.ts +117 -307
  87. package/extensions/job/ui.ts +7 -33
  88. package/extensions/memory/README.md +1 -1
  89. package/extensions/memory/errors.ts +5 -6
  90. package/extensions/memory/tui.ts +14 -24
  91. package/extensions/plan/README.md +94 -18
  92. package/extensions/plan/commands.ts +9 -5
  93. package/extensions/plan/format.ts +87 -0
  94. package/extensions/plan/index.ts +31 -15
  95. package/extensions/plan/plans.ts +2 -3
  96. package/extensions/plan/policy.ts +0 -6
  97. package/extensions/plan/runtime.ts +4 -2
  98. package/extensions/plan/steps.ts +37 -8
  99. package/extensions/plan/todos.ts +44 -3
  100. package/extensions/plan/tools/enter.ts +94 -0
  101. package/extensions/plan/tools/exit.ts +296 -0
  102. package/extensions/plan/tools/nested.ts +21 -0
  103. package/extensions/plan/tools/steps.ts +136 -0
  104. package/extensions/plan/tools/todo-seed.ts +76 -0
  105. package/extensions/plan/tools/write.ts +127 -0
  106. package/extensions/plan/tools.ts +18 -703
  107. package/extensions/plan/web-client.ts +6 -22
  108. package/extensions/plan/web-page.ts +19 -30
  109. package/extensions/plan/web-router.ts +34 -71
  110. package/extensions/plan/web.ts +13 -40
  111. package/extensions/question/answers.ts +3 -8
  112. package/extensions/question/tui.ts +6 -6
  113. package/extensions/rewind/README.md +95 -22
  114. package/extensions/rewind/checklist.ts +185 -0
  115. package/extensions/rewind/commands.ts +8 -0
  116. package/extensions/rewind/config.ts +3 -11
  117. package/extensions/rewind/format.ts +220 -2
  118. package/extensions/rewind/git.ts +74 -7
  119. package/extensions/rewind/hunk-picker.ts +114 -0
  120. package/extensions/rewind/patch.ts +88 -0
  121. package/extensions/rewind/path-picker.ts +67 -100
  122. package/extensions/rewind/paths.ts +93 -19
  123. package/extensions/rewind/restore.ts +53 -10
  124. package/extensions/rewind/review-tui.ts +320 -0
  125. package/extensions/rewind/review.ts +397 -0
  126. package/extensions/rewind/rewind-tui.ts +4 -3
  127. package/extensions/rewind/runtime.ts +94 -17
  128. package/extensions/subagent/README.md +61 -6
  129. package/extensions/subagent/background-tui.ts +208 -0
  130. package/extensions/subagent/background.ts +470 -0
  131. package/extensions/subagent/commands.ts +80 -0
  132. package/extensions/subagent/config.ts +4 -0
  133. package/extensions/subagent/format.ts +24 -1
  134. package/extensions/subagent/index.ts +91 -2
  135. package/extensions/subagent/render.ts +13 -0
  136. package/extensions/subagent/run.ts +17 -16
  137. package/extensions/subagent/schema.ts +12 -0
  138. package/extensions/subagent/types.ts +11 -0
  139. package/extensions/subagent/ui.ts +50 -0
  140. package/extensions/todo/README.md +18 -13
  141. package/extensions/todo/format.ts +3 -2
  142. package/extensions/todo/runtime.ts +3 -2
  143. package/extensions/todo/schema.ts +8 -84
  144. package/extensions/todo/state.ts +5 -46
  145. package/extensions/todo/tools.ts +2 -1
  146. package/extensions/todo/types.ts +6 -15
  147. package/extensions/watch/README.md +14 -6
  148. package/extensions/watch/child.ts +150 -0
  149. package/extensions/watch/format.ts +8 -48
  150. package/extensions/watch/index.ts +42 -58
  151. package/extensions/watch/runtime.ts +49 -128
  152. package/extensions/watch/schema.ts +1 -4
  153. package/extensions/watch/tools.ts +1 -11
  154. package/extensions/watch/tui.ts +103 -273
  155. package/extensions/watch/ui.ts +5 -32
  156. package/extensions/web-access/README.md +1 -1
  157. package/extensions/web-access/fetch/page.ts +1 -1
  158. package/extensions/web-access/fetch/tool.ts +6 -2
  159. package/extensions/web-access/search/search.ts +2 -5
  160. package/extensions/wechat/README.md +1 -1
  161. package/extensions/wechat/client.ts +23 -0
  162. package/extensions/wechat/commands.ts +57 -42
  163. package/extensions/wechat/deliver.ts +5 -9
  164. package/extensions/wechat/errors.ts +3 -6
  165. package/extensions/wechat/inbound.ts +2 -5
  166. package/extensions/wechat/index.ts +2 -8
  167. package/extensions/wechat/outbound.ts +2 -4
  168. package/extensions/wechat/peer-worker.ts +3 -8
  169. package/extensions/wechat/poll.ts +10 -9
  170. package/extensions/wechat/presence.ts +35 -62
  171. package/extensions/wechat/reply-pipeline.ts +3 -1
  172. package/extensions/wechat/status-screen.ts +1 -1
  173. package/extensions/wechat/timer.ts +57 -0
  174. package/extensions/worktree/config.ts +8 -20
  175. package/extensions/worktree/enter.ts +7 -12
  176. package/extensions/worktree/git.ts +38 -69
  177. package/extensions/worktree/index.ts +5 -4
  178. package/extensions/worktree/registry.ts +7 -7
  179. package/extensions/worktree/root-tools.ts +2 -2
  180. package/extensions/worktree/status.ts +3 -2
  181. package/lib/README.md +22 -8
  182. package/lib/config.ts +50 -3
  183. package/lib/errors.ts +12 -0
  184. package/lib/format.ts +50 -0
  185. package/lib/git/read.ts +8 -0
  186. package/lib/goal-state.ts +23 -8
  187. package/lib/http/headers.ts +54 -0
  188. package/lib/http/index.ts +13 -0
  189. package/lib/http/response.ts +46 -0
  190. package/lib/http/router.ts +52 -0
  191. package/lib/http/server.ts +91 -0
  192. package/lib/lines.ts +77 -0
  193. package/lib/pdf.ts +82 -0
  194. package/lib/plan-entry.ts +71 -0
  195. package/lib/plan-state.ts +34 -0
  196. package/lib/report.ts +99 -0
  197. package/lib/requirements.ts +25 -19
  198. package/lib/spawn.ts +170 -0
  199. package/lib/todo-state.ts +110 -23
  200. package/lib/tool-names.ts +7 -0
  201. package/lib/tool-result.ts +33 -0
  202. package/lib/tui/README.md +68 -3
  203. package/lib/tui/chrome.ts +28 -4
  204. package/lib/tui/columns.ts +6 -1
  205. package/lib/tui/confirm.ts +65 -0
  206. package/lib/tui/dock-screen.ts +410 -0
  207. package/lib/tui/glyphs.ts +2 -1
  208. package/lib/tui/indent.ts +1 -1
  209. package/lib/tui/index.ts +2 -0
  210. package/lib/tui/rows.ts +21 -0
  211. package/lib/tui/status-controller.ts +35 -1
  212. package/lib/tui/tool.ts +59 -1
  213. package/lib/web/components.ts +20 -1
  214. package/lib/web/markup.ts +32 -0
  215. package/lib/web/runtime.ts +50 -0
  216. package/package.json +22 -5
  217. package/skills/init/SKILL.md +165 -0
  218. package/skills/init/references/agents-md-quality.md +103 -0
  219. package/skills/init/references/interview-questions.md +81 -0
  220. package/skills/refactor/SKILL.md +141 -0
  221. package/extensions/watch/process.ts +0 -63
  222. package/extensions/web-access/fetch/pdf.ts +0 -45
package/lib/format.ts CHANGED
@@ -55,6 +55,17 @@ export function formatToolCount(count: number): string {
55
55
  return `${formatCount(n)} ${n === 1 ? "tool" : "tools"}`;
56
56
  }
57
57
 
58
+ /**
59
+ * A tail bounded to `maxChars`: the end of the joined lines, with the truncation
60
+ * flag. The header and the truncation suffix belong to the caller, because each
61
+ * surface says something different about where the rest of the output lives.
62
+ */
63
+ export function boundedTail(lines: readonly string[], maxChars: number): { text: string; truncated: boolean } {
64
+ const body = lines.join("\n");
65
+ if (body.length <= maxChars) return { text: body, truncated: false };
66
+ return { text: body.slice(body.length - maxChars), truncated: true };
67
+ }
68
+
58
69
  /**
59
70
  * Replace terminal control characters (including ESC and the C1 block) with
60
71
  * spaces, keeping `\n` and `\t` for callers that render multi-line text.
@@ -96,6 +107,45 @@ export function sanitizeLine(text: string): string {
96
107
  return sanitize(stripControlChars(text));
97
108
  }
98
109
 
110
+ /** A string read as one sanitized line; any other value reads empty. */
111
+ export function lineOf(value: unknown): string {
112
+ return typeof value === "string" ? sanitizeLine(value) : "";
113
+ }
114
+
115
+ /** Longest untrusted line kept for display; longer lines are clipped. */
116
+ export const MAX_OUTPUT_LINE = 200;
117
+
118
+ /** ESC-introduced sequences: OSC, CSI, and single-character escapes. */
119
+ const OSC = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
120
+ const CSI = /\u001b\[[0-?]*[ -/]*[@-~]/g;
121
+ const OTHER_ESC = /\u001b[@-Z\\-_]/g;
122
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g;
123
+
124
+ /**
125
+ * The boundary for one line of **program output**: a log file, a child's
126
+ * stdout, a tailed file.
127
+ *
128
+ * Stronger than {@link sanitizeLine}, which strips control characters but leaves
129
+ * the printable tail of an ANSI sequence behind. This removes whole OSC, CSI,
130
+ * and single-character escapes first, then resolves carriage returns the way a
131
+ * terminal would (keep the text after the last `\r`, so progress rewrites
132
+ * collapse to their final state), squeezes whitespace, and clips the result.
133
+ *
134
+ * The escape-stripping has to happen before the control-character pass: once
135
+ * ESC has become a space, the rest of the sequence is ordinary text.
136
+ */
137
+ export function sanitizeUntrustedLine(raw: string, maxLength: number = MAX_OUTPUT_LINE): string {
138
+ const withoutAnsi = raw.replace(OSC, "").replace(CSI, "").replace(OTHER_ESC, "");
139
+ const afterCr = withoutAnsi.slice(withoutAnsi.lastIndexOf("\r") + 1);
140
+ const cleaned = afterCr.replace(CONTROL, " ").replace(/\s+/g, " ").trim();
141
+ return cleaned.length > maxLength ? `${cleaned.slice(0, maxLength - 1)}…` : cleaned;
142
+ }
143
+
144
+ /** {@link sanitizeUntrustedLine} for a block of output, one entry per line. */
145
+ export function sanitizeUntrustedText(raw: string, maxLength: number = MAX_OUTPUT_LINE): string[] {
146
+ return raw.split("\n").map((line) => sanitizeUntrustedLine(line, maxLength));
147
+ }
148
+
99
149
  /**
100
150
  * Replace a home-directory prefix with `~`, matching at a path boundary so
101
151
  * `/home/bob` does not shorten `/home/bobby`. Separators are normalized to `/`
package/lib/git/read.ts CHANGED
@@ -54,6 +54,14 @@ export async function gitDir(runGit: RunGit, cwd: string): Promise<string | unde
54
54
  return dir || undefined;
55
55
  }
56
56
 
57
+ /** Absolute path of the repository's common git directory, shared by its worktrees. */
58
+ export async function gitCommonDir(runGit: RunGit, cwd: string): Promise<string | undefined> {
59
+ const result = await runGit(["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd });
60
+ if (result.code !== 0) return undefined;
61
+ const dir = result.stdout.trim();
62
+ return dir || undefined;
63
+ }
64
+
57
65
  /** Resolve a revision to a full object id, or undefined when it does not exist. */
58
66
  export async function revParse(runGit: RunGit, cwd: string, rev: string): Promise<string | undefined> {
59
67
  const result = await runGit(["rev-parse", "--verify", "--quiet", rev], { cwd });
package/lib/goal-state.ts CHANGED
@@ -6,8 +6,13 @@
6
6
  * `error`, which is not a write — and the `/goal` command appends a `goal` custom
7
7
  * entry whose `data.goal` is `null` when the goal is cleared. Replaying the branch
8
8
  * in order is how another extension reads the goal without importing goal's
9
- * modules: `plan` restates an active goal in its planning context and offers to
10
- * record one when a plan is approved with none.
9
+ * modules: `plan` restates a pursued goal in its planning context, promotes a
10
+ * `planning` goal at plan exit, and offers to record one when a plan is approved
11
+ * with none.
12
+ *
13
+ * The status is the goal's phase: `planning` while a plan is being designed,
14
+ * `active` once the objective is final, `achieved` when it is done. Only `goal`
15
+ * writes it; the promotion out of `planning` runs through the `goal` tool.
11
16
  *
12
17
  * Stored values are re-validated and re-sanitized here. Branch data can be
13
18
  * tampered with, migrated, or written by an older version, and a reader must not
@@ -15,9 +20,10 @@
15
20
  * ignored rather than clearing a goal that is still valid.
16
21
  *
17
22
  * The entry type and the objective limit live here and are imported by `goal`, so
18
- * the publisher and the reader cannot drift. `goal` keeps its own reconstruction
19
- * (`extensions/goal/state.ts`) because it also drives the widget; this module is
20
- * the minimal contract other extensions may depend on.
23
+ * the publisher and the reader cannot drift. `goal` reads the branch through
24
+ * {@link lastGoal} as well — [`extensions/goal/state.ts`](../extensions/goal/state.ts)
25
+ * adds only the widget's active check and the open-todo count on top — so there
26
+ * is one replay, not two.
21
27
  */
22
28
 
23
29
  import { sanitizeLine } from "./format.ts";
@@ -29,8 +35,8 @@ export const GOAL_ENTRY_TYPE = "goal";
29
35
  /** Longest objective the goal tool accepts, and the most a reader will replay. */
30
36
  export const MAX_GOAL_OBJECTIVE = 2000;
31
37
 
32
- /** Whether the goal is still being pursued, or already done. */
33
- export type PublishedGoalStatus = "active" | "achieved";
38
+ /** The goal's phase: being designed, final and pursued, or done. */
39
+ export type PublishedGoalStatus = "planning" | "active" | "achieved";
34
40
 
35
41
  /** The goal as another extension may read it. */
36
42
  export interface PublishedGoal {
@@ -60,12 +66,21 @@ function readGoal(value: unknown): PublishedGoal | null | undefined {
60
66
  if (!value || typeof value !== "object") return undefined;
61
67
  const goal = value as { objective?: unknown; status?: unknown };
62
68
  if (typeof goal.objective !== "string") return undefined;
63
- if (goal.status !== "active" && goal.status !== "achieved") return undefined;
69
+ if (goal.status !== "planning" && goal.status !== "active" && goal.status !== "achieved") return undefined;
64
70
  const objective = sanitizeLine(goal.objective);
65
71
  if (!objective || objective.length > MAX_GOAL_OBJECTIVE) return undefined;
66
72
  return { objective, status: goal.status };
67
73
  }
68
74
 
75
+ /**
76
+ * True while the goal steers the session: `planning` or `active`, never
77
+ * `achieved`. Both the reminder and the plan-mode context read this, so a goal
78
+ * being designed is restated like one being executed.
79
+ */
80
+ export function isPursuedGoal(goal: PublishedGoal | null): goal is PublishedGoal {
81
+ return goal?.status === "planning" || goal?.status === "active";
82
+ }
83
+
69
84
  /** The last goal written on the branch, or `null` when the session has none. */
70
85
  export function lastGoal(entries: Iterable<unknown>): PublishedGoal | null {
71
86
  let goal: PublishedGoal | null = null;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The response header sets both loopback servers answer with.
3
+ *
4
+ * One policy, declared once: the pages `file-browse` and `plan` serve are the
5
+ * same kind of document — a same-origin script, inline styles, and no network
6
+ * — so they must not drift into different envelopes, and a page that refuses to
7
+ * be framed cannot quietly stop doing so on one surface only.
8
+ *
9
+ * Every builder returns a fresh object, so a caller may add to a response
10
+ * without writing into another one. The policy itself is a code-authored
11
+ * constant; nothing here is model- or file-derived.
12
+ */
13
+
14
+ /**
15
+ * The page policy. `default-src 'none'` makes every fetch opt-in; the inline
16
+ * style allowance is the page's own `<style>` block, `script-src 'self'` the
17
+ * same-origin client, and `connect-src 'self'` the page's own API routes.
18
+ *
19
+ * The last three directives are the ones that make a local page safe to leave
20
+ * open: `frame-ancestors 'none'` (with the legacy `X-Frame-Options`) stops a
21
+ * local page from framing the surface and clickjacking a control with a side
22
+ * effect, `base-uri 'self'` stops an injected `<base>` from re-pointing every
23
+ * relative URL, and `form-action 'none'` stops a submission anywhere.
24
+ */
25
+ export const PAGE_CSP =
26
+ "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'self'; " +
27
+ "connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'";
28
+
29
+ /** The security and caching headers every response carries, whatever its body. */
30
+ function secured(contentType: string): Record<string, string> {
31
+ return {
32
+ "Content-Type": contentType,
33
+ "Content-Security-Policy": PAGE_CSP,
34
+ "X-Frame-Options": "DENY",
35
+ "X-Content-Type-Options": "nosniff",
36
+ "Referrer-Policy": "no-referrer",
37
+ "Cache-Control": "no-store",
38
+ };
39
+ }
40
+
41
+ /** Headers for a served HTML page. */
42
+ export function pageHeaders(): Record<string, string> {
43
+ return secured("text/html; charset=utf-8");
44
+ }
45
+
46
+ /** Headers for a same-origin script: the page policy, with the script's own type. */
47
+ export function scriptHeaders(): Record<string, string> {
48
+ return secured("text/javascript; charset=utf-8");
49
+ }
50
+
51
+ /** Headers for a JSON API reply: the same envelope, a JSON type. */
52
+ export function jsonHeaders(): Record<string, string> {
53
+ return secured("application/json; charset=utf-8");
54
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The loopback HTTP layer shared by `file-browse` and `plan`: the response
3
+ * shape, the header policy, the request-envelope helpers, and the socket seam.
4
+ *
5
+ * Import from here rather than a leaf module, so the layer reads as one kit.
6
+ * Value-only, like the rest of `lib/`: the only state is what a returned closure
7
+ * carries (`createCloser`).
8
+ */
9
+
10
+ export * from "./headers.ts";
11
+ export * from "./response.ts";
12
+ export * from "./router.ts";
13
+ export * from "./server.ts";
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The response shape both loopback servers answer with, and the builders that
3
+ * pair a body with the header set it needs.
4
+ *
5
+ * A router returns one of these instead of touching a socket, so every route is
6
+ * testable without a server, and `server.ts` in this directory is the only code
7
+ * that writes one out. The shape carries the two ways a response is not a
8
+ * buffered body — a file to stream and a stream to hold open — because those are
9
+ * properties of the answer, not of the server that serves it.
10
+ */
11
+
12
+ import { jsonHeaders, pageHeaders } from "./headers.ts";
13
+
14
+ /** One HTTP response, before it reaches a socket. */
15
+ export interface ServeResponse {
16
+ /** HTTP status code. */
17
+ status: number;
18
+ /** Headers to send verbatim. */
19
+ headers: Record<string, string>;
20
+ /** The buffered body; omitted for an empty response or a streamed file. */
21
+ body?: string | Buffer;
22
+ /** When set, the server streams this file instead of `body`. */
23
+ filePath?: string;
24
+ /** When true, the server keeps the connection open as a Server-Sent Events stream. */
25
+ stream?: boolean;
26
+ }
27
+
28
+ /** An HTML page, with any headers the route needs to add to the page policy. */
29
+ export function htmlResponse(status: number, body: string, extra?: Record<string, string>): ServeResponse {
30
+ return { status, headers: { ...pageHeaders(), ...extra }, body };
31
+ }
32
+
33
+ /** A JSON body, serialized once. */
34
+ export function jsonResponse(status: number, value: unknown): ServeResponse {
35
+ return { status, headers: jsonHeaders(), body: JSON.stringify(value) };
36
+ }
37
+
38
+ /** The JSON error envelope both API surfaces share: `{ error }`. */
39
+ export function jsonError(status: number, message: string): ServeResponse {
40
+ return jsonResponse(status, { error: message });
41
+ }
42
+
43
+ /** A plain-text body, for the internal-error fallback a server sends itself. */
44
+ export function textResponse(status: number, body: string): ServeResponse {
45
+ return { status, headers: { ...pageHeaders(), "Content-Type": "text/plain; charset=utf-8" }, body };
46
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The request-envelope helpers both loopback routers apply before a route runs:
3
+ * the request target, and the `Host` allowlist.
4
+ *
5
+ * Both surfaces bind `127.0.0.1`, so a `Host` naming anything else is a
6
+ * DNS-rebinding attempt against a local page. How strictly a missing `Host` is
7
+ * treated is the caller's decision: the browse server accepts one, because its
8
+ * router is also driven directly from tests and a plain `GET` with no header is
9
+ * not a rebinding, while the review server fails closed.
10
+ */
11
+
12
+ /** The `Host` names a loopback interface, with or without a port. */
13
+ const LOCAL_HOST = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i;
14
+
15
+ /** A request target split into the parts a router matches on. */
16
+ export interface ParsedRequestUrl {
17
+ /** The path, with the leading slash and no query. */
18
+ pathname: string;
19
+ /** The parsed query string. */
20
+ search: URLSearchParams;
21
+ }
22
+
23
+ /**
24
+ * Parse a raw request target (`path?query`) against a loopback base. Returns
25
+ * `undefined` for a target that cannot be parsed, which a router answers with
26
+ * `400` rather than a route.
27
+ */
28
+ export function parseRequestUrl(rawUrl: string): ParsedRequestUrl | undefined {
29
+ try {
30
+ const url = new URL(rawUrl, "http://127.0.0.1");
31
+ return { pathname: url.pathname, search: url.searchParams };
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Whether the request's `Host` names this loopback server.
39
+ *
40
+ * With `required: false`, only a present, non-empty foreign name is refused, so
41
+ * a header-less request from another caller still routes. With `required: true`,
42
+ * a missing or non-string `Host` is refused too, so the guard cannot be skipped
43
+ * by omitting the header.
44
+ */
45
+ export function isLocalHostHeader(
46
+ headers: Record<string, string | string[] | undefined>,
47
+ options: { required: boolean },
48
+ ): boolean {
49
+ const host = headers.host;
50
+ if (typeof host !== "string" || host === "") return !options.required;
51
+ return LOCAL_HOST.test(host);
52
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The socket seam shared by the two loopback servers: bind, write a response
3
+ * out, and close.
4
+ *
5
+ * Everything here is behavior a caller cannot get right by accident: a bind on
6
+ * `127.0.0.1` only, an accept-level `error` listener so a transient failure
7
+ * cannot take the host down, a `HEAD` that sends headers and no body, a `filePath`
8
+ * response streamed instead of buffered, and a close that is safe to call twice
9
+ * and drops keep-alive connections. What each server does *around* this — SSE
10
+ * streams, request bodies, session settling — stays with that server.
11
+ */
12
+
13
+ import { createReadStream } from "node:fs";
14
+ import type { IncomingMessage, Server, ServerResponse } from "node:http";
15
+ import type { ServeResponse } from "./response.ts";
16
+
17
+ /**
18
+ * Bind `server` to `127.0.0.1:port` and resolve the port it actually bound
19
+ * (`0` asks the OS to pick one). Rejects when the bind fails, so a caller that
20
+ * wants a fallback can retry with another port.
21
+ */
22
+ export async function bindLoopback(server: Server, port: number): Promise<number> {
23
+ await new Promise<void>((resolve, reject) => {
24
+ const onError = (error: Error) => reject(error);
25
+ server.once("error", onError);
26
+ server.listen(port, "127.0.0.1", () => {
27
+ server.off("error", onError);
28
+ resolve();
29
+ });
30
+ });
31
+ // An accept-level failure (EMFILE and friends) is emitted on the server, and an
32
+ // `error` event with no listener would take the host down mid-session. The
33
+ // affected request fails on its own and is answered as an error by the caller.
34
+ server.on("error", () => {});
35
+ const address = server.address();
36
+ return typeof address === "object" && address ? address.port : port;
37
+ }
38
+
39
+ /**
40
+ * Write one routed response: headers first, then no body for `HEAD`, the file
41
+ * for a `filePath` response, or the buffered body.
42
+ *
43
+ * A `stream` response is deliberately not handled here: keeping a connection
44
+ * open is the owning server's lifecycle, so it must check `stream` before
45
+ * calling this.
46
+ *
47
+ * `afterFlush` runs once a buffered body is on the wire, which is the point a
48
+ * server may resolve a session without the client seeing a dropped connection.
49
+ * A streamed file has no such single point, so `respond` never calls it for one.
50
+ */
51
+ export function respond(
52
+ request: IncomingMessage,
53
+ response: ServerResponse,
54
+ result: ServeResponse,
55
+ afterFlush?: () => void,
56
+ ): void {
57
+ response.writeHead(result.status, result.headers);
58
+ if ((request.method ?? "GET").toUpperCase() === "HEAD") {
59
+ response.end(afterFlush);
60
+ return;
61
+ }
62
+ if (result.filePath) {
63
+ const stream = createReadStream(result.filePath);
64
+ stream.on("error", () => response.destroy());
65
+ stream.pipe(response);
66
+ return;
67
+ }
68
+ response.end(result.body ?? "", afterFlush);
69
+ }
70
+
71
+ /**
72
+ * An idempotent `close()` for `server`.
73
+ *
74
+ * The second call resolves at once, so a shutdown path and a stop path can both
75
+ * await it. `closeAllConnections` is not on the `Server` type of every supported
76
+ * Node version, so it is probed: without it a keep-alive connection could hold
77
+ * the close open.
78
+ */
79
+ export function createCloser(server: Server): () => Promise<void> {
80
+ let closed = false;
81
+ return () =>
82
+ new Promise<void>((resolve) => {
83
+ if (closed) {
84
+ resolve();
85
+ return;
86
+ }
87
+ closed = true;
88
+ server.close(() => resolve());
89
+ (server as { closeAllConnections?: () => void }).closeAllConnections?.();
90
+ });
91
+ }
package/lib/lines.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Line splitting for a stream of untrusted output.
3
+ *
4
+ * A child's stdout, or a file read, arrives in arbitrary byte chunks: a chunk
5
+ * boundary can fall in the middle of a line, and — the case a naive
6
+ * `chunk.toString()` gets wrong — in the middle of a multi-byte UTF-8
7
+ * character. This buffers the bytes and decodes only at a newline, so a split
8
+ * character survives intact.
9
+ *
10
+ * `\r` is not treated as a break: a progress rewrite belongs to the sanitizer
11
+ * (`sanitizeUntrustedLine` in `lib/format.ts`, which keeps the text after the
12
+ * last carriage return), and breaking on it here would turn one rewritten line
13
+ * into several.
14
+ *
15
+ * Value-only: the buffer lives on the instance, so each reader owns one.
16
+ */
17
+
18
+ /** Bytes buffered for one incomplete line before it is emitted anyway. */
19
+ export const MAX_LINE_BYTES = 1024 * 1024;
20
+
21
+ export interface LineSplitterOptions {
22
+ /** Emit an unfinished line once it grows past this many bytes. Default 1 MiB. */
23
+ maxLineBytes?: number;
24
+ }
25
+
26
+ export class LineSplitter {
27
+ private pending: Buffer = Buffer.alloc(0);
28
+ private readonly maxLineBytes: number;
29
+
30
+ constructor(options: LineSplitterOptions = {}) {
31
+ this.maxLineBytes = options.maxLineBytes ?? MAX_LINE_BYTES;
32
+ }
33
+
34
+ /** Bytes held for the unfinished line. */
35
+ get pendingBytes(): number {
36
+ return this.pending.length;
37
+ }
38
+
39
+ /**
40
+ * Feed one chunk and take every complete line it finishes.
41
+ *
42
+ * A line far longer than any display can show is emitted once it reaches the
43
+ * cap instead of being dropped, so a program that never prints a newline still
44
+ * produces output.
45
+ */
46
+ push(chunk: Uint8Array | string): string[] {
47
+ const bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk);
48
+ const combined = this.pending.length > 0 ? Buffer.concat([this.pending, bytes]) : bytes;
49
+ const lines: string[] = [];
50
+ let start = 0;
51
+ for (let newline = combined.indexOf(0x0a); newline >= 0; newline = combined.indexOf(0x0a, start)) {
52
+ lines.push(combined.subarray(start, newline).toString("utf8"));
53
+ start = newline + 1;
54
+ }
55
+ let rest = combined.subarray(start);
56
+ if (rest.length > this.maxLineBytes) {
57
+ lines.push(rest.toString("utf8"));
58
+ rest = combined.subarray(0, 0);
59
+ }
60
+ // Copy, so a caller that reuses its read buffer cannot rewrite the tail.
61
+ this.pending = Buffer.from(rest);
62
+ return lines;
63
+ }
64
+
65
+ /** Take the unfinished line, for a stream that has ended. */
66
+ flush(): string[] {
67
+ if (this.pending.length === 0) return [];
68
+ const line = this.pending.toString("utf8");
69
+ this.pending = Buffer.alloc(0);
70
+ return [line];
71
+ }
72
+
73
+ /** Drop the unfinished line, for a source that was truncated or replaced. */
74
+ reset(): void {
75
+ this.pending = Buffer.alloc(0);
76
+ }
77
+ }
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
+ }