@gavin-hu/my-pi-agent 0.12.0 → 0.13.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 (153) hide show
  1. package/README.md +28 -21
  2. package/README.zh-CN.md +30 -22
  3. package/extensions/bytefly/README.md +139 -67
  4. package/extensions/bytefly/chrome.ts +7 -2
  5. package/extensions/bytefly/doctor/register.ts +6 -0
  6. package/extensions/bytefly/doctor/runtime.ts +36 -6
  7. package/extensions/bytefly/footer/collect/snapshot.ts +12 -2
  8. package/extensions/bytefly/footer/config.ts +7 -1
  9. package/extensions/bytefly/footer/delegation.ts +100 -0
  10. package/extensions/bytefly/footer/register.ts +13 -2
  11. package/extensions/bytefly/footer/render/lines.ts +2 -0
  12. package/extensions/bytefly/footer/render/segments.ts +27 -0
  13. package/extensions/bytefly/footer/types.ts +11 -0
  14. package/extensions/bytefly/index.ts +4 -3
  15. package/extensions/bytefly/notify/config.ts +2 -0
  16. package/extensions/bytefly/notify/format.ts +19 -1
  17. package/extensions/bytefly/notify/register.ts +37 -13
  18. package/extensions/bytefly/notify/types.ts +2 -0
  19. package/extensions/bytefly/panel/commands.ts +34 -13
  20. package/extensions/bytefly/panel/settings.ts +98 -16
  21. package/extensions/bytefly/update/cache.ts +73 -0
  22. package/extensions/bytefly/update/format.ts +65 -10
  23. package/extensions/bytefly/update/register.ts +99 -13
  24. package/extensions/bytefly/update/state.ts +13 -1
  25. package/extensions/document/README.md +22 -12
  26. package/extensions/document/config.ts +4 -0
  27. package/extensions/document/deadline.ts +61 -0
  28. package/extensions/document/tool.ts +13 -2
  29. package/extensions/file-browse/README.md +12 -8
  30. package/extensions/file-browse/client.ts +6 -7
  31. package/extensions/file-browse/files.ts +0 -4
  32. package/extensions/file-browse/git.ts +9 -10
  33. package/extensions/file-browse/html.ts +17 -7
  34. package/extensions/file-browse/router.ts +24 -15
  35. package/extensions/goal/README.md +7 -1
  36. package/extensions/goal/schema.ts +6 -2
  37. package/extensions/goal/types.ts +6 -2
  38. package/extensions/job/README.md +16 -12
  39. package/extensions/job/runtime.ts +23 -6
  40. package/extensions/job/status.ts +4 -2
  41. package/extensions/memory/README.md +55 -13
  42. package/extensions/memory/commands.ts +20 -2
  43. package/extensions/memory/doctor.ts +137 -0
  44. package/extensions/memory/format.ts +24 -7
  45. package/extensions/memory/runtime.ts +49 -0
  46. package/extensions/memory/store.ts +31 -0
  47. package/extensions/memory/tui.ts +136 -12
  48. package/extensions/plan/README.md +60 -29
  49. package/extensions/plan/commands.ts +67 -8
  50. package/extensions/plan/index.ts +35 -11
  51. package/extensions/plan/plan-index.ts +234 -0
  52. package/extensions/plan/plan-text.ts +95 -0
  53. package/extensions/plan/plans.ts +90 -1
  54. package/extensions/plan/review.ts +12 -9
  55. package/extensions/plan/runtime.ts +72 -27
  56. package/extensions/plan/steps.ts +35 -25
  57. package/extensions/plan/todos.ts +24 -2
  58. package/extensions/plan/tools.ts +124 -24
  59. package/extensions/plan/tui.ts +7 -28
  60. package/extensions/plan/types.ts +9 -0
  61. package/extensions/plan/web-page.ts +2 -2
  62. package/extensions/plan/web.ts +12 -11
  63. package/extensions/rewind/README.md +28 -8
  64. package/extensions/rewind/commands.ts +1 -1
  65. package/extensions/rewind/format.ts +4 -4
  66. package/extensions/rewind/git.ts +56 -18
  67. package/extensions/rewind/index.ts +3 -5
  68. package/extensions/rewind/path-picker.ts +198 -0
  69. package/extensions/rewind/paths.ts +128 -0
  70. package/extensions/rewind/restore.ts +69 -28
  71. package/extensions/rewind/rewind.ts +80 -3
  72. package/extensions/rewind/runtime.ts +13 -5
  73. package/extensions/rewind/snapshot-run.ts +5 -2
  74. package/extensions/rewind/types.ts +2 -0
  75. package/extensions/subagent/README.md +7 -1
  76. package/extensions/subagent/accounting.ts +34 -0
  77. package/extensions/subagent/index.ts +23 -1
  78. package/extensions/subagent/stream.ts +2 -0
  79. package/extensions/subagent/types.ts +2 -0
  80. package/extensions/watch/README.md +8 -3
  81. package/extensions/watch/index.ts +19 -0
  82. package/extensions/web-access/README.md +43 -13
  83. package/extensions/web-access/charset.ts +58 -0
  84. package/extensions/web-access/fetch/find.ts +11 -7
  85. package/extensions/web-access/fetch/format.ts +64 -18
  86. package/extensions/web-access/fetch/page.ts +5 -1
  87. package/extensions/web-access/fetch/tool.ts +19 -18
  88. package/extensions/web-access/http.ts +49 -8
  89. package/extensions/web-access/search/facts.ts +38 -0
  90. package/extensions/web-access/search/tool.ts +13 -0
  91. package/extensions/web-access/text.ts +50 -11
  92. package/extensions/worktree/README.md +74 -15
  93. package/extensions/worktree/config.ts +11 -0
  94. package/extensions/worktree/exit.ts +35 -3
  95. package/extensions/worktree/gaps.ts +74 -0
  96. package/extensions/worktree/guard.ts +50 -4
  97. package/extensions/worktree/handoff.ts +55 -0
  98. package/extensions/worktree/index.ts +12 -2
  99. package/extensions/worktree/runtime.ts +20 -0
  100. package/extensions/worktree/status.ts +15 -2
  101. package/extensions/worktree/tools.ts +3 -2
  102. package/lib/README.md +10 -8
  103. package/lib/events.ts +86 -1
  104. package/lib/goal-state.ts +95 -0
  105. package/lib/requirements.ts +282 -62
  106. package/lib/tui/README.md +91 -0
  107. package/lib/tui/glyphs.ts +1 -0
  108. package/lib/web/layout.ts +12 -2
  109. package/package.json +5 -3
  110. package/themes/bytefly-dark.json +1 -1
  111. package/assets/chrome.png +0 -0
  112. package/assets/file-browse.png +0 -0
  113. package/assets/job.png +0 -0
  114. package/assets/plan-review.png +0 -0
  115. package/assets/question.png +0 -0
  116. package/assets/rewind.png +0 -0
  117. package/assets/todo.png +0 -0
  118. package/assets/tool-renderings.png +0 -0
  119. package/assets/tools/ask_user_question-cancelled.png +0 -0
  120. package/assets/tools/ask_user_question.png +0 -0
  121. package/assets/tools/bash.png +0 -0
  122. package/assets/tools/edit.png +0 -0
  123. package/assets/tools/enter_plan_mode.png +0 -0
  124. package/assets/tools/enter_worktree.png +0 -0
  125. package/assets/tools/exit_plan_mode.png +0 -0
  126. package/assets/tools/exit_worktree.png +0 -0
  127. package/assets/tools/find.png +0 -0
  128. package/assets/tools/goal.png +0 -0
  129. package/assets/tools/grep.png +0 -0
  130. package/assets/tools/job.png +0 -0
  131. package/assets/tools/list_worktrees.png +0 -0
  132. package/assets/tools/ls.png +0 -0
  133. package/assets/tools/memory-long-subject.png +0 -0
  134. package/assets/tools/memory.png +0 -0
  135. package/assets/tools/notice.png +0 -0
  136. package/assets/tools/open_path.png +0 -0
  137. package/assets/tools/prune_worktrees.png +0 -0
  138. package/assets/tools/read.png +0 -0
  139. package/assets/tools/read_doc-running.png +0 -0
  140. package/assets/tools/read_doc.png +0 -0
  141. package/assets/tools/run_doctor.png +0 -0
  142. package/assets/tools/send_wechat.png +0 -0
  143. package/assets/tools/set_plan_steps.png +0 -0
  144. package/assets/tools/subagent.png +0 -0
  145. package/assets/tools/todo.png +0 -0
  146. package/assets/tools/turn-rule.png +0 -0
  147. package/assets/tools/user-message.png +0 -0
  148. package/assets/tools/watch.png +0 -0
  149. package/assets/tools/web_fetch-rejected.png +0 -0
  150. package/assets/tools/web_fetch.png +0 -0
  151. package/assets/tools/web_search.png +0 -0
  152. package/assets/tools/write.png +0 -0
  153. package/assets/tools/write_plan.png +0 -0
@@ -11,7 +11,7 @@
11
11
  * and `bun build --no-bundle` needs no separate asset.
12
12
  */
13
13
 
14
- import { THEME_TOGGLE_JS } from "../../lib/web/index.ts";
14
+ import { SIDEBAR_COLLAPSE_MAX, THEME_TOGGLE_JS } from "../../lib/web/index.ts";
15
15
 
16
16
  export const CLIENT_JS = `(function () {
17
17
  "use strict";
@@ -183,7 +183,7 @@ export const CLIENT_JS = `(function () {
183
183
 
184
184
  var panel = document.querySelector("details.bf-app__sidebar");
185
185
  if (panel && window.matchMedia) {
186
- var mq = window.matchMedia("(max-width: 860px)");
186
+ var mq = window.matchMedia("(max-width: ${SIDEBAR_COLLAPSE_MAX}px)");
187
187
  var syncSide = function () { panel.open = !mq.matches; };
188
188
  syncSide();
189
189
  if (mq.addEventListener) mq.addEventListener("change", syncSide);
@@ -289,11 +289,11 @@ export const CLIENT_JS = `(function () {
289
289
  });
290
290
  }
291
291
 
292
- function flash(button, label) {
292
+ function flash(button, label, message) {
293
293
  var original = button.textContent;
294
294
  button.textContent = label;
295
295
  var status = document.getElementById("copy-status");
296
- if (status) status.textContent = label === "copied" ? "Copied to clipboard" : "Copy failed";
296
+ if (status) status.textContent = message;
297
297
  window.setTimeout(function () { button.textContent = original; }, 1200);
298
298
  }
299
299
 
@@ -310,8 +310,8 @@ export const CLIENT_JS = `(function () {
310
310
  }
311
311
  source
312
312
  .then(function (text) { return copyText(text); })
313
- .then(function () { flash(button, "copied"); })
314
- .catch(function () { flash(button, "failed"); });
313
+ .then(function () { flash(button, "copied", "Copied to clipboard"); })
314
+ .catch(function () { flash(button, "failed", "Copy failed"); });
315
315
  });
316
316
  });
317
317
 
@@ -483,7 +483,6 @@ export const CLIENT_JS = `(function () {
483
483
  option.id = "palette-option-" + index;
484
484
  option.setAttribute("role", "option");
485
485
  option.setAttribute("data-index", String(index));
486
- option.setAttribute("aria-selected", index === 0 ? "true" : "false");
487
486
  appendHighlighted(option, result.path, result.positions);
488
487
  paletteList.appendChild(option);
489
488
  });
@@ -46,7 +46,6 @@ const TEXT_EXTS = new Set([
46
46
  ".ini",
47
47
  ".cfg",
48
48
  ".conf",
49
- ".env",
50
49
  ".xml",
51
50
  ".csv",
52
51
  ".tsv",
@@ -89,9 +88,6 @@ const TEXT_EXTS = new Set([
89
88
  ".vue",
90
89
  ".svelte",
91
90
  ".astro",
92
- ".gitignore",
93
- ".gitattributes",
94
- ".editorconfig",
95
91
  ]);
96
92
  const TEXT_NAMES = new Set([
97
93
  "license",
@@ -137,8 +137,8 @@ interface RepoState {
137
137
  const INSERTIONS = /(\d+) insertions?\(\+\)/;
138
138
  const DELETIONS = /(\d+) deletions?\(-\)/;
139
139
 
140
- /** A count from a `--shortstat` line, or `0` when the line does not carry it. */
141
- function shortstatCount(text: string, pattern: RegExp): number {
140
+ /** A count from a diffstat summary line, or `0` when the line does not carry it. */
141
+ function summaryCount(text: string, pattern: RegExp): number {
142
142
  const match = pattern.exec(text);
143
143
  return match ? Number(match[1]) : 0;
144
144
  }
@@ -218,17 +218,16 @@ export function createGitDiff(
218
218
  const current = await state();
219
219
  if (!current) return undefined;
220
220
  const args = diffArgs(effectiveMode(mode, current.hasCommits));
221
- const [stat, shortstat] = await Promise.all([
222
- run(["diff", "--no-color", "--stat", ...args, "--"], { cwd: root }),
223
- run(["diff", "--no-color", "--shortstat", ...args, "--"], { cwd: root }),
224
- ]);
225
- const summary = shortstat.code === 0 ? shortstat.stdout : "";
221
+ const stat = await run(["diff", "--no-color", "--stat", ...args, "--"], { cwd: root });
222
+ const output = stat.code === 0 ? stat.stdout : "";
223
+ const trimmed = output.trimEnd();
224
+ const summary = trimmed.slice(trimmed.lastIndexOf("\n") + 1);
226
225
  return {
227
226
  mode,
228
227
  files: current.files.filter((file) => inMode(file, mode)),
229
- stat: stat.code === 0 ? stat.stdout.trim() : "",
230
- insertions: shortstatCount(summary, INSERTIONS),
231
- deletions: shortstatCount(summary, DELETIONS),
228
+ stat: output.trim(),
229
+ insertions: summaryCount(summary, INSERTIONS),
230
+ deletions: summaryCount(summary, DELETIONS),
232
231
  };
233
232
  },
234
233
  async file(rel, mode) {
@@ -12,6 +12,7 @@ import { GLYPHS } from "../../lib/tui/glyphs.ts";
12
12
  import {
13
13
  brandLockup,
14
14
  docHead,
15
+ SIDEBAR_COLLAPSE_MAX,
15
16
  WEB_BASE_CSS,
16
17
  WEB_COMPONENTS_CSS,
17
18
  WEB_LAYOUT_CSS,
@@ -61,7 +62,7 @@ export interface ChangeSummary {
61
62
  }
62
63
 
63
64
  /** Whether a path carries a change marker: a changed file, or a directory above one. */
64
- export function isChanged(summary: ChangeSummary | undefined, rel: string, isDir: boolean): boolean {
65
+ function isChanged(summary: ChangeSummary | undefined, rel: string, isDir: boolean): boolean {
65
66
  if (!summary || !rel) return false;
66
67
  return isDir ? summary.dirs.has(rel) : summary.files.has(rel);
67
68
  }
@@ -169,6 +170,16 @@ body.palette-open{overflow:hidden}
169
170
  .browse-keys dt{white-space:nowrap}
170
171
  .browse-keys dd{margin:0;color:var(--muted);font-size:var(--fs-sm)}
171
172
  .browse-kbd{display:inline-block;min-width:20px;padding:1px 6px;border:1px solid var(--border);border-bottom-width:2px;border-radius:var(--radius-sm);background:var(--surface);color:var(--text);font:var(--fs-xs) var(--font-mono);text-align:center}
173
+ /* Wide screens: pin quick open and the filter, and scroll the tree on its own
174
+ box. The sidebar is a details element, whose content sits in an anonymous
175
+ wrapper with no definite height, so the panel fills the sidebar by absolute
176
+ positioning; below the sidebar breakpoint the disclosure keeps normal page
177
+ scroll. */
178
+ @media not (max-width:${SIDEBAR_COLLAPSE_MAX}px){
179
+ .browse-sidebar{position:relative;overflow:hidden}
180
+ .browse-side{position:absolute;inset:0;display:flex;flex-direction:column;overflow:hidden}
181
+ .browse-side .bf-tree{flex:1 1 auto;min-height:0;overflow:auto}
182
+ }
172
183
  @media (max-width:820px){
173
184
  .browse-root{order:9;flex:1 1 100%;direction:ltr;text-align:left}
174
185
  }
@@ -238,7 +249,7 @@ ${SPRITE}
238
249
  <button type="button" class="bf-btn bf-btn--icon bf-theme-toggle" id="theme-toggle" aria-label="Toggle light and dark theme" title="Toggle light and dark theme"><span class="theme-glyph" aria-hidden="true">☾</span></button>
239
250
  </div><div class="browse-progress" aria-hidden="true"><span class="browse-progress__fill" id="progress-fill"></span></div></header>
240
251
  <div class="bf-app__body">
241
- <details class="bf-app__sidebar" open>
252
+ <details class="bf-app__sidebar browse-sidebar" open>
242
253
  <summary class="bf-panel__summary">Files</summary>
243
254
  <aside class="bf-panel browse-side">
244
255
  <button type="button" class="browse-quick-open" id="quick-open" aria-haspopup="dialog"><span>quick open</span><kbd>/</kbd></button>
@@ -252,7 +263,7 @@ ${SPRITE}
252
263
  ${layout.main}
253
264
  </main>
254
265
  </div>
255
- <footer class="bf-app__footer">served from ${escapeHtml(layout.rootLabel)} · read-only · ${layout.footerNote}</footer>
266
+ <footer class="bf-app__footer">served from ${escapeHtml(layout.rootLabel)} · read-only · ${escapeHtml(layout.footerNote)}</footer>
256
267
  ${layout.keys && layout.keys.length > 0 ? renderKeysSheet(layout.keys) : ""}
257
268
  <script src="/app.js" defer></script>
258
269
  </body>
@@ -283,7 +294,7 @@ function renderTreeNode(node: TreeNode, currentRel: string, summary?: ChangeSumm
283
294
  const expanded = node.children.length > 0;
284
295
  const isDir = node.isDir;
285
296
  const spec = isDir ? FOLDER_ICON : languageOf(node.name);
286
- const href = isDir ? (node.rel ? `/browse/${encodePath(node.rel)}` : "/browse/") : `/view/${encodePath(node.rel)}`;
297
+ const href = hrefFor(node);
287
298
  const current = node.rel === currentRel ? ` aria-current="page"` : "";
288
299
  const twisty = isDir
289
300
  ? `<button type="button" class="bf-tree__tw bf-tree__tw--toggle" aria-expanded="${expanded}" aria-label="${escapeHtml(node.name)}">${expanded ? "▾" : "▸"}</button>`
@@ -495,7 +506,7 @@ export function renderFilePage(page: FilePage): string {
495
506
  mainClass: rendered ? "browse-page browse-measure" : "browse-page",
496
507
  keys,
497
508
  main: ` <div class="bf-card">${head}${content}</div>\n ${note}`,
498
- footerNote: escapeHtml(page.rel),
509
+ footerNote: page.rel,
499
510
  git: page.git,
500
511
  summary: page.summary,
501
512
  });
@@ -715,7 +726,7 @@ export function renderFileDiffPage(page: FileDiffPage): string {
715
726
  mainClass: "browse-page",
716
727
  keys,
717
728
  main: ` <div class="bf-card">${head}${content}</div>\n ${note}`,
718
- footerNote: `diff · ${escapeHtml(modeScope(page.mode))}`,
729
+ footerNote: `diff · ${modeScope(page.mode)}`,
719
730
  git: page.git,
720
731
  summary: page.summary,
721
732
  });
@@ -726,7 +737,6 @@ export interface ErrorPage {
726
737
  status: number;
727
738
  message: string;
728
739
  tree: TreeNode;
729
- rel: string;
730
740
  git?: GitStatus;
731
741
  }
732
742
 
@@ -7,6 +7,7 @@
7
7
  * tested without a socket.
8
8
  */
9
9
 
10
+ import type { Stats } from "node:fs";
10
11
  import { stat } from "node:fs/promises";
11
12
  import { THEME_JS } from "../../lib/web/index.ts";
12
13
  import { CLIENT_JS } from "./client.ts";
@@ -87,8 +88,8 @@ function jsonResponse(status: number, value: unknown): ServeResponse {
87
88
  };
88
89
  }
89
90
 
90
- async function treeFor(ctx: ServeContext, _currentRel: string): Promise<TreeNode> {
91
- return buildTree(ctx.root, _currentRel, ctx.config.maxDirEntries);
91
+ async function treeFor(ctx: ServeContext, currentRel: string): Promise<TreeNode> {
92
+ return buildTree(ctx.root, currentRel, ctx.config.maxDirEntries);
92
93
  }
93
94
 
94
95
  /** Resolve the header's git context, never failing the request. */
@@ -153,18 +154,30 @@ async function errorResponse(
153
154
  status,
154
155
  message,
155
156
  tree,
156
- rel: "",
157
157
  git: await gitFor(ctx),
158
158
  });
159
159
  return { status, headers: { ...HTML_SECURITY, ...extra }, body };
160
160
  }
161
161
 
162
- async function browse(ctx: ServeContext, rawRel: string): Promise<ServeResponse> {
162
+ /** Resolve a request path once and render the directory or file page it names. */
163
+ async function renderPath(ctx: ServeContext, rawRel: string, raw: boolean): Promise<ServeResponse> {
163
164
  const abs = await resolveRequestPath(ctx.root, rawRel);
164
165
  const rel = relativePath(ctx.root, abs);
165
- const info = await stat(abs).catch(() => undefined);
166
- if (!info || !info.isDirectory()) return view(ctx, rawRel, false);
166
+ const info: Stats | undefined = await stat(abs).catch(() => undefined);
167
+ if (info?.isDirectory()) return directoryResponse(ctx, rel);
168
+ if (!info) throw new HttpError(404, "That path does not exist.");
169
+ return fileResponse(ctx, rel, raw);
170
+ }
167
171
 
172
+ async function browse(ctx: ServeContext, rawRel: string): Promise<ServeResponse> {
173
+ return renderPath(ctx, rawRel, false);
174
+ }
175
+
176
+ async function view(ctx: ServeContext, rawRel: string, raw: boolean): Promise<ServeResponse> {
177
+ return renderPath(ctx, rawRel, raw);
178
+ }
179
+
180
+ async function directoryResponse(ctx: ServeContext, rel: string): Promise<ServeResponse> {
168
181
  const listing = await listDirectory(ctx.root, rel, ctx.config.maxDirEntries);
169
182
  const tree = await treeFor(ctx, rel);
170
183
  const git = await gitFor(ctx);
@@ -184,13 +197,7 @@ async function browse(ctx: ServeContext, rawRel: string): Promise<ServeResponse>
184
197
  );
185
198
  }
186
199
 
187
- async function view(ctx: ServeContext, rawRel: string, raw: boolean): Promise<ServeResponse> {
188
- const abs = await resolveRequestPath(ctx.root, rawRel);
189
- const rel = relativePath(ctx.root, abs);
190
- const info = await stat(abs).catch(() => undefined);
191
- if (info?.isDirectory()) return browse(ctx, rawRel);
192
- if (!info) throw new HttpError(404, "That path does not exist.");
193
-
200
+ async function fileResponse(ctx: ServeContext, rel: string, raw: boolean): Promise<ServeResponse> {
194
201
  const file = await readFileView(ctx.root, rel, ctx.config.maxFileBytes, ctx.config.maxTextLines, ctx.config.markdown);
195
202
  const tree = await treeFor(ctx, rel);
196
203
  const git = await gitFor(ctx);
@@ -375,7 +382,9 @@ export async function handleRequest(
375
382
  if (pathname.startsWith("/raw/")) return await raw(ctx, pathname.slice("/raw/".length));
376
383
  return errorResponse(ctx, 404, "That page does not exist.");
377
384
  } catch (error) {
378
- if (error instanceof HttpError) return errorResponse(ctx, error.status, error.message);
379
- return errorResponse(ctx, 500, "Something went wrong reading that path.");
385
+ const status = error instanceof HttpError ? error.status : 500;
386
+ const message = error instanceof HttpError ? error.message : "Something went wrong reading that path.";
387
+ if (pathname.startsWith("/api/")) return jsonResponse(status, { error: message });
388
+ return errorResponse(ctx, status, message);
380
389
  }
381
390
  }
@@ -93,6 +93,10 @@ the branch, so the note appears whether the todos were written by the model, by
93
93
 
94
94
  `goal` is allowlisted in `plan-mode` (alongside `todo`), so the model can record
95
95
  or update the objective during read-only exploration without leaving plan mode.
96
+ Plan mode also reads the goal: it restates an active one in its injected context,
97
+ and when a plan is approved with no goal at all it offers to record the plan's
98
+ title as the goal (`lib/goal-state.ts` is the reader). An existing goal — active
99
+ or achieved — is never replaced by that offer.
96
100
 
97
101
  ### `/goal` command
98
102
 
@@ -232,7 +236,9 @@ depends on config.
232
236
  todo list still holds open items appends one line to the model-facing result
233
237
  naming the count. The list is replayed from the branch by
234
238
  `lib/todo-state.ts` (shared with plan's approval-time merge, which reads the
235
- same channel), never by importing the `todo` extension. Only the text is
239
+ same channel), never by importing the `todo` extension. The same reasoning
240
+ applies to the goal itself: `lib/goal-state.ts` owns the entry type and the
241
+ objective limit, and plan reads the published goal through it. Only the text is
236
242
  affected: `details` and `structuredContent` stay the goal alone, and the
237
243
  transcript result is still the status header.
238
244
  - **One verb table.** `GOAL_VERBS` is the only list of reserved words: the
@@ -9,10 +9,14 @@
9
9
  import { StringEnum } from "@earendil-works/pi-ai";
10
10
  import { Type, type Static } from "typebox";
11
11
  import { sanitizeLine } from "../../lib/format.ts";
12
+ import { MAX_GOAL_OBJECTIVE } from "../../lib/goal-state.ts";
12
13
  import { GOAL_ACTIONS, GOAL_STATUSES, type Goal, type GoalStatus } from "./types.ts";
13
14
 
14
- /** Maximum length of the objective. */
15
- export const MAX_OBJECTIVE = 2000;
15
+ /**
16
+ * Maximum length of the objective. Owned by `lib/goal-state.ts`, so a reader of
17
+ * the published state applies the same limit as this validator.
18
+ */
19
+ export const MAX_OBJECTIVE = MAX_GOAL_OBJECTIVE;
16
20
 
17
21
  const GoalStatusEnum = StringEnum(GOAL_STATUSES, {
18
22
  description: 'Whether the goal is still being pursued. Defaults to "active".',
@@ -15,8 +15,12 @@ export const GOAL_ACTIONS = ["set", "achieve", "clear"] as const;
15
15
 
16
16
  export type GoalAction = (typeof GOAL_ACTIONS)[number];
17
17
 
18
- /** Custom-entry type used to persist a goal set outside the tool (the `/goal` command). */
19
- export const GOAL_ENTRY_TYPE = "goal";
18
+ /**
19
+ * Custom-entry type used to persist a goal set outside the tool (the `/goal`
20
+ * command). Owned by `lib/goal-state.ts` because another extension's reader
21
+ * dispatches on it; re-exported here so goal's own modules keep one import.
22
+ */
23
+ export { GOAL_ENTRY_TYPE } from "../../lib/goal-state.ts";
20
24
 
21
25
  /** How an achieved goal renders in the persistent widget. */
22
26
  export type AchievedStyle = "show" | "hide";
@@ -38,10 +38,11 @@ How the `job` call and its result look in the transcript — the call line as a
38
38
  killing, and clearing.
39
39
  - Reports finished jobs to the model at the start of the next turn, or — for
40
40
  jobs started with `wake: true` — by triggering one turn.
41
- - Kills session-owned jobs on shutdown so no process is left behind; start a
42
- job with `detached: true` to keep it running and reattach next session. A
43
- detached POSIX job points its own output straight at its log file, so it keeps
44
- logging after this process ends.
41
+ - Kills session-owned jobs on shutdown so no process is left behind; on POSIX,
42
+ start a job with `detached: true` to keep it running and reattach next session.
43
+ A detached job points its own output straight at its log file, so it keeps
44
+ logging after this process ends. Windows has neither a process group to leave
45
+ nor a shell `EXIT` trap, so it refuses the detach instead of faking one.
45
46
 
46
47
  ## Tool
47
48
 
@@ -53,7 +54,7 @@ How the `job` call and its result look in the transcript — the call line as a
53
54
  | `cwd` | Working directory (`start`); defaults to the session cwd. |
54
55
  | `label` | Short display label (`start`); defaults to the command. |
55
56
  | `wake` | Wake the agent with one triggered turn when it finishes (`start`). |
56
- | `detached` | Leave it running when the session ends (`start`). |
57
+ | `detached` | Leave it running when the session ends (`start`); POSIX only, refused on Windows. |
57
58
  | `stdin` | Give the job a stdin pipe so `send` can write to it (`start`); not with `detached`. |
58
59
  | `id` | Job id such as `j1` (`status`/`logs`/`send`/`kill`/`wait`/`clear`). |
59
60
  | `lines` | Log lines to return (`logs`, 1–2000). |
@@ -191,7 +192,7 @@ document, project wins):
191
192
  | `enabled` | `true` | Track jobs at all. |
192
193
  | `showStatus` | `true` | Show the `▸ N` / `✗ N` status chips. |
193
194
  | `wakeOnFinish` | `false` | Wake the agent when any job finishes. |
194
- | `detachedByDefault` | `false` | Leave jobs running when the session ends. |
195
+ | `detachedByDefault` | `false` | Leave jobs running when the session ends; inert on Windows. |
195
196
  | `maxJobs` | `20` | Finished jobs kept before the oldest are pruned. |
196
197
  | `maxLogLines` | `100` | Log lines returned by `logs`. |
197
198
  | `maxLogBytes` | `8388608` | Bytes a log segment may reach before it rotates to `<id>.log.1`; `0` keeps logs unbounded. |
@@ -214,8 +215,8 @@ rewritten.
214
215
  - **`unknown` still has no exit code on some paths.** A POSIX job is wrapped in
215
216
  an `EXIT` trap that writes `$?` to a per-job `.status` file, so a job
216
217
  reattached after its session died reports its real exit code. `exec cmd`
217
- replaces the shell and bypasses the trap, and Windows has no equivalent, so
218
- those jobs settle as `unknown`.
218
+ replaces the shell and bypasses the trap, and a Windows job has no equivalent,
219
+ so those settle as `unknown`.
219
220
  - **Another session's jobs are shown, but not managed here.** Records are merged
220
221
  from every session file so ids stay unique and a peer's job is not reaped, and
221
222
  `list`/`/jobs` show them tagged `peer` (a dead session's records are adopted
@@ -236,10 +237,13 @@ rewritten.
236
237
  keep appending to the moved inode, or lose everything. Its log therefore grows
237
238
  without the `maxLogBytes` cap; `logs` still reads a bounded tail, and the file
238
239
  can be emptied by hand.
239
- - **Windows is best-effort.** Tree-kill uses `taskkill /T`; POSIX uses process
240
- groups. A detached job only escapes the parent process on POSIX, where its
241
- shell writes the log directly; on Windows it keeps the parent pipe and is
242
- captured only until the session ends.
240
+ - **Windows cannot detach a job.** There is no process group to leave and no
241
+ shell `EXIT` trap, so `detached: true` is refused with the three alternatives
242
+ (`detached: false`, WSL, a `subagent`) rather than starting a job that would
243
+ die with the session while claiming to outlive it. `job.detachedByDefault` is
244
+ inert there instead of failing every start with a config set on another
245
+ machine, and `doctor` names both facts. Tree-kill is still `taskkill /T`, and a
246
+ job reported only after its process is gone settles as `unknown`.
243
247
 
244
248
  ## Non-goals
245
249
 
@@ -127,6 +127,8 @@ export interface RuntimeOptions {
127
127
  killTree?: KillTreeFn;
128
128
  startToken?: StartTokenFn;
129
129
  now?: () => number;
130
+ /** Platform seam: whether this is Windows. Defaults to the running platform. */
131
+ isWindows?: () => boolean;
130
132
  /** Fixed config, for tests; production loads it per cwd. */
131
133
  config?: JobsConfig;
132
134
  }
@@ -202,6 +204,7 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
202
204
  const killTree = options.killTree ?? defaultKillTree;
203
205
  const startToken = options.startToken ?? defaultStartToken;
204
206
  const now = options.now ?? (() => Date.now());
207
+ const isWindows = options.isWindows ?? (() => process.platform === "win32");
205
208
 
206
209
  const jobs = new Map<string, Job>();
207
210
  const handles = new Map<string, Handle>();
@@ -717,7 +720,20 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
717
720
  if (!config.enabled) throw new Error("jobs are disabled.");
718
721
  ui.attach(ctx);
719
722
  const wantsStdin = startOptions.stdin === true;
720
- const detached = startOptions.detached ?? config.detachedByDefault;
723
+ // Windows has no process group to leave and no shell `EXIT` trap to record an
724
+ // exit code, so a job there cannot outlive the session: refusing an explicit
725
+ // detach is the honest answer, and it happens before any id is reserved.
726
+ // `detachedByDefault` stays inert instead of failing every start with a config
727
+ // the user set on another machine; `doctor` names that.
728
+ const windows = isWindows();
729
+ if (windows && startOptions.detached === true) {
730
+ throw new Error(
731
+ "A detached job cannot outlive the session on Windows: there is no process group to leave " +
732
+ "and no shell EXIT trap to record its exit code. Start it with `detached: false`, " +
733
+ "run it under WSL, or move the work into a `subagent`.",
734
+ );
735
+ }
736
+ const detached = windows ? false : (startOptions.detached ?? config.detachedByDefault);
721
737
  // A detached job outlives this process, so a stdin pipe owned by this session
722
738
  // would hand it EOF the moment the session ends; refuse instead of silently
723
739
  // dropping the pipe (or the detach).
@@ -732,14 +748,15 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
732
748
  // so a peer that advanced the shared counter cannot hand us a live job's id.
733
749
  const id = store.nextId(jobs.keys());
734
750
  const logPath = store.logPath(id);
735
- const statusPath = process.platform === "win32" ? null : store.statusPath(id);
736
- // A detached POSIX job points its own output at the log, so it keeps logging
737
- // and survives after this process ends; Windows keeps the parent pipe.
738
- const detachOutput = detached && process.platform !== "win32";
751
+ // Only a POSIX shell has an `EXIT` trap to record a status, and only a detached
752
+ // job points its own output at the log so it keeps logging after this process
753
+ // ends (which on Windows is never: `detached` is false there).
754
+ const statusPath = windows ? null : store.statusPath(id);
755
+ const detachOutput = detached;
739
756
 
740
757
  // Wrap the command so a POSIX shell records its exit status; the path travels
741
758
  // in the environment so the shell never has to quote it.
742
- const program = withExitTrap(startOptions.command, process.platform === "win32", detachOutput);
759
+ const program = withExitTrap(startOptions.command, windows, detachOutput);
743
760
  const env: NodeJS.ProcessEnv = { ...process.env };
744
761
  if (statusPath) env[STATUS_ENV] = statusPath;
745
762
  if (detachOutput) env[LOG_ENV] = logPath;
@@ -7,8 +7,10 @@
7
7
  * shell never has to quote it). A later session reads that file when it finds a
8
8
  * dead pid and reports `exited`/`failed` instead of `unknown`.
9
9
  *
10
- * Windows shells have no equivalent trap, so those jobs keep the old behavior:
11
- * a reattached job with no code stays `unknown`.
10
+ * Windows shells have no equivalent trap. A Windows job is never detached (the
11
+ * runtime refuses it), so the missing trap is not a second limitation: a job
12
+ * reported only after its process is gone still settles as `unknown`, which
13
+ * `doctor` names.
12
14
  */
13
15
 
14
16
  import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
@@ -39,9 +39,15 @@ How the `memory` call and its result look in the transcript — the call line as
39
39
  - Re-reads each store when its file changes on disk (compared by mtime and size),
40
40
  so an external edit lands in the same session without a restart.
41
41
  - Exposes `/memory`: in the TUI it opens a picker to add, edit, or forget a
42
- single note, filter by scope, and reload; elsewhere it lists the store.
43
- `/memory path`, `edit`, and `clear` print the file paths, open the multi-line
44
- editor, and clear a scope.
42
+ single note, merge two notes into one, move a note to the other scope, filter
43
+ by scope, and reload; elsewhere it lists the store. `/memory path`, `edit`, and `clear` print the file paths,
44
+ open the multi-line editor, and clear a scope; `/memory doctor` reports store
45
+ health.
46
+ - Reports store health with `/memory doctor`: note counts and file bytes per
47
+ scope, how much of the `maxInjectBytes` budget the injection uses and which
48
+ scope overflowed it, and the near-duplicate pairs the conservative key already
49
+ considers one fact. It is read-only — the report names the pair and the picker
50
+ merges it.
45
51
  - Loads and writes the project store only when the project is trusted, sanitizes
46
52
  every note to one terminal-safe line, and writes atomically.
47
53
 
@@ -91,24 +97,49 @@ or stem both survive.
91
97
 
92
98
  | Command | What it does |
93
99
  |---|---|
94
- | `/memory` | In the TUI, open the picker (add, edit, or forget a note; `s`/Tab filters by scope; `r` reloads). Other modes notify the stored notes. |
100
+ | `/memory` | In the TUI, open the picker (add, edit, forget, merge, or move notes; `s`/Tab filters by scope; `r` reloads). Other modes notify the stored notes. |
95
101
  | `/memory list` | Notify the stored notes and counts. |
96
102
  | `/memory path` | Notify the two file paths and whether the project store is trusted. |
97
103
  | `/memory reload` | Re-read the stores from disk and report what changed. |
104
+ | `/memory doctor` | Report counts, file bytes, the injection budget with the notes it omits, and near-duplicate pairs. Read-only. |
98
105
  | `/memory edit [project\|global]` | Open the multi-line editor prefilled with the file and save the result. |
99
106
  | `/memory clear [project\|global]` | Clear a scope after a confirmation. |
100
107
 
101
108
  Bulk deletion lives only in the command, behind a confirm — the model cannot wipe
102
109
  memory in one call.
103
110
 
111
+ ### Picker keys
112
+
113
+ | Key | Action |
114
+ |---|---|
115
+ | `Enter` / `e` | Edit the focused note in the shared inline editor. |
116
+ | `a` | Add a note to the active scope. |
117
+ | `d` | Forget the focused note after a `y`/`N` confirm. |
118
+ | `m` | Mark the focused note as a merge source; `m` on a second note in the same scope opens the editor pre-filled with `"<first>; <second>"`. `m` on the marked note unmarks it. |
119
+ | `p` | Move the focused note to the other scope: project → global, or global → project. No confirmation; `p` again moves it back, and a move into an untrusted project is refused. |
120
+ | `s` / `Tab` | Cycle the scope filter. |
121
+ | `r` | Re-read the stores. |
122
+ | `Esc` | Cancel the mark, the editor, or the screen, in that order. |
123
+
124
+ A merge is one write: the store replaces the first note in place and drops the
125
+ second, so a cancelled or failed merge leaves both notes as they were. Two notes
126
+ in different scopes are refused — the stores are separate files.
127
+
128
+ A move is two writes across those files, ordered target first: the note is added
129
+ to the destination and only then removed from the source. If the second write
130
+ fails the note is in both scopes — a duplicate `/memory doctor` reports and `m`
131
+ merges — rather than gone. A note the destination already holds is not
132
+ duplicated, and the move still removes it from the source.
133
+
104
134
  ## Behaviour by mode
105
135
 
106
136
  The tool and the context injection work in every mode (`tui`, `rpc`, `json`,
107
- `print`). The picker opened by a bare `/memory` requires `ctx.mode === "tui"`, and
108
- `/memory edit` and `/memory clear` require `ctx.hasUI`; in JSON and print sessions
109
- the command reports that the interactive form needs a TUI session. Memory itself is
110
- external state, so it is not reconstructed from the session tree and tree
111
- navigation does not change it.
137
+ `print`), and `/memory doctor` reports through `notify` everywhere. The picker
138
+ opened by a bare `/memory` requires `ctx.mode === "tui"`, and `/memory edit` and
139
+ `/memory clear` require `ctx.hasUI`; in JSON and print sessions the command
140
+ reports that the interactive form needs a TUI session. Memory itself is external
141
+ state, so it is not reconstructed from the session tree and tree navigation does
142
+ not change it.
112
143
 
113
144
  ## Configuration
114
145
 
@@ -152,10 +183,11 @@ skill. Mitigations:
152
183
  - The near-duplicate guard on `add` is deliberately conservative: it folds
153
184
  case, whitespace runs, and trailing sentence punctuation only, never word
154
185
  order, stemming, or internal punctuation. Two paraphrases can therefore still
155
- both be stored; use `update` to consolidate them.
186
+ both be stored; `/memory doctor` reports the pairs the key does consider equal,
187
+ and `m` in the picker merges them under wording you write.
156
188
  - `update` replaces every exact match of `text`; a replacement that already
157
189
  exists under a different note can leave a duplicate. Forget the old note first
158
- when consolidating.
190
+ when consolidating, or merge the pair in the picker afterward.
159
191
  - Only one note per line; multi-line notes are collapsed to a single line.
160
192
 
161
193
  ## Non-goals
@@ -180,6 +212,15 @@ when consolidating.
180
212
 
181
213
  ## Design notes
182
214
 
215
+ - **Health reads the injection it describes.** `injectionPlan` is the same
216
+ cap arithmetic `buildInjection` joins into the context body, so the doctor
217
+ cannot claim notes fit while recall drops them; `buildInjection` is a join over
218
+ that plan, which is why the report needed no second budget model.
219
+ - **A merge is one transform.** `applyMerge` replaces the first note and drops
220
+ the second inside the same read-modify-write the other mutations use, so a
221
+ cancelled or failed merge cannot leave one note gone and the other not. The
222
+ editor is pre-filled with both notes, but the wording saved is the user's — the
223
+ duplicate rule stays conservative and nothing is folded automatically.
183
224
  - **External storage, deliberately.** `goal`/`todo` keep state in tool-result
184
225
  `details` because it follows the active branch. Memory is the opposite: it must
185
226
  outlive the branch and be editable outside Pi, so it is plain files read at
@@ -207,11 +248,12 @@ when consolidating.
207
248
  | `types.ts` | `MemoryScope`, `MemoryAction`, `MemoryDetails`, the context type. |
208
249
  | `config.ts` | Defaults and the `memory` section of `bytefly.json` loading/clamping. |
209
250
  | `schema.ts` | TypeBox params/result and pure validation of action, scope, and text. |
210
- | `store.ts` | Paths, parse/read, file stamps, add/update/forget/clear transforms, atomic queued write. |
251
+ | `store.ts` | Paths, parse/read, file stamps, add/update/forget/merge/clear transforms, atomic queued write. |
252
+ | `doctor.ts` | The health report (pure): counts, file bytes, the injection plan's omissions, and near-duplicate pairs. |
211
253
  | `runtime.ts` | Session view of both stores, stamp-based refresh, trust gating, mutations. |
212
254
  | `format.ts` | Pure call/result/notice text and the bounded injection body. |
213
255
  | `errors.ts` | `messageOf`, the shared error-text extraction for the tool and command. |
214
- | `tui.ts` | The `/memory` picker component (note list, inline editor, scope filter), drawn from the `lib/tui/` kit (`titleRule`, `dockListFrame`, `listRow`, `emptyState`). |
256
+ | `tui.ts` | The `/memory` picker component (note list, inline editor, merge mark, scope move, scope filter), drawn from the `lib/tui/` kit (`titleRule`, `dockListFrame`, `listRow`, `emptyState`). |
215
257
  | `tools.ts` | `registerTool("memory", …)`. |
216
258
  | `commands.ts` | `registerCommand("memory", …)`. |
217
259
 
@@ -12,6 +12,7 @@
12
12
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
13
13
  import { withRailsSuppressed } from "../../lib/rails.ts";
14
14
  import { messageOf } from "./errors.ts";
15
+ import { formatHealth, memoryHealth } from "./doctor.ts";
15
16
  import { formatNotice, scopeLabel } from "./format.ts";
16
17
  import type { MemoryRuntime } from "./runtime.ts";
17
18
  import { MemoryPickerComponent } from "./tui.ts";
@@ -30,10 +31,10 @@ function notify(ctx: ExtensionCommandContext, message: string, kind: "info" | "w
30
31
  }
31
32
 
32
33
  /** `/memory` subcommands and the scope argument accepted by `edit`/`clear`. */
33
- const MEMORY_SUBCOMMANDS = ["list", "path", "reload", "edit", "clear"];
34
+ const MEMORY_SUBCOMMANDS = ["list", "path", "reload", "doctor", "edit", "clear"];
34
35
 
35
36
  /** Usage line shared by the command description and the unknown-subcommand warning. */
36
- const MEMORY_USAGE = "/memory [list | path | reload | edit [project|global] | clear [project|global]]";
37
+ const MEMORY_USAGE = "/memory [list | path | reload | doctor | edit [project|global] | clear [project|global]]";
37
38
 
38
39
  /** Subcommand on the first token, scope on the second for `edit`/`clear`. */
39
40
  function completeMemoryArg(prefix: string): Array<{ value: string; label: string }> | null {
@@ -74,6 +75,8 @@ async function openPicker(pi: ExtensionAPI, runtime: MemoryRuntime, ctx: Extensi
74
75
  add: (scope, text) => runtime.add(scope, text),
75
76
  update: (scope, from, to) => runtime.update(scope, from, to),
76
77
  forget: (scope, entry) => runtime.forget(scope, entry),
78
+ merge: (scope, first, second, merged) => runtime.merge(scope, first, second, merged),
79
+ move: (from, to, text) => runtime.move(from, to, text),
77
80
  },
78
81
  () => {
79
82
  stopTimer();
@@ -140,6 +143,21 @@ export function registerCommands(pi: ExtensionAPI, runtime: MemoryRuntime): void
140
143
  );
141
144
  return;
142
145
  }
146
+ case "doctor": {
147
+ // Read-only: the report names what to fix, and `m` in the picker merges a pair.
148
+ notify(
149
+ ctx,
150
+ formatHealth(
151
+ memoryHealth({
152
+ entries: runtime.entries(),
153
+ sizes: runtime.sizes(),
154
+ trusted: runtime.trusted(),
155
+ config: runtime.config(),
156
+ }),
157
+ ),
158
+ );
159
+ return;
160
+ }
143
161
  case "edit": {
144
162
  if (!ctx.hasUI) {
145
163
  notify(ctx, "Editing memory requires an interactive session.", "warning");