@gavin-hu/my-pi-agent 0.16.0 → 0.18.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 (200) hide show
  1. package/README.md +29 -15
  2. package/README.zh-CN.md +27 -15
  3. package/extensions/bytefly/README.md +195 -115
  4. package/extensions/bytefly/footer/collect/entries.ts +1 -6
  5. package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
  6. package/extensions/bytefly/footer/price/price.ts +12 -8
  7. package/extensions/bytefly/footer/register.ts +13 -1
  8. package/extensions/bytefly/footer/types.ts +11 -0
  9. package/extensions/bytefly/footer/unpriced.ts +36 -0
  10. package/extensions/bytefly/header/component.ts +15 -29
  11. package/extensions/bytefly/header/identity.ts +71 -0
  12. package/extensions/bytefly/header/register.ts +9 -3
  13. package/extensions/bytefly/index.ts +13 -5
  14. package/extensions/bytefly/message/format.ts +20 -13
  15. package/extensions/bytefly/message/register.ts +8 -7
  16. package/extensions/bytefly/notify/types.ts +1 -1
  17. package/extensions/bytefly/panel/commands.ts +46 -25
  18. package/extensions/bytefly/panel/format.ts +49 -14
  19. package/extensions/bytefly/panel/join.ts +35 -9
  20. package/extensions/bytefly/panel/settings.ts +29 -39
  21. package/extensions/bytefly/panel/skills.ts +51 -0
  22. package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
  23. package/extensions/bytefly/panel/tui/frame.ts +9 -1
  24. package/extensions/bytefly/panel/tui/list-view.ts +72 -0
  25. package/extensions/bytefly/panel/tui/nav.ts +71 -3
  26. package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
  27. package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
  28. package/extensions/bytefly/panel/tui.ts +223 -331
  29. package/extensions/bytefly/panel/types.ts +26 -8
  30. package/extensions/bytefly/picker/discovery.ts +32 -27
  31. package/extensions/bytefly/picker/resources.ts +16 -8
  32. package/extensions/bytefly/picker/runtime.ts +62 -34
  33. package/extensions/bytefly/picker/toggle.ts +16 -10
  34. package/extensions/bytefly/picker/types.ts +31 -13
  35. package/extensions/bytefly/update/format.ts +66 -8
  36. package/extensions/bytefly/update/patch.ts +66 -0
  37. package/extensions/bytefly/update/register.ts +4 -11
  38. package/extensions/bytefly/update/release.ts +65 -0
  39. package/extensions/bytefly/update/state.ts +14 -0
  40. package/extensions/bytefly/update/suppress.ts +47 -29
  41. package/extensions/bytefly/update/types.ts +15 -5
  42. package/extensions/check/tui.ts +2 -0
  43. package/extensions/context/README.md +21 -11
  44. package/extensions/context/format.ts +10 -8
  45. package/extensions/context/report.ts +21 -8
  46. package/extensions/context/tui.ts +26 -10
  47. package/extensions/context/types.ts +5 -3
  48. package/extensions/file-browse/README.md +89 -22
  49. package/extensions/file-browse/chrome.ts +83 -0
  50. package/extensions/file-browse/client.ts +74 -459
  51. package/extensions/file-browse/config.ts +12 -3
  52. package/extensions/file-browse/diff-html.ts +94 -0
  53. package/extensions/file-browse/html.ts +26 -152
  54. package/extensions/file-browse/live-client.ts +47 -1
  55. package/extensions/file-browse/live.ts +35 -14
  56. package/extensions/file-browse/palette.ts +224 -0
  57. package/extensions/file-browse/rev.ts +42 -0
  58. package/extensions/file-browse/router.ts +27 -6
  59. package/extensions/file-browse/server.ts +7 -0
  60. package/extensions/file-browse/styles.ts +10 -3
  61. package/extensions/file-browse/tree.ts +381 -0
  62. package/extensions/file-read/README.md +4 -1
  63. package/extensions/file-read/extract/odt.ts +7 -5
  64. package/extensions/goal/plan-route.ts +8 -2
  65. package/extensions/goal/tools.ts +6 -11
  66. package/extensions/job/README.md +7 -5
  67. package/extensions/job/index.ts +35 -52
  68. package/extensions/job/tools.ts +51 -77
  69. package/extensions/memory/README.md +3 -3
  70. package/extensions/memory/tools.ts +4 -6
  71. package/extensions/memory/tui.ts +5 -1
  72. package/extensions/plan/README.md +333 -84
  73. package/extensions/plan/commands.ts +73 -7
  74. package/extensions/plan/feedback.ts +300 -63
  75. package/extensions/plan/index.ts +1 -1
  76. package/extensions/plan/plan-index.ts +4 -2
  77. package/extensions/plan/plan-text.ts +18 -1
  78. package/extensions/plan/plans.ts +184 -58
  79. package/extensions/plan/review-prompt.ts +93 -0
  80. package/extensions/plan/review.ts +95 -95
  81. package/extensions/plan/runtime.ts +4 -2
  82. package/extensions/plan/split.ts +87 -0
  83. package/extensions/plan/steps.ts +123 -107
  84. package/extensions/plan/tools/cancel.ts +79 -0
  85. package/extensions/plan/tools/enter.ts +17 -12
  86. package/extensions/plan/tools/exit.ts +148 -59
  87. package/extensions/plan/tools/steps.ts +7 -19
  88. package/extensions/plan/tools/write.ts +29 -31
  89. package/extensions/plan/tools.ts +6 -3
  90. package/extensions/plan/tui.ts +60 -15
  91. package/extensions/plan/types.ts +45 -3
  92. package/extensions/plan/web-chrome.ts +102 -0
  93. package/extensions/plan/web-client.ts +177 -446
  94. package/extensions/plan/web-collab.ts +207 -0
  95. package/extensions/plan/web-comments.ts +269 -0
  96. package/extensions/plan/web-markdown.ts +222 -0
  97. package/extensions/plan/web-page.ts +131 -19
  98. package/extensions/plan/web-router.ts +148 -32
  99. package/extensions/plan/web-share-session.ts +197 -0
  100. package/extensions/plan/web-share.ts +169 -0
  101. package/extensions/plan/web-streams.ts +124 -0
  102. package/extensions/plan/web-tokens.ts +63 -0
  103. package/extensions/plan/web.ts +193 -45
  104. package/extensions/question/README.md +3 -0
  105. package/extensions/question/dialogs.ts +91 -1
  106. package/extensions/question/tools.ts +2 -1
  107. package/extensions/rewind/checklist.ts +4 -0
  108. package/extensions/rewind/review-tui.ts +3 -1
  109. package/extensions/rewind/rewind-tui.ts +4 -0
  110. package/extensions/session-bus/README.md +247 -0
  111. package/extensions/session-bus/commands.ts +165 -0
  112. package/extensions/session-bus/format.ts +124 -0
  113. package/extensions/session-bus/index.ts +69 -0
  114. package/extensions/session-bus/notice.ts +58 -0
  115. package/extensions/session-bus/protocol.ts +134 -0
  116. package/extensions/session-bus/result.ts +243 -0
  117. package/extensions/session-bus/rows.ts +169 -0
  118. package/extensions/session-bus/runtime.ts +575 -0
  119. package/extensions/session-bus/store.ts +329 -0
  120. package/extensions/session-bus/team.ts +102 -0
  121. package/extensions/session-bus/tools.ts +227 -0
  122. package/extensions/session-bus/tui.ts +394 -0
  123. package/extensions/session-bus/types.ts +98 -0
  124. package/extensions/subagent/README.md +26 -13
  125. package/extensions/subagent/format.ts +72 -25
  126. package/extensions/subagent/index.ts +21 -47
  127. package/extensions/subagent/orchestrate.ts +18 -41
  128. package/extensions/subagent/render.ts +29 -42
  129. package/extensions/subagent/stream.ts +24 -18
  130. package/extensions/subagent/types.ts +7 -0
  131. package/extensions/todo/tools.ts +5 -6
  132. package/extensions/todo/tui.ts +5 -1
  133. package/extensions/watch/README.md +9 -5
  134. package/extensions/watch/index.ts +25 -44
  135. package/extensions/watch/tools.ts +31 -59
  136. package/extensions/wechat/README.md +45 -36
  137. package/extensions/wechat/bridge.ts +46 -48
  138. package/extensions/wechat/client.ts +1 -4
  139. package/extensions/wechat/commands.ts +3 -3
  140. package/extensions/wechat/deliver.ts +57 -37
  141. package/extensions/wechat/interaction.ts +54 -18
  142. package/extensions/wechat/login-screen.ts +2 -3
  143. package/extensions/wechat/notices.ts +9 -7
  144. package/extensions/wechat/peer-worker.ts +19 -34
  145. package/extensions/wechat/presence.ts +3 -4
  146. package/extensions/wechat/prompt.ts +53 -23
  147. package/extensions/wechat/reply-pipeline.ts +12 -4
  148. package/extensions/wechat/slash.ts +14 -0
  149. package/extensions/wechat/status-screen.ts +2 -2
  150. package/extensions/wechat/tools.ts +3 -11
  151. package/extensions/worktree/README.md +48 -12
  152. package/extensions/worktree/commands.ts +109 -65
  153. package/extensions/worktree/enter.ts +1 -1
  154. package/extensions/worktree/exit.ts +11 -1
  155. package/extensions/worktree/git.ts +5 -0
  156. package/extensions/worktree/lifecycle.ts +9 -1
  157. package/extensions/worktree/registry.ts +11 -5
  158. package/extensions/worktree/screen.ts +263 -0
  159. package/extensions/worktree/status.ts +84 -40
  160. package/lib/README.md +12 -7
  161. package/lib/claims/overlap.ts +43 -0
  162. package/lib/config.ts +12 -0
  163. package/lib/format.ts +6 -2
  164. package/lib/http/router.ts +40 -13
  165. package/lib/http/server.ts +27 -13
  166. package/lib/interaction.ts +28 -1
  167. package/lib/notice.ts +29 -2
  168. package/lib/plan-entry.ts +7 -2
  169. package/lib/plans/sections.ts +172 -0
  170. package/lib/plans/setting.ts +52 -0
  171. package/lib/plans/split.ts +215 -0
  172. package/lib/plans/store.ts +19 -0
  173. package/lib/plans/team.ts +151 -0
  174. package/lib/qr.ts +64 -0
  175. package/lib/repo-key.ts +74 -0
  176. package/lib/report.ts +117 -25
  177. package/lib/requirements.ts +1 -2
  178. package/lib/tool-result.ts +54 -4
  179. package/lib/tui/README.md +30 -8
  180. package/lib/tui/chrome.ts +27 -6
  181. package/lib/tui/dock-screen.ts +91 -19
  182. package/lib/tui/gauge.ts +9 -5
  183. package/lib/tui/line-pane.ts +15 -4
  184. package/lib/tui/tool.ts +23 -4
  185. package/lib/usage.ts +26 -0
  186. package/lib/web/README.md +131 -0
  187. package/lib/web/base.ts +19 -2
  188. package/lib/web/components.ts +10 -6
  189. package/lib/web/layout.ts +2 -0
  190. package/lib/web/markup.ts +2 -1
  191. package/lib/web/runtime.ts +103 -25
  192. package/lib/web/tokens.ts +23 -7
  193. package/package.json +5 -2
  194. package/skills/changelog/SKILL.md +57 -45
  195. package/skills/init/SKILL.md +80 -45
  196. package/skills/init/references/agents-md-quality.md +26 -2
  197. package/skills/init/references/interview-questions.md +36 -14
  198. package/skills/next/SKILL.md +209 -0
  199. package/skills/parallel-dev/SKILL.md +150 -0
  200. package/extensions/wechat/qrcode.ts +0 -46
