@gavin-hu/my-pi-agent 0.17.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 (112) hide show
  1. package/README.md +17 -9
  2. package/README.zh-CN.md +16 -9
  3. package/extensions/bytefly/README.md +25 -18
  4. package/extensions/bytefly/notify/types.ts +1 -1
  5. package/extensions/bytefly/panel/settings.ts +25 -18
  6. package/extensions/file-browse/README.md +60 -21
  7. package/extensions/file-browse/chrome.ts +83 -0
  8. package/extensions/file-browse/client.ts +62 -0
  9. package/extensions/file-browse/config.ts +12 -3
  10. package/extensions/file-browse/diff-html.ts +94 -0
  11. package/extensions/file-browse/html.ts +19 -147
  12. package/extensions/file-browse/live-client.ts +47 -1
  13. package/extensions/file-browse/live.ts +35 -14
  14. package/extensions/file-browse/rev.ts +42 -0
  15. package/extensions/file-browse/router.ts +25 -4
  16. package/extensions/file-browse/server.ts +7 -0
  17. package/extensions/file-browse/styles.ts +2 -0
  18. package/extensions/goal/plan-route.ts +8 -2
  19. package/extensions/job/README.md +7 -5
  20. package/extensions/job/index.ts +15 -7
  21. package/extensions/memory/README.md +3 -3
  22. package/extensions/plan/README.md +280 -86
  23. package/extensions/plan/commands.ts +56 -5
  24. package/extensions/plan/feedback.ts +300 -63
  25. package/extensions/plan/index.ts +1 -1
  26. package/extensions/plan/plan-index.ts +1 -1
  27. package/extensions/plan/plan-text.ts +18 -1
  28. package/extensions/plan/plans.ts +165 -57
  29. package/extensions/plan/review-prompt.ts +93 -0
  30. package/extensions/plan/review.ts +78 -98
  31. package/extensions/plan/runtime.ts +1 -1
  32. package/extensions/plan/split.ts +87 -0
  33. package/extensions/plan/steps.ts +80 -93
  34. package/extensions/plan/tools/enter.ts +8 -3
  35. package/extensions/plan/tools/exit.ts +101 -23
  36. package/extensions/plan/tools/write.ts +8 -3
  37. package/extensions/plan/tools.ts +1 -1
  38. package/extensions/plan/tui.ts +51 -8
  39. package/extensions/plan/types.ts +37 -3
  40. package/extensions/plan/web-chrome.ts +2 -1
  41. package/extensions/plan/web-client.ts +121 -177
  42. package/extensions/plan/web-collab.ts +207 -0
  43. package/extensions/plan/web-comments.ts +269 -0
  44. package/extensions/plan/web-page.ts +103 -17
  45. package/extensions/plan/web-router.ts +146 -30
  46. package/extensions/plan/web-share-session.ts +197 -0
  47. package/extensions/plan/web-share.ts +169 -0
  48. package/extensions/plan/web-streams.ts +124 -0
  49. package/extensions/plan/web-tokens.ts +63 -0
  50. package/extensions/plan/web.ts +193 -45
  51. package/extensions/question/README.md +3 -0
  52. package/extensions/question/dialogs.ts +91 -1
  53. package/extensions/question/tools.ts +2 -1
  54. package/extensions/session-bus/README.md +247 -0
  55. package/extensions/session-bus/commands.ts +165 -0
  56. package/extensions/session-bus/format.ts +124 -0
  57. package/extensions/session-bus/index.ts +69 -0
  58. package/extensions/session-bus/notice.ts +58 -0
  59. package/extensions/session-bus/protocol.ts +134 -0
  60. package/extensions/session-bus/result.ts +243 -0
  61. package/extensions/session-bus/rows.ts +169 -0
  62. package/extensions/session-bus/runtime.ts +575 -0
  63. package/extensions/session-bus/store.ts +329 -0
  64. package/extensions/session-bus/team.ts +102 -0
  65. package/extensions/session-bus/tools.ts +227 -0
  66. package/extensions/session-bus/tui.ts +394 -0
  67. package/extensions/session-bus/types.ts +98 -0
  68. package/extensions/subagent/README.md +1 -1
  69. package/extensions/watch/README.md +9 -5
  70. package/extensions/watch/index.ts +15 -9
  71. package/extensions/wechat/README.md +20 -33
  72. package/extensions/wechat/bridge.ts +8 -8
  73. package/extensions/wechat/commands.ts +2 -2
  74. package/extensions/wechat/deliver.ts +57 -37
  75. package/extensions/wechat/interaction.ts +31 -16
  76. package/extensions/wechat/notices.ts +7 -7
  77. package/extensions/wechat/peer-worker.ts +19 -34
  78. package/extensions/wechat/presence.ts +3 -4
  79. package/extensions/wechat/prompt.ts +39 -24
  80. package/extensions/wechat/reply-pipeline.ts +12 -4
  81. package/extensions/worktree/git.ts +5 -0
  82. package/extensions/worktree/registry.ts +11 -5
  83. package/extensions/worktree/status.ts +1 -3
  84. package/lib/README.md +6 -2
  85. package/lib/claims/overlap.ts +43 -0
  86. package/lib/http/router.ts +40 -13
  87. package/lib/http/server.ts +27 -13
  88. package/lib/interaction.ts +10 -0
  89. package/lib/notice.ts +29 -2
  90. package/lib/plan-entry.ts +7 -2
  91. package/lib/plans/sections.ts +172 -0
  92. package/lib/plans/setting.ts +52 -0
  93. package/lib/plans/split.ts +215 -0
  94. package/lib/plans/store.ts +19 -0
  95. package/lib/plans/team.ts +151 -0
  96. package/lib/qr.ts +64 -0
  97. package/lib/repo-key.ts +74 -0
  98. package/lib/report.ts +52 -33
  99. package/lib/requirements.ts +1 -2
  100. package/lib/tui/README.md +16 -2
  101. package/lib/tui/dock-screen.ts +85 -17
  102. package/lib/tui/line-pane.ts +15 -4
  103. package/lib/tui/tool.ts +23 -4
  104. package/lib/web/components.ts +1 -1
  105. package/package.json +3 -2
  106. package/skills/init/SKILL.md +80 -45
  107. package/skills/init/references/agents-md-quality.md +26 -2
  108. package/skills/init/references/interview-questions.md +36 -14
  109. package/skills/next/SKILL.md +78 -53
  110. package/skills/parallel-dev/SKILL.md +150 -0
  111. package/extensions/plan/config.ts +0 -27
  112. package/extensions/wechat/qrcode.ts +0 -46
