@gavin-hu/my-pi-agent 0.19.0 → 0.21.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 (208) hide show
  1. package/README.md +19 -12
  2. package/README.zh-CN.md +15 -9
  3. package/docs/extensions/README.md +2 -1
  4. package/docs/extensions/board.md +329 -151
  5. package/docs/extensions/bytefly.md +427 -273
  6. package/docs/extensions/file-browse.md +46 -44
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +86 -24
  9. package/docs/extensions/serve.md +129 -0
  10. package/docs/extensions/session-bus.md +23 -10
  11. package/docs/extensions/team.md +56 -28
  12. package/docs/extensions/wechat.md +2 -1
  13. package/docs/extensions/worktree.md +30 -15
  14. package/extensions/board/commands.ts +48 -17
  15. package/extensions/board/format.ts +107 -9
  16. package/extensions/board/index.ts +14 -2
  17. package/extensions/board/join.ts +31 -10
  18. package/extensions/board/page.ts +202 -156
  19. package/extensions/board/result.ts +10 -24
  20. package/extensions/board/rows.ts +1 -1
  21. package/extensions/board/schema.ts +31 -10
  22. package/extensions/board/seat.ts +9 -10
  23. package/extensions/board/snapshot.ts +7 -5
  24. package/extensions/board/tools.ts +211 -34
  25. package/extensions/board/transport/router.ts +27 -110
  26. package/extensions/board/transport/server.ts +76 -56
  27. package/extensions/board/types.ts +57 -0
  28. package/extensions/board/view/client.ts +24 -13
  29. package/extensions/board/view/dialog.ts +43 -0
  30. package/extensions/board/view/keys.ts +28 -24
  31. package/extensions/board/view/live.ts +6 -178
  32. package/extensions/board/view/names.ts +91 -0
  33. package/extensions/board/view/page.ts +60 -89
  34. package/extensions/board/view/render.ts +228 -200
  35. package/extensions/board/view/styles.ts +72 -49
  36. package/extensions/board/view/team.ts +63 -41
  37. package/extensions/bytefly/footer/budget.ts +6 -5
  38. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  39. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  40. package/extensions/bytefly/footer/config.ts +19 -11
  41. package/extensions/bytefly/footer/price/price.ts +30 -8
  42. package/extensions/bytefly/footer/register.ts +5 -5
  43. package/extensions/bytefly/footer/render/footer.ts +18 -4
  44. package/extensions/bytefly/footer/render/format.ts +18 -0
  45. package/extensions/bytefly/footer/render/key.ts +46 -0
  46. package/extensions/bytefly/footer/render/layout.ts +13 -3
  47. package/extensions/bytefly/footer/render/lines.ts +23 -7
  48. package/extensions/bytefly/footer/render/segments.ts +93 -31
  49. package/extensions/bytefly/footer/types.ts +29 -5
  50. package/extensions/bytefly/footer/unpriced.ts +42 -17
  51. package/extensions/bytefly/header/component.ts +4 -20
  52. package/extensions/bytefly/panel/commands.ts +28 -1
  53. package/extensions/bytefly/panel/filter.ts +68 -0
  54. package/extensions/bytefly/panel/format.ts +16 -15
  55. package/extensions/bytefly/panel/join.ts +56 -13
  56. package/extensions/bytefly/panel/prices.ts +43 -0
  57. package/extensions/bytefly/panel/settings.ts +72 -23
  58. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  59. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  60. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  61. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  62. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  63. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  64. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  65. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  66. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  67. package/extensions/bytefly/panel/tui.ts +306 -259
  68. package/extensions/check/commands.ts +2 -1
  69. package/extensions/check/render.ts +10 -1
  70. package/extensions/file-browse/config/config.ts +4 -19
  71. package/extensions/file-browse/files/classify.ts +136 -0
  72. package/extensions/file-browse/files/files.ts +8 -547
  73. package/extensions/file-browse/files/index.ts +90 -0
  74. package/extensions/file-browse/files/list.ts +261 -0
  75. package/extensions/file-browse/files/tree.ts +101 -0
  76. package/extensions/file-browse/index.ts +27 -48
  77. package/extensions/file-browse/live/live.ts +1 -1
  78. package/extensions/file-browse/transport/router.ts +12 -6
  79. package/extensions/file-browse/transport/server.ts +61 -28
  80. package/extensions/file-browse/view/chrome.ts +4 -4
  81. package/extensions/file-browse/view/client.ts +3 -1
  82. package/extensions/file-browse/view/emit.ts +51 -0
  83. package/extensions/file-browse/view/html.ts +78 -46
  84. package/extensions/file-browse/view/live-client.ts +2 -2
  85. package/extensions/file-browse/view/markdown.ts +23 -26
  86. package/extensions/file-browse/view/palette.ts +2 -2
  87. package/extensions/file-browse/view/raw-html.ts +27 -22
  88. package/extensions/file-browse/view/tree.ts +1 -1
  89. package/extensions/file-browse/view/urls.ts +20 -6
  90. package/extensions/goal/tui.ts +12 -2
  91. package/extensions/job/runtime.ts +4 -4
  92. package/extensions/job/tools.ts +19 -7
  93. package/extensions/job/tui.ts +2 -28
  94. package/extensions/plan/commands.ts +2 -2
  95. package/extensions/plan/feedback.ts +88 -18
  96. package/extensions/plan/format.ts +10 -3
  97. package/extensions/plan/index.ts +24 -2
  98. package/extensions/plan/plan-files.ts +88 -0
  99. package/extensions/plan/plan-text.ts +114 -0
  100. package/extensions/plan/plans.ts +19 -23
  101. package/extensions/plan/policy.ts +10 -5
  102. package/extensions/plan/runtime.ts +43 -4
  103. package/extensions/plan/todos.ts +51 -3
  104. package/extensions/plan/tools/exit.ts +10 -1
  105. package/extensions/plan/tools/todo-seed.ts +4 -2
  106. package/extensions/plan/tools/write.ts +12 -1
  107. package/extensions/plan/tools.ts +2 -2
  108. package/extensions/plan/types.ts +15 -0
  109. package/extensions/plan/web-blocks.ts +160 -0
  110. package/extensions/plan/web-chrome.ts +7 -5
  111. package/extensions/plan/web-client.ts +271 -325
  112. package/extensions/plan/web-collab.ts +7 -7
  113. package/extensions/plan/web-comments.ts +57 -16
  114. package/extensions/plan/web-composer.ts +91 -0
  115. package/extensions/plan/web-draft.ts +40 -0
  116. package/extensions/plan/web-keys.ts +258 -0
  117. package/extensions/plan/web-markdown.ts +1 -1
  118. package/extensions/plan/web-outline.ts +212 -0
  119. package/extensions/plan/web-page.ts +59 -86
  120. package/extensions/plan/web-payload.ts +188 -0
  121. package/extensions/plan/web-plan.ts +160 -0
  122. package/extensions/plan/web-router.ts +0 -1
  123. package/extensions/plan/web-settle.ts +201 -0
  124. package/extensions/plan/web-share-session.ts +24 -55
  125. package/extensions/plan/web-share.ts +19 -37
  126. package/extensions/plan/web-write.ts +112 -0
  127. package/extensions/plan/web.ts +77 -130
  128. package/extensions/rewind/commands/review.ts +2 -1
  129. package/extensions/serve/config.ts +37 -0
  130. package/extensions/serve/index.ts +71 -0
  131. package/extensions/serve/registry.ts +251 -0
  132. package/extensions/session-bus/join-request.ts +16 -9
  133. package/extensions/session-bus/result.ts +1 -27
  134. package/extensions/session-bus/rows.ts +7 -3
  135. package/extensions/session-bus/runtime.ts +100 -38
  136. package/extensions/session-bus/store.ts +14 -2
  137. package/extensions/session-bus/tools.ts +5 -4
  138. package/extensions/session-bus/tui.ts +1 -1
  139. package/extensions/session-bus/types.ts +10 -1
  140. package/extensions/subagent/commands.ts +2 -1
  141. package/extensions/team/commands.ts +44 -9
  142. package/extensions/team/format.ts +14 -0
  143. package/extensions/team/index.ts +2 -1
  144. package/extensions/team/tools.ts +30 -9
  145. package/extensions/team/types.ts +17 -0
  146. package/extensions/todo/index.ts +4 -1
  147. package/extensions/todo/nudge.ts +8 -2
  148. package/extensions/todo/tui.ts +21 -3
  149. package/extensions/wechat/bridge.ts +43 -172
  150. package/extensions/wechat/intake.ts +245 -0
  151. package/extensions/worktree/commands.ts +24 -17
  152. package/extensions/worktree/exit.ts +12 -6
  153. package/extensions/worktree/git.ts +22 -14
  154. package/extensions/worktree/guard.ts +15 -285
  155. package/extensions/worktree/handoff.ts +10 -2
  156. package/extensions/worktree/index.ts +2 -1
  157. package/extensions/worktree/lifecycle.ts +2 -0
  158. package/extensions/worktree/registry.ts +20 -11
  159. package/extensions/worktree/root-tools.ts +13 -3
  160. package/extensions/worktree/runtime.ts +8 -2
  161. package/extensions/worktree/screen.ts +15 -8
  162. package/extensions/worktree/shell.ts +303 -0
  163. package/extensions/worktree/state.ts +1 -1
  164. package/extensions/worktree/status.ts +42 -9
  165. package/extensions/worktree/tools.ts +19 -13
  166. package/lib/README.md +9 -7
  167. package/lib/board/card.ts +192 -3
  168. package/lib/board/links.ts +26 -2
  169. package/lib/board/page-pointer.ts +111 -32
  170. package/lib/board/round-text.ts +11 -2
  171. package/lib/board/round.ts +25 -20
  172. package/lib/board/rules.ts +144 -11
  173. package/lib/board/session.ts +84 -18
  174. package/lib/board/store.ts +268 -55
  175. package/lib/board/view.ts +114 -24
  176. package/lib/bus-request.ts +83 -0
  177. package/lib/format.ts +22 -0
  178. package/lib/http/index.ts +11 -7
  179. package/lib/http/mount.ts +142 -0
  180. package/lib/http/port.ts +25 -0
  181. package/lib/http/serve.ts +170 -43
  182. package/lib/http/server.ts +3 -3
  183. package/lib/http/sink.ts +34 -9
  184. package/lib/plan-entry.ts +20 -13
  185. package/lib/policy.ts +14 -1
  186. package/lib/requirements.ts +2 -2
  187. package/lib/session-label.ts +23 -7
  188. package/lib/session-roster.ts +74 -3
  189. package/lib/session-tool.ts +37 -21
  190. package/lib/spawn.ts +37 -3
  191. package/lib/stray-fields.ts +45 -0
  192. package/lib/tool-names.ts +13 -0
  193. package/lib/tui/README.md +6 -3
  194. package/lib/tui/glyphs.ts +3 -6
  195. package/lib/tui/render-cache.ts +29 -0
  196. package/lib/tui/tool.ts +30 -1
  197. package/lib/web/README.md +52 -2
  198. package/lib/web/client-runtime.ts +149 -0
  199. package/lib/web/index.ts +4 -2
  200. package/lib/web/markup.ts +106 -2
  201. package/lib/web/names.ts +42 -0
  202. package/package.json +3 -2
  203. package/extensions/board/config.ts +0 -36
  204. package/extensions/board/view/actions.ts +0 -306
  205. package/extensions/board/writes.ts +0 -99
  206. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  207. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  208. package/extensions/job/process.ts +0 -35