@@ -1,16 +1,30 @@
1
1
  /**
2
- * The request-envelope helpers both loopback routers apply before a route runs:
3
- * the request target, and the `Host` allowlist.
2
+ * The request-envelope helpers both servers apply before a route runs: the
3
+ * request target, and the `Host` allowlist.
4
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.
5
+ * A server answers to the names it was bound on and nothing else, so a `Host`
6
+ * naming anything else is a DNS-rebinding attempt against a page the owner
7
+ * opened. How strictly a missing `Host` is treated is the caller's decision: the
8
+ * browse server accepts one, because its router is also driven directly from
9
+ * tests and a plain `GET` with no header is not a rebinding, while the review
10
+ * server fails closed.
10
11
  */
11
12
 
12
- /** The `Host` names a loopback interface, with or without a port. */
13
- const LOCAL_HOST = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i;
13
+ /** The names a loopback-bound server answers to. */
14
+ export const LOOPBACK_HOSTS: readonly string[] = ["127.0.0.1", "localhost", "[::1]"];
15
+
16
+ /** The host name in a `Host` header, without its port, lowercased. */
17
+ export function hostHeaderName(host: string): string {
18
+ const text = host.trim().toLowerCase();
19
+ if (text.startsWith("[")) {
20
+ const end = text.indexOf("]");
21
+ return end > 0 ? text.slice(0, end + 1) : text;
22
+ }
23
+ const first = text.indexOf(":");
24
+ // No colon, or an unbracketed IPv6 address whose colons are not a port.
25
+ if (first <= 0 || first !== text.lastIndexOf(":")) return text;
26
+ return /^\d+$/.test(text.slice(first + 1)) ? text.slice(0, first) : text;
27
+ }
14
28
 
15
29
  /** A request target split into the parts a router matches on. */
16
30
  export interface ParsedRequestUrl {
@@ -35,18 +49,31 @@ export function parseRequestUrl(rawUrl: string): ParsedRequestUrl | undefined {
35
49
  }
36
50
 
37
51
  /**
38
- * Whether the request's `Host` names this loopback server.
52
+ * Whether the request's `Host` names one of the hosts this server answers to.
39
53
  *
40
54
  * With `required: false`, only a present, non-empty foreign name is refused, so
41
55
  * a header-less request from another caller still routes. With `required: true`,
42
56
  * a missing or non-string `Host` is refused too, so the guard cannot be skipped
43
57
  * by omitting the header.
44
58
  */
45
- export function isLocalHostHeader(
59
+ export function isAllowedHostHeader(
46
60
  headers: Record<string, string | string[] | undefined>,
47
- options: { required: boolean },
61
+ options: { hosts: readonly string[]; required: boolean },
48
62
  ): boolean {
49
63
  const host = headers.host;
50
64
  if (typeof host !== "string" || host === "") return !options.required;
51
- return LOCAL_HOST.test(host);
65
+ const name = hostHeaderName(host);
66
+ return options.hosts.some((allowed) => hostHeaderName(allowed) === name);
67
+ }
68
+
69
+ /**
70
+ * Whether the request's `Host` names this loopback server. A caller that binds
71
+ * anything other than loopback passes its own names to
72
+ * {@link isAllowedHostHeader} instead.
73
+ */
74
+ export function isLocalHostHeader(
75
+ headers: Record<string, string | string[] | undefined>,
76
+ options: { required: boolean },
77
+ ): boolean {
78
+ return isAllowedHostHeader(headers, { hosts: LOOPBACK_HOSTS, required: options.required });
52
79
  }
@@ -1,14 +1,15 @@
1
1
  /**
2
- * The socket seam shared by the two loopback servers: bind, write a response
3
- * out, and close.
2
+ * The socket seam shared by the browser servers: bind, write a response out,
3
+ * and close.
4
4
  *
5
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, a `stream` response opened as an event
9
- * stream, and a close that is safe to call twice and drops keep-alive
10
- * connections. What each server does *around* this — which frames an event
11
- * stream carries, request bodies, session settling — stays with that server.
6
+ * an address the caller names (loopback unless it asked for more), an
7
+ * accept-level `error` listener so a transient failure cannot take the host
8
+ * down, a `HEAD` that sends headers and no body, a `filePath` response streamed
9
+ * instead of buffered, a `stream` response opened as an event stream, and a
10
+ * close that is safe to call twice and drops keep-alive connections. What each
11
+ * server does *around* this — which frames an event stream carries, request
12
+ * bodies, session settling — stays with that server.
12
13
  */
13
14
 
14
15
  import { createReadStream } from "node:fs";
@@ -16,15 +17,19 @@ import type { IncomingMessage, Server, ServerResponse } from "node:http";
16
17
  import type { ServeResponse } from "./response.ts";
17
18
 
18
19
  /**
19
- * Bind `server` to `127.0.0.1:port` and resolve the port it actually bound
20
- * (`0` asks the OS to pick one). Rejects when the bind fails, so a caller that
21
- * wants a fallback can retry with another port.
20
+ * Bind `server` to `host:port` and resolve the port it actually bound (`0` asks
21
+ * the OS to pick one). Rejects when the bind fails, so a caller that wants a
22
+ * fallback can retry with another port.
23
+ *
24
+ * `host` is the address the caller is about to print, never a wildcard by
25
+ * default: a review shared on a LAN opens exactly one address, so the link the
26
+ * owner sees is the whole surface.
22
27
  */
23
- export async function bindLoopback(server: Server, port: number): Promise<number> {
28
+ export async function bindHost(server: Server, host: string, port: number): Promise<number> {
24
29
  await new Promise<void>((resolve, reject) => {
25
30
  const onError = (error: Error) => reject(error);
26
31
  server.once("error", onError);
27
- server.listen(port, "127.0.0.1", () => {
32
+ server.listen(port, host, () => {
28
33
  server.off("error", onError);
29
34
  resolve();
30
35
  });
@@ -37,6 +42,15 @@ export async function bindLoopback(server: Server, port: number): Promise<number
37
42
  return typeof address === "object" && address ? address.port : port;
38
43
  }
39
44
 
45
+ /**
46
+ * Bind `server` to `127.0.0.1:port` and resolve the port it actually bound
47
+ * (`0` asks the OS to pick one). Rejects when the bind fails, so a caller that
48
+ * wants a fallback can retry with another port.
49
+ */
50
+ export async function bindLoopback(server: Server, port: number): Promise<number> {
51
+ return bindHost(server, "127.0.0.1", port);
52
+ }
53
+
40
54
  /**
41
55
  * Write one routed response: headers first, then no body for `HEAD`, the file
42
56
  * for a `filePath` response, or the buffered body.
@@ -20,6 +20,9 @@ import type { ExtensionContext, ExtensionUIContext } from "@earendil-works/pi-co
20
20
  /** The host run modes, derived because the package does not export the union. */
21
21
  export type InteractionMode = ExtensionContext["mode"];
22
22
 
23
+ /** The two languages a remote prompt is rendered in. */
24
+ export type InteractionLanguage = "zh" | "en";
25
+
23
26
  /** The `ctx.ui` property a remote transport marks with the channel that owns its turn. */
24
27
  export const INTERACTION_TURN_KEY = "isRemoteTurn";
25
28
 
@@ -59,6 +62,14 @@ export interface InteractionChannel {
59
62
  post(text: string, signal?: AbortSignal): Promise<void>;
60
63
  /** Send a local file to the human; resolves true when delivered. */
61
64
  postFile(path: string, name: string, signal?: AbortSignal): Promise<boolean>;
65
+ /** The language this channel renders prompts in, when it knows one. */
66
+ language?(): InteractionLanguage;
67
+ /**
68
+ * Widen the bound on the prompts asked inside the returned scope: use it for a
69
+ * dialog the human must read something before answering. Returns the restore,
70
+ * and a transport with no such policy omits the method.
71
+ */
72
+ setPromptTimeout?(ms: number): () => void;
62
73
  }
63
74
 
64
75
  /** The slice of an extension context {@link remoteChannel} reads. */
@@ -85,6 +96,11 @@ export function isRemoteTurn(ctx: UIContextCarrier): boolean {
85
96
  return remoteChannel(ctx) !== undefined;
86
97
  }
87
98
 
99
+ /** The remote channel's prompt language, or undefined for a local turn. */
100
+ export function remoteLanguage(ctx: UIContextCarrier): InteractionLanguage | undefined {
101
+ return remoteChannel(ctx)?.language?.();
102
+ }
103
+
88
104
  /** Whether a full-screen `ctx.ui.custom` component may be used for this turn. */
89
105
  export function supportsCustomUI(ctx: UIContextCarrier): boolean {
90
106
  return ctx.mode === "tui" && !isRemoteTurn(ctx);
@@ -105,6 +121,12 @@ export interface AskHumanOptions<T> {
105
121
  * attachment may never be opened (a phone client can hide a file bubble).
106
122
  */
107
123
  note?: string;
124
+ /**
125
+ * How long a remote channel may wait for the answer before dismissing the
126
+ * dialog. Raise it when the human has to read an artifact first; the channel's
127
+ * own bound applies when it is omitted or unsupported.
128
+ */
129
+ timeoutMs?: number;
108
130
  }
109
131
 
110
132
  /**
@@ -138,5 +160,10 @@ export async function askHuman<T>(
138
160
  // The preface is best-effort; the dialogs still run.
139
161
  }
140
162
  }
141
- return options.dialogs();
163
+ const restore = options.timeoutMs === undefined ? undefined : channel?.setPromptTimeout?.(options.timeoutMs);
164
+ try {
165
+ return await options.dialogs();
166
+ } finally {
167
+ restore?.();
168
+ }
142
169
  }
package/lib/notice.ts CHANGED
@@ -11,8 +11,10 @@
11
11
  *
12
12
  * `content` is what the model reads; `details` is never sent to the model and
13
13
  * is the renderer's whole input, so a producer states the facts once and the
14
- * renderer decides the look. Value-only: constants and pure functions, no
15
- * state.
14
+ * renderer decides the look. `burstText` is the one assembly of that content for
15
+ * a burst of pending outcomes, so a job completion, a watch match, and a bus
16
+ * message are laid out by the same code. Value-only: constants and pure
17
+ * functions, no state.
16
18
  */
17
19
 
18
20
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
@@ -68,6 +70,31 @@ export function noticeMessage(input: NoticeInput): NoticeMessage {
68
70
  return { customType: NOTICE_TYPE, content: input.content, display: true, details: input.details };
69
71
  }
70
72
 
73
+ /** The parts of one burst of pending outcomes, as the model reads them. */
74
+ export interface BurstTextInput {
75
+ /** The marker embedded in the injected context, e.g. `[BACKGROUND JOBS]`. */
76
+ marker: string;
77
+ /** The counted headline without its colon, e.g. `2 background jobs finished`. */
78
+ headline: string;
79
+ /** One line per outcome, already formatted by the caller. */
80
+ lines: readonly string[];
81
+ /** A trailing count of the rows the caller left out, e.g. `… 3 more matches`. */
82
+ more?: string;
83
+ /** How to read the rest, e.g. 'Run `job logs <id>` for output.'. */
84
+ hint?: string;
85
+ }
86
+
87
+ /**
88
+ * The model-facing text of a burst: marker, counted headline, one line per
89
+ * outcome, and the caller's own tail. The joins and the colon live here so a
90
+ * `job` completion, a `watch` match, and a bus message read alike; the wording,
91
+ * the cap, and the hint stay with the extension that knows them.
92
+ */
93
+ export function burstText(input: BurstTextInput): string {
94
+ const { marker, headline, lines, more, hint } = input;
95
+ return [marker, `${headline}:`, ...lines, ...(more ? [more] : []), ...(hint ? [hint] : [])].join("\n");
96
+ }
97
+
71
98
  /** Whether a conversation message is a notice. */
72
99
  export function isNotice(message: AgentMessage): message is NoticeEntry {
73
100
  return message.role === "custom" && (message as { customType?: string }).customType === NOTICE_TYPE;
package/lib/plan-entry.ts CHANGED
@@ -25,8 +25,8 @@ export const PLAN_ENTER_REQUEST = "my-pi-agent/plan-mode/enter";
25
25
  /** One clause describing the read-only guarantee, reused across prompts. */
26
26
  export const READ_ONLY_SUMMARY =
27
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)";
28
+ "subagent delegation is forced read-only, and the only file write is write_plan (a plan file in the agent " +
29
+ "directory)";
30
30
 
31
31
  /**
32
32
  * The consent question, as the user sees it. Shown by `enter_plan_mode` and by
@@ -36,6 +36,11 @@ export const PLAN_CONSENT =
36
36
  `Plan mode is read-only exploration: ${READ_ONLY_SUMMARY}. ` +
37
37
  "Enter plan mode to investigate and propose an approach before making changes?";
38
38
 
39
+ /** The short remote consent; the full read-only summary stays in the tool docs. */
40
+ export const PLAN_CONSENT_SHORT =
41
+ "Enter read-only plan mode? Writes and mutating tools are disabled until you approve a plan.";
42
+ export const PLAN_CONSENT_SHORT_ZH = "进入只读 plan 模式?批准计划前会禁用写入和修改。";
43
+
39
44
  /** What a caller sends on {@link PLAN_ENTER_REQUEST}. */
40
45
  export interface PlanEnterRequest {
41
46
  /** The caller's context; `plan` uses it for the footer status. */
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Plan-document sections (pure).
3
+ *
4
+ * One fence-aware scan of a plan's markdown, and one rewrite of a section in
5
+ * place. Two features need it: plan mode, whose review screen navigates by
6
+ * section and whose `set_plan_steps` rewrites `## Steps`, and the split, which
7
+ * writes and replaces `## Team` at approval. A second scanner would disagree
8
+ * about a heading inside a fence, so both go through this module.
9
+ *
10
+ * The scan is deliberately smaller than CommonMark: a heading is up to three
11
+ * leading spaces, a space after the hashes, and an optional closing run; a fence
12
+ * is three backticks or tildes anywhere a fence may start. Inside a fence
13
+ * nothing is a heading, which is what keeps a `# comment` in a shell example out
14
+ * of the outline. Every section keeps its raw source lines, so a caller can edit
15
+ * exactly what it read.
16
+ */
17
+
18
+ /** A heading-delimited chunk of a plan, for navigation and in-place edits. */
19
+ export interface PlanSection {
20
+ /** Plain heading text, or `""` for the content before the first heading. */
21
+ title: string;
22
+ /** The section's markdown, including its heading line. */
23
+ text: string;
24
+ /** 0-based source line of the section's heading (0 for the preamble). */
25
+ line: number;
26
+ /** Heading level, or 0 for the preamble before the first heading. */
27
+ level: number;
28
+ }
29
+
30
+ /**
31
+ * A markdown heading outside a fence, capturing its level and text: up to three
32
+ * leading spaces, a space after the hashes, and a closing hash run only when a
33
+ * space separates it. Shared with the block model, so the section split and the
34
+ * browser outline read a heading line the same way, and `## C#` keeps its title.
35
+ */
36
+ export const HEADING = /^ {0,3}(#{1,6})\s+(.*?)(?:\s+#+)?\s*$/;
37
+
38
+ /**
39
+ * A markdown code-fence opener or closer (backticks or tildes). Shared with the
40
+ * block model: a tab-indented fence is prose for both parsers, never code for one
41
+ * and prose for the other.
42
+ */
43
+ export const FENCE = /^ {0,3}(`{3,}|~{3,})/;
44
+
45
+ /**
46
+ * A heading or step label as the review surfaces compare it: inline emphasis and
47
+ * code removed, whitespace collapsed. Exported so a renderer labels an outline
48
+ * entry the same way {@link extractSections} labels its section, which is what
49
+ * lets the browser mark a changed section.
50
+ */
51
+ export function normalizeHeadingText(text: string): string {
52
+ return text
53
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
54
+ .replace(/\*([^*]+)\*/g, "$1")
55
+ .replace(/(?<![A-Za-z0-9_])_([^_]+)_(?![A-Za-z0-9_])/g, "$1")
56
+ .replace(/`([^`]+)`/g, "$1")
57
+ .replace(/\s+/g, " ")
58
+ .trim();
59
+ }
60
+
61
+ /**
62
+ * Sections of a plan, split at heading lines outside code fences. The content
63
+ * before the first heading is a section titled `""`. Pi's `Markdown` renderer
64
+ * exposes no source-line map, so the review screen renders these chunks
65
+ * separately to know where each section begins.
66
+ */
67
+ export function extractSections(plan: string): PlanSection[] {
68
+ const sections: PlanSection[] = [];
69
+ let current: PlanSection | undefined;
70
+ let fence: string | undefined;
71
+
72
+ const flush = (): void => {
73
+ if (current && (current.title !== "" || current.text.trim() !== "")) sections.push(current);
74
+ current = undefined;
75
+ };
76
+
77
+ plan.split("\n").forEach((line, index) => {
78
+ const fenceMatch = line.match(FENCE);
79
+ if (fenceMatch) {
80
+ if (fence === undefined) fence = fenceMatch[1][0];
81
+ else if (fence === fenceMatch[1][0]) fence = undefined;
82
+ } else if (fence === undefined) {
83
+ const heading = line.match(HEADING);
84
+ if (heading) {
85
+ flush();
86
+ current = { title: normalizeHeadingText(heading[2]), text: line, line: index, level: heading[1].length };
87
+ return;
88
+ }
89
+ }
90
+ if (current === undefined) current = { title: "", text: line, line: 0, level: 0 };
91
+ else current.text += `\n${line}`;
92
+ });
93
+
94
+ flush();
95
+ return sections;
96
+ }
97
+
98
+ /**
99
+ * The index one past a section's last line: the next heading at its level or
100
+ * shallower, outside a fence, or the end of the document.
101
+ */
102
+ function sectionEnd(lines: readonly string[], start: number): number {
103
+ const level = lines[start].match(HEADING)?.[1].length ?? 2;
104
+ let fence: string | undefined;
105
+ for (let index = start + 1; index < lines.length; index++) {
106
+ const line = lines[index];
107
+ const fenceMatch = line.match(FENCE);
108
+ if (fenceMatch) {
109
+ if (fence === undefined) fence = fenceMatch[1][0];
110
+ else if (fence === fenceMatch[1][0]) fence = undefined;
111
+ continue;
112
+ }
113
+ if (fence !== undefined) continue;
114
+ const heading = line.match(HEADING);
115
+ if (heading && heading[1].length <= level) return index;
116
+ }
117
+ return lines.length;
118
+ }
119
+
120
+ /**
121
+ * Replace a level-2 section's body, or remove the section, or append one.
122
+ *
123
+ * `body` is the section's markdown after its heading, without the heading line —
124
+ * the caller keeps the heading text so a refinement cannot rename the section
125
+ * that other code looks for. An empty `body` removes the whole section, which is
126
+ * how a team stops existing; a missing section with a body appends one, so the
127
+ * write is idempotent rather than order-dependent.
128
+ */
129
+ export function replaceSection(plan: string, title: string, body: string | undefined): string {
130
+ const lines = plan.split("\n");
131
+ const wanted = normalizeHeadingText(title).toLowerCase();
132
+ const sections = extractSections(plan);
133
+ const found = sections.find(
134
+ (candidate) => candidate.level === 2 && normalizeHeadingText(candidate.title).toLowerCase() === wanted,
135
+ );
136
+
137
+ if (!found) {
138
+ if (body === undefined) return plan;
139
+ const prefix = plan.trim() === "" ? "" : `${plan.replace(/\s+$/, "")}\n\n`;
140
+ return `${prefix}## ${title}\n${body.replace(/\s+$/, "")}\n`;
141
+ }
142
+
143
+ const end = sectionEnd(lines, found.line);
144
+ const head = lines.slice(0, found.line);
145
+ const tail = lines.slice(end);
146
+ if (body === undefined) {
147
+ // Drop the blank line that separated the section from what follows, so a
148
+ // removed section leaves one gap rather than two.
149
+ const joined = [...head, ...tail];
150
+ return (
151
+ joined
152
+ .join("\n")
153
+ .replace(/\n{3,}/g, "\n\n")
154
+ .replace(/\s+$/, "") + (plan.endsWith("\n") ? "\n" : "")
155
+ );
156
+ }
157
+ const replacement = [`## ${title}`, "", ...body.replace(/\s+$/, "").split("\n")];
158
+ // Keep one blank line between the rewritten section and whatever followed it.
159
+ const gap = tail.length > 0 && tail[0].trim() !== "" ? [""] : [];
160
+ return [...head, ...replacement, ...gap, ...tail].join("\n");
161
+ }
162
+
163
+ /** The `## Team` section's body, without its heading or surrounding blank lines. */
164
+ export function teamSectionBody(plan: string): string | undefined {
165
+ const lines = plan.split("\n");
166
+ const section = extractSections(plan).find(
167
+ (candidate) => candidate.level === 2 && normalizeHeadingText(candidate.title).toLowerCase() === "team",
168
+ );
169
+ if (!section) return undefined;
170
+ const body = lines.slice(section.line + 1, sectionEnd(lines, section.line)).join("\n");
171
+ return body.replace(/^(?:[ \t]*\n)+/, "").replace(/\s+$/, "");
172
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The switch that turns the split on, and the words the user flips it with.
3
+ *
4
+ * It lives in `lib/` because three places must agree: the settings row that
5
+ * writes it, plan approval that reads it, and the bus that refuses a join while
6
+ * it is off. A second copy of the key would mean a panel row that changes
7
+ * nothing — the failure this module exists to prevent.
8
+ *
9
+ * Off by default: the split changes what approval means and adds work nobody
10
+ * asked for, so it ships closed and an existing user upgrades into the single
11
+ * flow they already know.
12
+ */
13
+
14
+ import { asBoolean, configValueAt, loadConfigSection } from "../config.ts";
15
+
16
+ /** The shared document's section and key that hold the switch. */
17
+ export const SPLIT_SECTION = "plan";
18
+ export const SPLIT_KEY = "split";
19
+ export const SPLIT_DEFAULT = false;
20
+
21
+ /** The settings-panel row, so a refusal can name what the user flips. */
22
+ export const SPLIT_SETTING_ID = "plan.split";
23
+ export const SPLIT_SETTING_LABEL = "Split plans into slices";
24
+
25
+ /** The section's shape and its default, so a loader and its documentation agree. */
26
+ export interface SplitConfig {
27
+ split: boolean;
28
+ }
29
+
30
+ export const DEFAULT_SPLIT_CONFIG: SplitConfig = { split: SPLIT_DEFAULT };
31
+
32
+ /** Read the section over `base`, ignoring a value that is not a boolean. */
33
+ export function normalizeSplitConfig(raw: Record<string, unknown> | undefined, base: SplitConfig): SplitConfig {
34
+ return { split: asBoolean(configValueAt(raw, [SPLIT_KEY]), base.split) };
35
+ }
36
+
37
+ /**
38
+ * Whether this repository (or the user's global config) has the split on.
39
+ *
40
+ * The project file is read at `root` when the caller knows it: a split belongs to
41
+ * the repository, so a session started in a subdirectory must honour what the
42
+ * repository turned on. Without a root the session's cwd is used, which is what a
43
+ * caller outside a repository has.
44
+ */
45
+ export function splitEnabled(cwd: string, root?: string): boolean {
46
+ return loadConfigSection(root ?? cwd, SPLIT_SECTION, DEFAULT_SPLIT_CONFIG, normalizeSplitConfig).split;
47
+ }
48
+
49
+ /** The one line a refusal or a note uses to say the switch is off and where it lives. */
50
+ export const SPLIT_OFF_NOTE =
51
+ `Parallel slices are off (${SPLIT_SETTING_ID}). ` +
52
+ `Enable "${SPLIT_SETTING_LABEL}" in /bytefly settings to split a plan.`;