@@ -2,15 +2,15 @@
2
2
  * Pure rendering and parsing of remote dialog prompts (no I/O).
3
3
  *
4
4
  * A prompt is rendered to a WeChat message by {@link formatPrompt}; the reply is
5
- * interpreted by {@link parsePromptAnswer}. Hints are bilingual (Chinese and
6
- * English) so either reader can answer, and {@link formatPrompt} can prepend a
7
- * short notice when a reply was not understood. The parser is deliberately total:
5
+ * interpreted by {@link parsePromptAnswer}. Hints are rendered in the peer's
6
+ * language, and {@link formatPrompt} can prepend a short notice when a reply was
7
+ * not understood. The parser is deliberately total:
8
8
  * anything it cannot resolve reports `retry`, and free text on a `select` becomes
9
9
  * the {@link INTERACTION_OTHER_LABEL} option so the caller can collect it once.
10
10
  */
11
11
 
12
12
  import { INTERACTION_OTHER_LABEL, type InteractionRequest } from "../../lib/interaction.ts";
13
- import { sanitizeInbound } from "./format.ts";
13
+ import { sanitizeInbound, type PeerLanguage } from "./format.ts";
14
14
 
15
15
  /** A parsed reply: a resolved answer, the "other" choice, or a retry. */
16
16
  export type PromptParse =
@@ -20,20 +20,30 @@ export type PromptParse =
20
20
  | { kind: "cancelled" }
21
21
  | { kind: "retry" };
22
22
 
23
- const SELECT_OTHER_HINT =
24
- "回复编号或选项文字;其它内容作为自定义回答;回复「取消」或 cancel 跳过。\n" +
25
- "Reply with a number or the option text; anything else becomes your own answer; reply cancel to dismiss.";
26
- const SELECT_CHOICE_HINT =
27
- "回复编号或选项文字;回复「取消」或 cancel 跳过。\n" +
28
- "Reply with a number or the option text; reply cancel to dismiss.";
29
- const CONFIRM_HINT = "回复「是」或「否」。\nReply yes or no.";
30
- const INPUT_HINT = "直接回复内容;回复「取消」或 cancel 跳过。\nReply with the text; reply cancel to skip.";
31
- const EDITOR_HINT =
32
- "把内容作为一条消息发回;回复「取消」或 cancel 取消。\nSend the text as one message; reply cancel to cancel.";
23
+ const SELECT_OTHER_HINT: Record<PeerLanguage, string> = {
24
+ zh: "回复编号或选项文字;其它内容作为自定义回答;回复「取消」跳过。",
25
+ en: "Reply with a number or the option text; anything else is your own answer; reply cancel to dismiss.",
26
+ };
27
+ const SELECT_CHOICE_HINT: Record<PeerLanguage, string> = {
28
+ zh: "回复编号或选项文字;回复「取消」跳过。",
29
+ en: "Reply with a number or the option text; reply cancel to dismiss.",
30
+ };
31
+ const CONFIRM_HINT: Record<PeerLanguage, string> = {
32
+ zh: "回复「是」或「否」。",
33
+ en: "Reply yes or no.",
34
+ };
35
+ const INPUT_HINT: Record<PeerLanguage, string> = {
36
+ zh: "直接回复内容;回复「取消」跳过。",
37
+ en: "Reply with the text; reply cancel to skip.",
38
+ };
39
+ const EDITOR_HINT: Record<PeerLanguage, string> = {
40
+ zh: "把内容作为一条消息发回;回复「取消」取消。",
41
+ en: "Send the text as one message; reply cancel to cancel.",
42
+ };
33
43
 
34
44
  /** Sent ahead of a re-prompt when a reply could not be interpreted. */
35
- export const RETRY_NOTICE =
36
- "没有识别到有效回复,请重新回答。\nSorry, I didn't recognize that reply — please answer again.";
45
+ export const RETRY_NOTICE = "Sorry, I didn't recognize that reply — please answer again.";
46
+ const RETRY_ZH = "没有识别到有效回复,请重新回答。";
37
47
 
38
48
  const CANCEL_WORDS = new Set(["取消", "退出", "cancel", "quit", "exit"]);
39
49
  const YES_WORDS = new Set(["是", "y", "yes", "1", "确认", "确定", "行", "ok", "好", "对"]);
@@ -44,24 +54,29 @@ function lines(parts: Array<string | undefined>): string {
44
54
  }
45
55
 
46
56
  /** Render a dialog prompt as a WeChat message body, optionally after a retry. */
47
- export function formatPrompt(request: InteractionRequest, options: { retry?: boolean } = {}): string {
48
- const body = promptBody(request);
49
- return options.retry ? `${RETRY_NOTICE}\n\n${body}` : body;
57
+ export function formatPrompt(
58
+ request: InteractionRequest,
59
+ options: { retry?: boolean; language?: PeerLanguage } = {},
60
+ ): string {
61
+ const language = options.language ?? "en";
62
+ const body = promptBody(request, language);
63
+ const retry = language === "zh" ? RETRY_ZH : RETRY_NOTICE;
64
+ return options.retry ? `${retry}\n\n${body}` : body;
50
65
  }
51
66
 
52
- function promptBody(request: InteractionRequest): string {
67
+ function promptBody(request: InteractionRequest, language: PeerLanguage): string {
53
68
  switch (request.kind) {
54
69
  case "select": {
55
70
  const options = (request.options ?? []).map((label, index) => `${index + 1}. ${label}`);
56
71
  const hasOther = (request.options ?? []).includes(INTERACTION_OTHER_LABEL);
57
- return lines([request.title, ...options, hasOther ? SELECT_OTHER_HINT : SELECT_CHOICE_HINT]);
72
+ return lines([request.title, ...options, hasOther ? SELECT_OTHER_HINT[language] : SELECT_CHOICE_HINT[language]]);
58
73
  }
59
74
  case "confirm":
60
- return lines([request.title, request.message, CONFIRM_HINT]);
75
+ return lines([request.title, request.message, CONFIRM_HINT[language]]);
61
76
  case "input":
62
- return lines([request.title, request.placeholder, INPUT_HINT]);
77
+ return lines([request.title, request.placeholder, INPUT_HINT[language]]);
63
78
  case "editor":
64
- return lines([request.title, request.prefill, EDITOR_HINT]);
79
+ return lines([request.title, request.prefill, EDITOR_HINT[language]]);
65
80
  }
66
81
  }
67
82
 
@@ -27,6 +27,8 @@ export interface ReplyPipelineDeps {
27
27
  export interface ReplyPipeline {
28
28
  /** Add streamed text; parts go out as boundaries are crossed. */
29
29
  push(delta: string): void;
30
+ /** Send the current buffer now, in order; used before a dialog or file. */
31
+ flush(): Promise<void>;
30
32
  /** Send whatever is left and resolve once every part is out. */
31
33
  finish(): Promise<void>;
32
34
  /** Drop the unsent remainder (an aborted turn) and cancel the timer. */
@@ -106,6 +108,13 @@ export function createReplyPipeline(deps: ReplyPipelineDeps): ReplyPipeline {
106
108
  enqueue(part);
107
109
  }
108
110
 
111
+ async function flushBuffer(): Promise<void> {
112
+ cancelQuiet();
113
+ enqueue(buffer);
114
+ buffer = "";
115
+ await chain;
116
+ }
117
+
109
118
  return {
110
119
  push(delta: string): void {
111
120
  if (!delta) return;
@@ -125,11 +134,10 @@ export function createReplyPipeline(deps: ReplyPipelineDeps): ReplyPipeline {
125
134
  scheduleQuiet();
126
135
  },
127
136
 
137
+ flush: flushBuffer,
138
+
128
139
  async finish(): Promise<void> {
129
- cancelQuiet();
130
- enqueue(buffer);
131
- buffer = "";
132
- await chain;
140
+ await flushBuffer();
133
141
  },
134
142
 
135
143
  drop(): void {
@@ -355,6 +355,11 @@ export async function inspectWork(pi: ExtensionAPI, dir: string, base?: string):
355
355
  }
356
356
 
357
357
  export interface ManagedWorktree {
358
+ /**
359
+ * Canonical path, resolved through symlinks by `listManagedWorktrees`. Every
360
+ * caller takes that as given: re-resolving it here would repeat a sync ancestor
361
+ * walk the listing already paid for, once per worktree per status read.
362
+ */
358
363
  path: string;
359
364
  branch?: string;
360
365
  head?: string;
@@ -145,7 +145,9 @@ export function reconcileRegistry(
145
145
  options: { persist?: boolean } = {},
146
146
  ): { registry: WorktreeRegistry; changed: boolean } {
147
147
  const registry = loadRegistry(repoRoot, config);
148
- const live = new Set(managed.map((entry) => realPathOfNearest(entry.path)));
148
+ // Resolve each entry once: the live set and the synthesized records both need it.
149
+ const resolved = managed.map((entry) => ({ entry, path: realPathOfNearest(entry.path) }));
150
+ const live = new Set(resolved.map((row) => row.path));
149
151
  const byPath = new Map(registry.worktrees.map((record) => [record.path, record] as const));
150
152
 
151
153
  let changed = false;
@@ -157,8 +159,7 @@ export function reconcileRegistry(
157
159
  changed = true;
158
160
  }
159
161
  }
160
- for (const entry of managed) {
161
- const path = realPathOfNearest(entry.path);
162
+ for (const { entry, path } of resolved) {
162
163
  if (byPath.has(path)) continue;
163
164
  next.push({
164
165
  path,
@@ -183,13 +184,18 @@ export interface StatusOptions {
183
184
  record?: WorktreeRecord;
184
185
  }
185
186
 
186
- /** Derive one checkout's status from its git listing and registry record. */
187
+ /**
188
+ * Derive one checkout's status from its git listing and registry record.
189
+ *
190
+ * `entry.path` is taken as canonical — the listing and `reconcileRegistry` are the
191
+ * two places a path is resolved — so this does not walk the ancestors again.
192
+ */
187
193
  export async function statusOf(
188
194
  pi: ExtensionAPI,
189
195
  entry: ManagedWorktree,
190
196
  options: StatusOptions = {},
191
197
  ): Promise<CheckoutStatus> {
192
- const path = realPathOfNearest(entry.path);
198
+ const path = entry.path;
193
199
  const locked = entry.locked !== undefined;
194
200
  const staleLock = locked && entry.locked ? isStaleLock(entry.locked) : false;
195
201
  const base: CheckoutStatus = {
@@ -10,7 +10,6 @@
10
10
 
11
11
  import { basename } from "node:path";
12
12
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
- import { realPathOfNearest } from "../../lib/path.ts";
14
13
  import { mapConcurrent } from "../../lib/pool.ts";
15
14
  import { loadConfig } from "./config.ts";
16
15
  import { gapLines, type IsolationGap, isolationGaps } from "./gaps.ts";
@@ -133,8 +132,7 @@ export async function worktreeSnapshot(pi: ExtensionAPI, ctx: ExtensionContext):
133
132
  // lookup instead of a scan of every record.
134
133
  const records = new Map(registry.worktrees.map((record) => [record.path, record]));
135
134
  const row = async (entry: ManagedWorktree): Promise<ManagedRow> => {
136
- const path = realPathOfNearest(entry.path);
137
- return { entry, status: await statusOf(pi, entry, { baseRef, record: records.get(path) }) };
135
+ return { entry, status: await statusOf(pi, entry, { baseRef, record: records.get(entry.path) }) };
138
136
  };
139
137
  // Each row is up to four git reads; four at a time keeps the list in the order
140
138
  // git reports while overlapping the waits. `statusOf` passes no `env`, which is
package/lib/README.md CHANGED
@@ -18,17 +18,19 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
18
18
  | Module | Responsibility |
19
19
  |---|---|
20
20
  | [`browser.ts`](./browser.ts) | Cross-platform default-browser opener shared by `file-browse` and `plan`: pure `browserCommand(url, platform)` plus best-effort `openInBrowser(url, { platform, spawn })` with an injectable spawn. |
21
+ | [`claims/`](./claims/) | Claim overlap, shared by the two things that ask "do these paths collide?" so approval and claiming cannot disagree: [`claims/overlap.ts`](./claims/overlap.ts) holds `normalizeClaimPath` (repository-relative, posix, no trailing slash — a Windows spelling reads as the same path), `claimsConflict` (a claim covers everything below it, in either direction) and `findClaimOverlap` (the first colliding pair). Used by `session-bus` when a session claims, and by plan approval when a split names its units. |
21
22
  | [`config.ts`](./config.ts) | Config loading, two ways: `loadConfigFile` for a whole file, and the shared `bytefly.json` (`SHARED_CONFIG_FILE`) via `configSection` / `configValueAt` / `loadConfigSection` (global, then project), plus the atomic `writeConfigFile` / `writeConfigSection` / `writeConfigValue` / `writeConfigValueAt` / `writeConfigValuesAt` / `unsetConfigValueAt` (the leaf writers preserve siblings and metadata, `writeConfigValuesAt` applies several leaves in one document pass, and the last removes one and prunes what it emptied). Includes `clampInteger` / `clampNumber` / `cleanString` / `optionalString` / `asBoolean` / `stringList` / `asRecord` / `asNumber` (the untrusted-JSON narrowing pair) / `readJson` / `readJsonResult`. |
22
23
  | [`env.ts`](./env.ts) | Env-var contracts shared across extensions: the worktree environment (`ENV_ROOT`, `worktreeRoot`, `resolveEffectiveCwd`) used by worktree, file-browse, job, and rewind, plus `isExtensionEnabled` / `ENV_DISABLED_EXTENSIONS` for the package-wide disable list. |
23
24
  | [`escape.ts`](./escape.ts) | `escapeHtml`: the single HTML text/attribute escape used by the browse page renderers and the Markdown and syntax highlighters, plus `decodeCodePoint`, the other half of the same boundary — one numeric character reference as text, with a surrogate or out-of-range value decoded to U+FFFD. |
24
25
  | [`events.ts`](./events.ts) | Inter-extension `pi.events` channels and payloads: `JOB_FINISHED_CHANNEL` / `JobFinishedEvent` (published by `job`, consumed by bytefly's notify capability), `SUBAGENT_FINISHED_CHANNEL` / `SubagentFinishedEvent` (published by `subagent`, consumed by bytefly's footer roll-up), and `WEB_SEARCH_PROVIDER_CHANNEL` / `WebSearchProviderEvent` (published by `web-access` at session start, consumed by bytefly's doctor so it can report the resolved provider without importing the extension — the key itself is never in the payload). |
25
- | [`notice.ts`](./notice.ts) | The shared transcript notice: `NOTICE_TYPE`, the `NoticeDetails` / `NoticeItem` shape, `noticeMessage` (the `display: true` message both send paths accept), `isNotice`, and `pruneStaleNotices` — posted by `job` and `watch`, rendered by bytefly. |
26
- | [`report.ts`](./report.ts) | The pending-report channel: `createPendingReporter` with `wake` / `pending` / `markReported` / `buildNotice` / `source` / `onWoke`, returning the handlers that report at the next turn, raise at most one triggered turn per wake window, and prune only their own notices; and `registerPendingChannel`, which binds those handlers to `session_start` / `session_tree` / `agent_start` / `agent_settled` / `before_agent_start` / `context` / `session_shutdown` around a `load` / `setStatus` / `shutdown` runtime, so the order a session starts and shuts down is written once. `job` and `watch` supply only their own arming in `onSessionStart` / `onSessionShutdown`. |
26
+ | [`notice.ts`](./notice.ts) | The shared transcript notice: `NOTICE_TYPE`, the `NoticeDetails` / `NoticeItem` shape, `noticeMessage` (the `display: true` message both send paths accept), `isNotice`, and `pruneStaleNotices` — posted by `job`, `watch`, and `session-bus`, rendered by bytefly — plus `burstText`, the one assembly of the model-facing text for a burst of pending outcomes (marker, counted headline, rows, an optional `… N more`, an optional hint). |
27
+ | [`report.ts`](./report.ts) | The pending-report channel: `createPendingReporter` with `wake` / `pending` / `markReported` / `buildNotice` / `wakeRequested` / `source` / `onWoke`, returning the handlers that report at the next turn, raise at most one triggered turn per wake window, retry a wake the running turn suppressed once `agent_settled` reports an unaborted settle, and prune only their own notices; and `registerPendingChannel`, which binds those handlers to `session_start` / `session_tree` / `agent_start` / `agent_settled` / `before_agent_start` / `context` / `session_shutdown` around a `load` / `setStatus?` / `shutdown` runtime, so the order a session starts and shuts down is written once. A `load` that resolves a repository may return a promise, which is awaited before the trigger is armed; a runtime that publishes no chip omits `setStatus`. `job`, `watch`, and `session-bus` supply only their own arming in `onSessionStart` / `onSessionShutdown` and their own `wakeRequested`. |
27
28
  | [`errors.ts`](./errors.ts) | `messageOf`: the one boundary that turns an unknown thrown value into a user-facing line (an `Error` yields its message, anything else is stringified). |
28
29
  | [`format.ts`](./format.ts) | Pure text/number formatting shared by renderers: `formatTokens`, `formatCount`, `formatDuration` (`450ms`, `3.2s`, `1m05s`, `2h03m`), `boundedTail` (the end of a joined tail with a `truncated` flag), `stripControlChars`, `sanitize`, `sanitizeLine` — the boundary for untrusted, single-line text — `lineOf` (a value read as one sanitized line), `sanitizeUntrustedLine` / `sanitizeUntrustedText` with `MAX_OUTPUT_LINE` for **program output** (OSC/CSI escapes removed whole, `\r` progress resolved, clipped), and `shortenPath`. |
29
30
  | [`timer.ts`](./timer.ts) | The three timer seams and their production defaults, each cancellable and each built so a pending timer never holds the process open: `ArmTimer` / `defaultTimer` (one shot), `RepeatTimer` / `defaultInterval` (repeating), and `abortableSleep` (resolve after a delay, reject `Error("aborted")` when a signal wins). Shared by `wechat`'s bridge and prompt channel, plan's review page, `job`'s and `watch`'s repaint clocks, `bytefly`'s working tick, the browse live channel, and the search throttle — so a test drives a deadline instead of waiting on one. |
30
31
  | [`lines.ts`](./lines.ts) | `LineSplitter`, the byte-safe splitter for a stream of program output: buffers bytes and decodes only at a newline, so a UTF-8 character split across two reads survives, and caps an over-long line instead of dropping it. Shared by `job`, `watch`, `check`, and `subagent`. |
31
32
  | [`pool.ts`](./pool.ts) | `mapConcurrent`: one async read per entry with at most `limit` calls in flight and results in input order, so a directory listing or a status sweep fans out without an unbounded burst or a per-entry round trip. Shared by `file-browse` and `worktree`. |
33
+ | [`qr.ts`](./qr.ts) | The QR seam shared by `wechat`'s login and `plan`'s shared review link: `qrMatrix` (the module matrix), `qrLines` (Unicode half-block terminal lines of a fixed width), and `qrSvg` (inline markup a page can paste); the `qrcode` package is imported on first use, never at registration. |
32
34
  | [`interaction.ts`](./interaction.ts) | Turn-affinity prompt contract: the channel a remote transport marks on the UI context it owns (`INTERACTION_TURN_KEY`), the `InteractionChannel` interface (`request` / `post` / `postFile`, plus an optional `setPromptTimeout` scope for a dialog the human must read something before answering), and `remoteChannel` / `isRemoteTurn` / `supportsCustomUI` / `askHuman` (with an optional file, fallback text, digest preface, and prompt `timeoutMs`) plus the shared “Other” label. |
33
35
  | [`git/`](./git/) | Shared git plumbing. [`git/runner.ts`](./git/runner.ts) is the `RunGit` seam (`createExecRunner` with timeout/env handling, `runGitOrThrow`, `GitError`); [`git/read.ts`](./git/read.ts) holds the read helpers (`repoRoot`, `repoRootFor`, `gitDir`, `gitCommonDir`, `revParse`, `revParseMany` for several revisions in one call, `hasCommits`, `currentBranch`); [`git/index.ts`](./git/index.ts) is the barrel. |
34
36
  | [`highlight.ts`](./highlight.ts) | First-party, escape-first syntax tokenizer (`detectLanguage`, `fenceLanguage`, `highlightLines`, `HighlightLanguage`, `TokenClass`) for js/ts, json, css, html, shell, python, java, yaml, and sql; shared by `file-browse` and plan's code blocks. |
@@ -37,10 +39,12 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
37
39
  | [`path.ts`](./path.ts) | `isInside` (string containment) plus `realPathOfNearest` / `isInsideReal` (symlink-aware): the containment predicates the file-browse, worktree, doc, and plan guards share, and the canonical-path helper the worktree registry keys records by. |
38
40
  | [`pdf.ts`](./pdf.ts) | The optional `unpdf` seam shared by `web-access` and `file-read`: `pdfGuidance`, `PdfUnavailableError`, `pdfTextFromResult`, and the lazy `loadPdfExtractor` / `extractPdfText` (an injected extractor wins), so a missing package and a merged-pages result read the same way through either surface. |
39
41
  | [`pi-invocation.ts`](./pi-invocation.ts) | `getPiInvocation`: how to launch a nested `pi` process, shared by `subagent` and bytefly's doctor surface. |
42
+ | [`plans/`](./plans/) | The plan document's shared shape, for plan mode and for whatever reads a plan without importing it: [`plans/sections.ts`](./plans/sections.ts) is the one fence-aware scan (`extractSections`, `replaceSection`, `teamSectionBody`, and the `HEADING` / `FENCE` / `normalizeHeadingText` vocabulary), [`plans/team.ts`](./plans/team.ts) parses and renders the `## Team` section (`PlanUnit`, `PlanTeam`, `renderTeam`, `parseTeam`, `readTeam`, `writeTeam`), [`plans/split.ts`](./plans/split.ts) decides whether a proposed split is one (`checkSplit`, its refusals by name, `SHARED_FILES`, `MAX_OWNERS`, `mergeOrder`), and [`plans/store.ts`](./plans/store.ts) holds the one directory rule (`plansDir`) the store writes to and the bus reads from. The switch lives apart in [`plans/setting.ts`](./plans/setting.ts): `SPLIT_SECTION` / `SPLIT_KEY` / `SPLIT_DEFAULT`, the `SPLIT_SETTING_ID` / `SPLIT_SETTING_LABEL` the settings row shows, `splitEnabled(cwd)` and `SPLIT_OFF_NOTE`, so the panel row, approval, and the bus read one key. |
40
43
  | [`policy.ts`](./policy.ts) | `createReadOnlyPolicy`: default-deny tool classification from `readOnlyHint` + explicit allow/deny. |
41
44
  | [`plan-state.ts`](./plan-state.ts) | The plan-mode flag replayed from the session branch: `PLAN_MODE_ENTRY_TYPE` and `isPlanModeEnabled(entries)`, so `rewind` can refuse a write while planning without importing the plan extension. |
42
45
  | [`plan-entry.ts`](./plan-entry.ts) | Plan-mode entry shared by the extension that owns the mode and the one that offers it: `READ_ONLY_SUMMARY` and `PLAN_CONSENT` (one wording for the read-only guarantee), the `PLAN_ENTER_REQUEST` channel with its `PlanEnterRequest` payload, and `requestPlanMode(pi, ctx)` — a request/response over `pi.events` that resolves `false` instead of hanging when `plan` is not loaded, so a command context (which has no `executeTool`) can still enter the mode after asking the user once. |
43
46
  | [`rails.ts`](./rails.ts) | Above-editor widget ordering (`goal` → `todo`) and dock-screen suppression, carried on `pi.events`. |
47
+ | [`repo-key.ts`](./repo-key.ts) | The per-repository storage key that outlives one checkout: `repoKeyOf(value)` hashes an identity to `REPO_KEY_LENGTH` hex characters, and `repoKeyFor(pi, cwd)` returns `{ root, key, fromGit }` from the repository's *common* git directory, so every worktree of one repository files state under the same key, while a directory outside git keys on itself. Shared by `session-bus` (sessions, mail, claims) and `plan` (plans). |
44
48
  | [`screen-session.ts`](./screen-session.ts) | `withPolledScreen`: mount one `ctx.ui.custom` dock screen — rails suppressed for its lifetime, an optional repaint tick through an injected `RepeatTimer`, and one teardown — handing the mount the host's `tui`, `theme`, `requestRender`, `close`, and `viewportRows`. Shared by `/jobs`, `/watch`, and `/memory`, whose rails window and timer window were the same one. |
45
49
  | [`requirements.ts`](./requirements.ts) | Per-extension runtime prerequisite table plus a pure `probeExtension(seams)` over injected seams (exec, config reads, optional imports, the published search provider, env, and a refresh-only reachability probe): bytefly's doctor surface reports on it and the extensions that own a dependency format their errors from it. A check kind is either a prerequisite (`binary`, `module`, `state`, `invocation`, `notify`, `provider`) or a `safety` switch whose off value trades a protection away, read from `bytefly.json` global-then-project. |
46
50
  | [`shell.ts`](./shell.ts) | Shell escape rule shared by the worktree isolation guard and plan mode's read-only git guard: `SHELL_ESCAPABLE`, `isShellEscapable`, `hasShellEscape`. |
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Claim overlap (pure).
3
+ *
4
+ * Two things in this package need the same answer to "do these paths collide?":
5
+ * `session-bus` when a session claims a path, and plan approval when a proposed
6
+ * split names its units. They must agree, because a split that passes validation
7
+ * and then conflicts at claim time — or the reverse — teaches the user that a
8
+ * claim is arbitrary. So the predicate lives here once and both call it.
9
+ *
10
+ * A claim on a directory covers every file below it, in either direction, and a
11
+ * claim is always a repository-relative, posix-separated path: the caller
12
+ * normalizes first, because a Windows spelling (`a\b`) and a posix one (`a/b`)
13
+ * naming the same file must not read as two claims.
14
+ */
15
+
16
+ /** A repository-relative path with one separator and no trailing slash. */
17
+ export function normalizeClaimPath(raw: string): string {
18
+ const posix = raw.trim().replace(/\\/g, "/");
19
+ const trimmed = posix
20
+ .replace(/^\.\//, "")
21
+ .replace(/\/{2,}/g, "/")
22
+ .replace(/\/+$/, "");
23
+ return trimmed === "." ? "" : trimmed;
24
+ }
25
+
26
+ /** Whether one claim covers the other path in either direction. */
27
+ export function claimsConflict(a: string, b: string): boolean {
28
+ const left = normalizeClaimPath(a);
29
+ const right = normalizeClaimPath(b);
30
+ // The root claim (an empty path, or ".") covers everything below it.
31
+ if (left === "" || right === "") return true;
32
+ return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
33
+ }
34
+
35
+ /** The first two prefixes in `paths` that collide, if any. */
36
+ export function findClaimOverlap(paths: readonly string[]): [string, string] | undefined {
37
+ for (let i = 0; i < paths.length; i++) {
38
+ for (let j = i + 1; j < paths.length; j++) {
39
+ if (claimsConflict(paths[i], paths[j])) return [paths[i], paths[j]];
40
+ }
41
+ }
42
+ return undefined;
43
+ }
@@ -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,8 @@ 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;
62
67
  /**
63
68
  * Widen the bound on the prompts asked inside the returned scope: use it for a
64
69
  * dialog the human must read something before answering. Returns the restore,
@@ -91,6 +96,11 @@ export function isRemoteTurn(ctx: UIContextCarrier): boolean {
91
96
  return remoteChannel(ctx) !== undefined;
92
97
  }
93
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
+
94
104
  /** Whether a full-screen `ctx.ui.custom` component may be used for this turn. */
95
105
  export function supportsCustomUI(ctx: UIContextCarrier): boolean {
96
106
  return ctx.mode === "tui" && !isRemoteTurn(ctx);
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. */