@@ -38,9 +38,9 @@ import {
38
38
  sanitizeHtmlBlock,
39
39
  sanitizeInlineTag,
40
40
  } from "./raw-html.ts";
41
- import { resolveImage, resolveLink } from "./urls.ts";
41
+ import { emitAnchor, emitImage, emitImageLink } from "./emit.ts";
42
+ import { type LinkBase, resolveImage, resolveLink } from "./urls.ts";
42
43
 
43
- const HTTP_SCHEME = /^https?:/i;
44
44
  /** A GFM extended autolink: `http(s)://` or `www.` followed by a domain. */
45
45
  const AUTOLINK = /^(?:https?:\/\/|www\.)[^\s<>]*/i;
46
46
 
@@ -65,12 +65,9 @@ interface LinkDefinition {
65
65
  title?: string;
66
66
  }
67
67
 
68
- /**
69
- * Per-document render state: the resolution base, the reference definitions,
70
- * and the raw-HTML balancing state.
71
- */
68
+ /** Per-document render state: where links resolve, the definitions, and the raw-HTML sink. */
72
69
  interface Context {
73
- baseDir: string;
70
+ link: LinkBase;
74
71
  defs: Map<string, LinkDefinition>;
75
72
  /** Emit sanitized raw HTML; `false` escapes it as text. */
76
73
  html: boolean;
@@ -82,6 +79,8 @@ interface Context {
82
79
  export interface MarkdownOptions {
83
80
  /** Emit sanitized raw HTML (the default) or escape it (`false`). */
84
81
  html?: boolean;
82
+ /** The prefix local links are written under, e.g. `/files`; `""` for the origin's root. */
83
+ base?: string;
85
84
  }
86
85
 
87
86
  /**
@@ -92,7 +91,7 @@ export interface MarkdownOptions {
92
91
  export function renderMarkdown(source: string, baseDir = "", options: MarkdownOptions = {}): string {
93
92
  const html = options.html ?? true;
94
93
  const { defs, body } = collectDefinitions(source.split(/\r\n|\r|\n/), html);
95
- const context = createContext(baseDir, defs, html);
94
+ const context = createContext({ dir: baseDir, base: options.base ?? "" }, defs, html);
96
95
  const rendered = renderBlocks(body, context);
97
96
  return html ? rendered + closeHtmlSink(context.sink) : rendered;
98
97
  }
@@ -110,10 +109,10 @@ function headingSlug(text: string): string {
110
109
  }
111
110
 
112
111
  /** Build the per-document render state, suffixing repeated heading slugs. */
113
- function createContext(baseDir: string, defs: Map<string, LinkDefinition>, html: boolean): Context {
112
+ function createContext(link: LinkBase, defs: Map<string, LinkDefinition>, html: boolean): Context {
114
113
  const seen = new Map<string, number>();
115
114
  return {
116
- baseDir,
115
+ link,
117
116
  defs,
118
117
  html,
119
118
  sink: createHtmlSink(),
@@ -338,7 +337,7 @@ function renderHtmlBlock(lines: string[], start: number, block: HtmlBlockStart,
338
337
  if (isBlank(lines[i])) break;
339
338
  collected.push(lines[i]);
340
339
  }
341
- return [sanitizeHtmlBlock(collected.join("\n"), ctx.baseDir, ctx.sink), i];
340
+ return [sanitizeHtmlBlock(collected.join("\n"), ctx.link, ctx.sink), i];
342
341
  }
343
342
 
344
343
  interface Fence {
@@ -641,22 +640,19 @@ function parseReferenceLink(text: string, start: number, ctx: Context): ParsedLi
641
640
 
642
641
  function renderLink(parsed: ParsedLink, ctx: Context): string {
643
642
  const label = renderInline(parsed.label, ctx);
644
- const target = resolveLink(parsed.dest, ctx.baseDir);
643
+ const target = resolveLink(parsed.dest, ctx.link);
645
644
  if (!target) return label;
646
- const title = parsed.title ? ` title="${escapeHtml(parsed.title)}"` : "";
647
- const external = target.external ? ` target="_blank" rel="noopener noreferrer"` : "";
648
- return `<a href="${escapeHtml(target.href)}"${title}${external}>${label}</a>`;
645
+ // The anchor's shape is `emit.ts`'s, the same one the raw-HTML sanitizer emits: a link
646
+ // written in Markdown and the same link written as HTML are the same link.
647
+ return emitAnchor({ href: target.href, content: label, title: parsed.title, external: target.external });
649
648
  }
650
649
 
651
650
  function renderImage(parsed: ParsedLink, ctx: Context): string {
652
651
  const alt = escapeHtml(parsed.label);
653
- const target = resolveImage(parsed.dest, ctx.baseDir);
652
+ const target = resolveImage(parsed.dest, ctx.link);
654
653
  if (!target) return alt;
655
- if (!target.image) {
656
- return `<a href="${escapeHtml(target.href)}" target="_blank" rel="noopener noreferrer">${alt || escapeHtml(parsed.dest)}</a>`;
657
- }
658
- const title = parsed.title ? ` title="${escapeHtml(parsed.title)}"` : "";
659
- return `<img class="md-img" src="${escapeHtml(target.href)}" alt="${alt}" loading="lazy" decoding="async"${title}>`;
654
+ if (!target.image) return emitImageLink(target.href, parsed.label || parsed.dest);
655
+ return emitImage({ href: target.href, alt, title: parsed.title });
660
656
  }
661
657
 
662
658
  function matchRun(text: string, start: number, char: string): number {
@@ -812,13 +808,15 @@ function renderInline(text: string, ctx: Context): string {
812
808
  const match = /^<((?:https?|mailto):[^>\s]+)>/i.exec(text.slice(i));
813
809
  if (match) {
814
810
  const href = match[1];
815
- const external = HTTP_SCHEME.test(href) ? ` target="_blank" rel="noopener noreferrer"` : "";
816
- out += `<a href="${escapeHtml(href)}"${external}>${escapeHtml(href)}</a>`;
811
+ // Resolved rather than pattern-matched: `urls.ts` owns which schemes open in a
812
+ // new tab, and a `<mailto:>` link is external but does not.
813
+ const external = resolveLink(href, ctx.link)?.external ?? false;
814
+ out += emitAnchor({ href, content: escapeHtml(href), external });
817
815
  i += match[0].length;
818
816
  continue;
819
817
  }
820
818
  if (ctx.html) {
821
- const tag = sanitizeInlineTag(text, i, ctx.baseDir, tags);
819
+ const tag = sanitizeInlineTag(text, i, ctx.link, tags);
822
820
  if (tag) {
823
821
  out += tag.html;
824
822
  i = tag.end;
@@ -836,8 +834,7 @@ function renderInline(text: string, ctx: Context): string {
836
834
  // Require a domain, so a bare scheme is not a link.
837
835
  if (dots > 0 && /\.[^./]/.test(label.slice(dots))) {
838
836
  const href = /^www\./i.test(label) ? `http://${label}` : label;
839
- const external = HTTP_SCHEME.test(href) ? ` target="_blank" rel="noopener noreferrer"` : "";
840
- out += `<a href="${escapeHtml(href)}"${external}>${escapeHtml(label)}</a>`;
837
+ out += emitAnchor({ href, content: escapeHtml(label), external: true });
841
838
  i += label.length;
842
839
  continue;
843
840
  }
@@ -122,7 +122,7 @@ export const PALETTE_CLIENT_JS = ` // Quick open: a fuzzy path palette backed by
122
122
 
123
123
  function runSearch() {
124
124
  var query = paletteInput.value.trim();
125
- fetch("/api/files?q=" + encodeURIComponent(query))
125
+ fetch(pageBase() + "/api/files?q=" + encodeURIComponent(query))
126
126
  .then(function (response) {
127
127
  if (!response.ok) throw new Error("search failed");
128
128
  return response.json();
@@ -212,7 +212,7 @@ export const PALETTE_CLIENT_JS = ` // Quick open: a fuzzy path palette backed by
212
212
 
213
213
  function openActive() {
214
214
  var result = paletteResults[paletteActive];
215
- if (result) window.location.assign("/view/" + encodeSegments(result.path));
215
+ if (result) window.location.assign(pageBase() + "/view/" + encodeSegments(result.path));
216
216
  }
217
217
  var quickOpenButton = document.getElementById("quick-open");
218
218
  if (quickOpenButton) quickOpenButton.addEventListener("click", openPalette);
@@ -26,7 +26,8 @@
26
26
  */
27
27
 
28
28
  import { decodeCodePoint, escapeHtml } from "../../../lib/escape.ts";
29
- import { resolveImage, resolveLink } from "./urls.ts";
29
+ import { emitImage, emitImageLink, EXTERNAL_ATTRS } from "./emit.ts";
30
+ import { type LinkBase, resolveImage, resolveLink } from "./urls.ts";
30
31
 
31
32
  /** Elements re-emitted with sanitized attributes. */
32
33
  const BLOCK_ELEMENTS = new Set([
@@ -139,6 +140,12 @@ const BLOCK_OPEN = /^ {0,3}<(\/?)([a-zA-Z][a-zA-Z0-9-]*)/;
139
140
  const COMMENT_OPEN = /^ {0,3}<!--/;
140
141
  const DECLARATION_OPEN = /^ {0,3}<[!?]/;
141
142
  /** The five XML references, plus numeric ones, are the only refs decoded in a value. */
143
+ /**
144
+ * The five references an attribute value may carry, decoded.
145
+ *
146
+ * Not the Markdown renderer's table: a tag's attribute is not running text, and decoding
147
+ * `&nbsp;` or `&mdash;` inside one would invent characters the author did not write.
148
+ */
142
149
  const ATTRIBUTE_ENTITIES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
143
150
  const ATTRIBUTE_ENTITY = /&(?:#([0-9]{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z]+));/g;
144
151
 
@@ -198,7 +205,7 @@ export function closesSuppressed(line: string, name: string): boolean {
198
205
  }
199
206
 
200
207
  /** Sanitize one HTML block, emitting text and allowlisted tags into `sink`. */
201
- export function sanitizeHtmlBlock(source: string, baseDir: string, sink: HtmlSink): string {
208
+ export function sanitizeHtmlBlock(source: string, linkBase: LinkBase, sink: HtmlSink): string {
202
209
  let out = "";
203
210
  let i = 0;
204
211
  while (i < source.length) {
@@ -228,7 +235,7 @@ export function sanitizeHtmlBlock(source: string, baseDir: string, sink: HtmlSin
228
235
  out += closeTo(sink.stack, sink.stack.lastIndexOf(tag.name));
229
236
  continue;
230
237
  }
231
- out += tag.name === "img" ? sanitizeImage(tag, baseDir) : sanitizeOpenTag(tag, baseDir);
238
+ out += tag.name === "img" ? sanitizeImage(tag, linkBase) : sanitizeOpenTag(tag, linkBase);
232
239
  if (tag.name !== "img" && !VOID.has(tag.name) && !tag.selfClosing) sink.stack.push(tag.name);
233
240
  }
234
241
  return out;
@@ -243,7 +250,7 @@ export function sanitizeHtmlBlock(source: string, baseDir: string, sink: HtmlSin
243
250
  export function sanitizeInlineTag(
244
251
  source: string,
245
252
  start: number,
246
- baseDir: string,
253
+ linkBase: LinkBase,
247
254
  stack: string[],
248
255
  ): { html: string; end: number } | undefined {
249
256
  const tag = parseRawTag(source, start);
@@ -253,7 +260,7 @@ export function sanitizeInlineTag(
253
260
  if (SUPPRESSED.has(tag.name)) return { html: "", end: skipSuppressed(source, tag.next, tag) };
254
261
  if (!ALLOWED.has(tag.name) || !PHRASING.has(tag.name)) return undefined;
255
262
  if (tag.end) return { html: closeTo(stack, stack.lastIndexOf(tag.name)), end: tag.next };
256
- const html = tag.name === "img" ? sanitizeImage(tag, baseDir) : sanitizeOpenTag(tag, baseDir);
263
+ const html = tag.name === "img" ? sanitizeImage(tag, linkBase) : sanitizeOpenTag(tag, linkBase);
257
264
  if (tag.name !== "img" && !VOID.has(tag.name) && !tag.selfClosing) stack.push(tag.name);
258
265
  return { html, end: tag.next };
259
266
  }
@@ -395,7 +402,7 @@ function skipSuppressed(source: string, from: number, tag: RawTag): number {
395
402
  }
396
403
 
397
404
  /** Rebuild an allowlisted start tag from its parsed name and allowed attributes. */
398
- function sanitizeOpenTag(tag: RawTag, baseDir: string): string {
405
+ function sanitizeOpenTag(tag: RawTag, linkBase: LinkBase): string {
399
406
  const out: string[] = [];
400
407
  const id = attribute(tag, "id");
401
408
  if (id !== undefined && id !== "") out.push(` id="${escapeHtml(id)}"`);
@@ -404,10 +411,10 @@ function sanitizeOpenTag(tag: RawTag, baseDir: string): string {
404
411
  switch (tag.name) {
405
412
  case "a": {
406
413
  const href = attribute(tag, "href");
407
- const target = href === undefined ? undefined : resolveLink(href, baseDir);
414
+ const target = href === undefined ? undefined : resolveLink(href, linkBase);
408
415
  if (target !== undefined) {
409
416
  out.push(` href="${escapeHtml(target.href)}"`);
410
- if (target.external) out.push(' target="_blank" rel="noopener noreferrer"');
417
+ if (target.external) out.push(EXTERNAL_ATTRS);
411
418
  }
412
419
  break;
413
420
  }
@@ -451,21 +458,19 @@ function sanitizeOpenTag(tag: RawTag, baseDir: string): string {
451
458
  * (the page CSP forbids remote images), and an unresolvable one leaves the alt
452
459
  * text, so a broken badge is still readable.
453
460
  */
454
- function sanitizeImage(tag: RawTag, baseDir: string): string {
461
+ function sanitizeImage(tag: RawTag, linkBase: LinkBase): string {
455
462
  const alt = attribute(tag, "alt") ?? "";
456
463
  const src = attribute(tag, "src");
457
- const target = src === undefined ? undefined : resolveImage(src, baseDir);
464
+ const target = src === undefined ? undefined : resolveImage(src, linkBase);
458
465
  if (target === undefined) return alt === "" ? "" : escapeHtml(alt);
459
- if (!target.image) {
460
- const label = alt === "" ? target.href : alt;
461
- return `<a href="${escapeHtml(target.href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(label)}</a>`;
462
- }
463
- const out: string[] = [` class="md-img" src="${escapeHtml(target.href)}" alt="${escapeHtml(alt)}"`];
464
- const width = numeric(attribute(tag, "width"));
465
- if (width !== undefined) out.push(` width="${width}"`);
466
- const height = numeric(attribute(tag, "height"));
467
- if (height !== undefined) out.push(` height="${height}"`);
468
- const title = attribute(tag, "title");
469
- if (title !== undefined) out.push(` title="${escapeHtml(title)}"`);
470
- return `<img${out.join("")} loading="lazy" decoding="async">`;
466
+ if (!target.image) return emitImageLink(target.href, alt === "" ? target.href : alt);
467
+ // The element is `emit.ts`'s, so an `<img>` written as HTML and the same one written as
468
+ // Markdown are the same element, down to the lazy decoding.
469
+ return emitImage({
470
+ href: target.href,
471
+ alt,
472
+ title: attribute(tag, "title"),
473
+ width: numeric(attribute(tag, "width")),
474
+ height: numeric(attribute(tag, "height")),
475
+ });
471
476
  }
@@ -189,7 +189,7 @@ export const TREE_CLIENT_JS = ` var tree = document.querySelector("nav.bf-tree")
189
189
  li.dataset.loading = "true";
190
190
  twisty.textContent = "\\u2026";
191
191
  if (link) link.setAttribute("aria-busy", "true");
192
- fetch("/api/tree?path=" + encodeURIComponent(path))
192
+ fetch(pageBase() + "/api/tree?path=" + encodeURIComponent(path))
193
193
  .then(function (response) {
194
194
  if (!response.ok) throw new Error("load failed");
195
195
  return response.json();
@@ -26,6 +26,20 @@ export interface ResolvedLink {
26
26
  external: boolean;
27
27
  }
28
28
 
29
+ /**
30
+ * Where a destination resolves from, and what it is written under.
31
+ *
32
+ * `dir` is the root-relative directory the destination is relative to; `base` is the
33
+ * prefix the page answers under, so a page served at `/files` writes
34
+ * `/files/view/<path>` and one holding the origin's root writes `/view/<path>`. The two
35
+ * are one value because a caller that knows one almost always knows the other, and a
36
+ * second parameter is a second thing to thread through ten functions.
37
+ */
38
+ export interface LinkBase {
39
+ dir: string;
40
+ base: string;
41
+ }
42
+
29
43
  /** An image destination the page may emit; `image: false` means link to it instead. */
30
44
  export interface ResolvedImage {
31
45
  href: string;
@@ -53,15 +67,15 @@ function resolveLocalPath(href: string, baseDir: string): { rel: string; suffix:
53
67
  }
54
68
 
55
69
  /** Resolve a link destination, or `undefined` when the scheme or path is not allowed. */
56
- export function resolveLink(dest: string, baseDir: string): ResolvedLink | undefined {
70
+ export function resolveLink(dest: string, linkBase: LinkBase): ResolvedLink | undefined {
57
71
  const value = dest.trim();
58
72
  if (value === "") return undefined;
59
73
  if (value.startsWith("#")) return { href: value, external: false };
60
74
  if (SAFE_LINK_SCHEME.test(value)) return { href: value, external: HTTP_SCHEME.test(value) };
61
75
  if (HAS_SCHEME.test(value)) return undefined;
62
- const local = resolveLocalPath(value, baseDir);
76
+ const local = resolveLocalPath(value, linkBase.dir);
63
77
  if (!local) return undefined;
64
- return { href: `/view/${encodePath(local.rel)}${local.suffix}`, external: false };
78
+ return { href: `${linkBase.base}/view/${encodePath(local.rel)}${local.suffix}`, external: false };
65
79
  }
66
80
 
67
81
  /**
@@ -69,12 +83,12 @@ export function resolveLink(dest: string, baseDir: string): ResolvedLink | undef
69
83
  * URL resolves with `image: false` because the page CSP forbids remote images, so
70
84
  * the caller renders a link instead of an `<img>`.
71
85
  */
72
- export function resolveImage(dest: string, baseDir: string): ResolvedImage | undefined {
86
+ export function resolveImage(dest: string, linkBase: LinkBase): ResolvedImage | undefined {
73
87
  const value = dest.trim();
74
88
  if (value === "") return undefined;
75
89
  if (HTTP_SCHEME.test(value)) return { href: value, image: false };
76
90
  if (HAS_SCHEME.test(value)) return undefined;
77
- const local = resolveLocalPath(value, baseDir);
91
+ const local = resolveLocalPath(value, linkBase.dir);
78
92
  if (!local) return undefined;
79
- return { href: `/raw/${encodePath(local.rel)}${local.suffix}`, image: true };
93
+ return { href: `${linkBase.base}/raw/${encodePath(local.rel)}${local.suffix}`, image: true };
80
94
  }
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type { Theme } from "@earendil-works/pi-coding-agent";
12
12
  import { truncateToWidth, type Component } from "@earendil-works/pi-tui";
13
+ import { RenderCache } from "../../lib/tui/render-cache.ts";
13
14
  import { goalHeader, goalLine } from "./format.ts";
14
15
  import type { AchievedStyle, Goal } from "./types.ts";
15
16
 
@@ -47,6 +48,7 @@ export class GoalWidget implements Component {
47
48
  export class GoalHeader implements Component {
48
49
  private goal: Goal;
49
50
  private theme: Theme;
51
+ private readonly cache = new RenderCache();
50
52
 
51
53
  constructor(goal: Goal, theme: Theme) {
52
54
  this.goal = goal;
@@ -57,13 +59,21 @@ export class GoalHeader implements Component {
57
59
  update(goal: Goal, theme: Theme): void {
58
60
  this.goal = goal;
59
61
  this.theme = theme;
62
+ this.cache.invalidate();
60
63
  }
61
64
 
62
- invalidate(): void {}
65
+ invalidate(): void {
66
+ this.cache.invalidate();
67
+ }
63
68
 
64
69
  render(width: number): string[] {
70
+ const w = Math.max(1, width);
71
+ return this.cache.lines(w, undefined, () => this.build(w));
72
+ }
73
+
74
+ private build(w: number): string[] {
65
75
  // The leading blank line separates the header from the call line, matching
66
76
  // the built-ins' header/output gap.
67
- return ["", truncateToWidth(goalHeader(this.goal, this.theme), Math.max(1, width), "…")];
77
+ return ["", truncateToWidth(goalHeader(this.goal, this.theme), w, "…")];
68
78
  }
69
79
  }
@@ -29,6 +29,7 @@ import {
29
29
  type KillTreeFn,
30
30
  type SpawnedProcess,
31
31
  type SpawnFn,
32
+ startTokenOf,
32
33
  type StdinSink,
33
34
  } from "../../lib/spawn.ts";
34
35
  import { loadConfig, type JobsConfig } from "./config.ts";
@@ -38,7 +39,6 @@ import { defaultInterval } from "../../lib/timer.ts";
38
39
  import { clipLabel } from "../../lib/tui/columns.ts";
39
40
  import { compareJobs, formatLogs } from "./format.ts";
40
41
  import { readLogTail } from "./logs.ts";
41
- import { defaultStartToken, type LivenessFn, type StartTokenFn } from "./process.ts";
42
42
  import {
43
43
  loadRegistries,
44
44
  mergeRecords,
@@ -114,9 +114,9 @@ export interface SendResult {
114
114
 
115
115
  export interface RuntimeOptions {
116
116
  spawn?: SpawnFn;
117
- liveness?: LivenessFn;
117
+ liveness?: typeof isPidAlive;
118
118
  killTree?: KillTreeFn;
119
- startToken?: StartTokenFn;
119
+ startToken?: typeof startTokenOf;
120
120
  now?: () => number;
121
121
  /** Platform seam: whether this is Windows. Defaults to the running platform. */
122
122
  isWindows?: () => boolean;
@@ -183,7 +183,7 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
183
183
  const spawn = options.spawn ?? defaultSpawn;
184
184
  const liveness = options.liveness ?? isPidAlive;
185
185
  const killTree = options.killTree ?? defaultKillTree;
186
- const startToken = options.startToken ?? defaultStartToken;
186
+ const startToken = options.startToken ?? startTokenOf;
187
187
  const now = options.now ?? (() => Date.now());
188
188
  const isWindows = options.isWindows ?? (() => process.platform === "win32");
189
189
 
@@ -11,7 +11,15 @@
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  import { errorResult, textResult } from "../../lib/tool-result.ts";
13
13
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
14
- import { callLine, detailsOrLine, elapsedSuffix, rejectionLine, syncElapsedTimer } from "../../lib/tui/tool.ts";
14
+ import {
15
+ callLine,
16
+ detailsOrLine,
17
+ elapsedSuffix,
18
+ isTailResult,
19
+ rejectionLine,
20
+ syncElapsedTimer,
21
+ TailResult,
22
+ } from "../../lib/tui/tool.ts";
15
23
  import { SEPARATORS } from "../../lib/tui/glyphs.ts";
16
24
  import { paint } from "../../lib/tui/tokens.ts";
17
25
  import {
@@ -26,8 +34,8 @@ import {
26
34
  import type { JobsRuntime } from "./runtime.ts";
27
35
  import { JobParams, normalizeCall, type JobArgs, type JobCall } from "./schema.ts";
28
36
  import { toRecord } from "./store.ts";
29
- import { JobResult, type JobResultInput } from "./tui.ts";
30
- import type { JobDetails } from "./types.ts";
37
+ import { jobTail, type JobResultInput } from "./tui.ts";
38
+ import type { JobDetails, JobRecord } from "./types.ts";
31
39
 
32
40
  export const TOOL_NAME = "job";
33
41
 
@@ -224,8 +232,10 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
224
232
  }
225
233
  const shown = options.expanded ? all : all.slice(Math.max(0, all.length - RESULT_ROWS));
226
234
  const input: JobResultInput = { kind: "logs", lines: shown, earlier: all.length - shown.length };
227
- const view = context.lastComponent instanceof JobResult ? context.lastComponent : new JobResult(input, theme);
228
- view.setInput(input, theme);
235
+ const previous = context.lastComponent;
236
+ const tail = jobTail(input);
237
+ const view = isTailResult<JobRecord>(previous) ? previous : new TailResult<JobRecord>(tail, theme);
238
+ view.setInput(tail, theme);
229
239
  return view;
230
240
  }
231
241
 
@@ -242,8 +252,10 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
242
252
  more: ordered.length - shown.length,
243
253
  sessionId: details.sessionId,
244
254
  };
245
- const view = context.lastComponent instanceof JobResult ? context.lastComponent : new JobResult(input, theme);
246
- view.setInput(input, theme);
255
+ const previous = context.lastComponent;
256
+ const tail = jobTail(input);
257
+ const view = isTailResult<JobRecord>(previous) ? previous : new TailResult<JobRecord>(tail, theme);
258
+ view.setInput(tail, theme);
247
259
  return view;
248
260
  }
249
261
 
@@ -15,13 +15,12 @@
15
15
  */
16
16
 
17
17
  import type { Theme } from "@earendil-works/pi-coding-agent";
18
- import type { Component } from "@earendil-works/pi-tui";
19
18
  import { formatDuration, sanitizeUntrustedLine } from "../../lib/format.ts";
20
19
  import type { ScreenHint, ViewportRowsSource } from "../../lib/tui/chrome.ts";
21
20
  import { DockListScreen, type DetailContents } from "../../lib/tui/dock-screen.ts";
22
21
  import { SEPARATORS } from "../../lib/tui/glyphs.ts";
23
22
  import { statusRow, type StatusRow } from "../../lib/tui/rows.ts";
24
- import { TailResult, type TailResultInput } from "../../lib/tui/tool.ts";
23
+ import type { TailResultInput } from "../../lib/tui/tool.ts";
25
24
  import { paint } from "../../lib/tui/tokens.ts";
26
25
  import {
27
26
  compareJobs,
@@ -62,7 +61,7 @@ export type JobResultInput =
62
61
  | { kind: "logs"; lines: string[]; earlier: number };
63
62
 
64
63
  /** Map the job vocabulary onto the shared transcript tail block. */
65
- function jobTail(input: JobResultInput): TailResultInput<JobRecord> {
64
+ export function jobTail(input: JobResultInput): TailResultInput<JobRecord> {
66
65
  if (input.kind === "logs") {
67
66
  return { kind: "lines", lines: input.lines, earlier: input.earlier, earlierLabel: "lines" };
68
67
  }
@@ -77,31 +76,6 @@ function jobTail(input: JobResultInput): TailResultInput<JobRecord> {
77
76
  };
78
77
  }
79
78
 
80
- /**
81
- * Transcript result block for the `job` tool: the shared tail block over the
82
- * `/jobs`-style row rail or the themed log tail. Reused across renders.
83
- */
84
- export class JobResult implements Component {
85
- private readonly view: TailResult<JobRecord>;
86
-
87
- constructor(input: JobResultInput, theme: Theme) {
88
- this.view = new TailResult(jobTail(input), theme);
89
- }
90
-
91
- /** Update in place so the transcript can reuse this component across renders. */
92
- setInput(input: JobResultInput, theme: Theme): void {
93
- this.view.setInput(jobTail(input), theme);
94
- }
95
-
96
- invalidate(): void {
97
- this.view.invalidate();
98
- }
99
-
100
- render(width: number): string[] {
101
- return this.view.render(width);
102
- }
103
- }
104
-
105
79
  export interface JobListCallbacks {
106
80
  /** This session's id: a record with another id belongs to a peer session. */
107
81
  sessionId(): string;
@@ -19,7 +19,7 @@ import { PlanIndexComponent, formatPlanIndex } from "./plan-index.ts";
19
19
  import { runBrowserReview } from "./review.ts";
20
20
  import type { PlanRuntime } from "./runtime.ts";
21
21
  import type { PlanSummary } from "./plans.ts";
22
- import { createPlanWebReview, type PlanWebReview } from "./web.ts";
22
+ import { createPlanWebReview, NO_SERVER_DEPS, type PlanWebReview } from "./web.ts";
23
23
 
24
24
  const ENABLED_NOTICE = `Plan mode enabled — ${READ_ONLY_SUMMARY}.`;
25
25
  const DISABLED_NOTICE = "Plan mode disabled — full access restored.";
@@ -219,7 +219,7 @@ async function openPlanIndex(
219
219
  export function registerCommands(
220
220
  pi: ExtensionAPI,
221
221
  runtime: PlanRuntime,
222
- web: PlanWebReview = createPlanWebReview(runtime.plans),
222
+ web: PlanWebReview = createPlanWebReview(runtime.plans, NO_SERVER_DEPS),
223
223
  ): void {
224
224
  pi.registerCommand("plan", {
225
225
  description: