@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
@@ -3,17 +3,20 @@
3
3
  *
4
4
  * A page that stays open holds one event stream; this module builds the snapshot
5
5
  * it is sent, the frame that carries it, and the channel that fans it out on a
6
- * tick. The git and diff providers already memoize, so a tick re-reads at most
7
- * one `git status`, and every read failure degrades to `null`: a status feed
8
- * must never be able to fail a page or the server that serves it.
6
+ * tick. A tick costs one `git status` and one `git diff --stat`: the providers'
7
+ * 2 s memo only coalesces a tick with a page render in the same window, so the
8
+ * cadence is what bounds the reads. Every read failure degrades to `null`: a
9
+ * status feed must never be able to fail a page or the server that serves it.
9
10
  *
10
11
  * The channel opens no socket of its own — the caller hands it a `write`/`end`
11
12
  * pair — so the whole feed is tested without one.
12
13
  */
13
14
 
14
15
  import { defaultInterval, defaultTimer } from "../../lib/timer.ts";
16
+ import { LIVE_MAX_CLIENTS } from "./config.ts";
15
17
  import { changeDirs } from "./diff.ts";
16
18
  import type { DiffList, GitDiffProvider, GitStatus, GitStatusProvider } from "./git.ts";
19
+ import { changeRevision } from "./rev.ts";
17
20
 
18
21
  /**
19
22
  * The changed-path index the pages render from: the totals the changes chip
@@ -50,12 +53,27 @@ export interface LiveChanges {
50
53
  deletions: number;
51
54
  files: string[];
52
55
  dirs: string[];
56
+ /**
57
+ * Identity of this change set, counts included. A page that painted the same
58
+ * revision repaints nothing when the next frame arrives.
59
+ */
60
+ rev: string;
61
+ }
62
+
63
+ /** A change index as it goes over the wire, with its revision. */
64
+ function changesOf(index: ChangeIndex): LiveChanges {
65
+ const changes = {
66
+ count: index.count,
67
+ insertions: index.insertions,
68
+ deletions: index.deletions,
69
+ files: [...index.files].sort(),
70
+ dirs: [...index.dirs].sort(),
71
+ };
72
+ return { ...changes, rev: changeRevision(changes) };
53
73
  }
54
74
 
55
75
  /** One status frame's payload. */
56
76
  export interface LiveSnapshot {
57
- /** Server wall clock when the snapshot was taken, in ms. */
58
- at: number;
59
77
  /** How long the server has been listening, in ms. */
60
78
  uptimeMs: number;
61
79
  /** Requests the server has answered so far, this one included. */
@@ -112,15 +130,7 @@ export function createLiveStatus(options: LiveStatusOptions): LiveStatusSource {
112
130
  uptimeMs: Math.max(0, at - options.startedAt),
113
131
  requests: options.requests(),
114
132
  git,
115
- changes: index
116
- ? {
117
- count: index.count,
118
- insertions: index.insertions,
119
- deletions: index.deletions,
120
- files: [...index.files].sort(),
121
- dirs: [...index.dirs].sort(),
122
- }
123
- : null,
133
+ changes: index ? changesOf(index) : null,
124
134
  };
125
135
  },
126
136
  };
@@ -158,6 +168,8 @@ export interface LiveChannelOptions {
158
168
  intervalMs: number;
159
169
  /** How long the last page's departure is held before `onIdle` is called, in ms. */
160
170
  idleMs: number;
171
+ /** How many pages may attach at once; defaults to `LIVE_MAX_CLIENTS`. */
172
+ maxClients?: number;
161
173
  /** Called once the last page has been gone for `idleMs`; omitted, the channel only idles. */
162
174
  onIdle?: () => void;
163
175
  /** Timer seam; defaults to an unref'd interval. */
@@ -172,6 +184,11 @@ export interface LiveChannel {
172
184
  attach(client: LiveClient): () => void;
173
185
  /** End every connected page, stop the ticker, and drop a pending idle stop. */
174
186
  close(): void;
187
+ /**
188
+ * Whether another page must be refused. The caller asks before it opens the
189
+ * stream, so a refusal never has to take back a head it already wrote.
190
+ */
191
+ atCapacity(): boolean;
175
192
  }
176
193
 
177
194
  /**
@@ -183,6 +200,7 @@ export interface LiveChannel {
183
200
  export function createLiveChannel(options: LiveChannelOptions): LiveChannel {
184
201
  const timer = options.timer ?? defaultTicker;
185
202
  const timeout = options.timeout ?? defaultTimeout;
203
+ const maxClients = options.maxClients ?? LIVE_MAX_CLIENTS;
186
204
  const clients = new Set<LiveClient>();
187
205
  let cancel: (() => void) | undefined;
188
206
  let cancelIdle: (() => void) | undefined;
@@ -252,5 +270,8 @@ export function createLiveChannel(options: LiveChannelOptions): LiveChannel {
252
270
  for (const client of clients) client.end();
253
271
  clients.clear();
254
272
  },
273
+ atCapacity() {
274
+ return clients.size >= maxClients;
275
+ },
255
276
  };
256
277
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Revisions for the browse live status.
3
+ *
4
+ * A revision is a short digest of something a page already drew — the change set
5
+ * a frame carries, or the names a listing holds — so a client can tell "nothing
6
+ * moved" from "paint again" without comparing the payload it was sent. A
7
+ * collision costs one skipped repaint and is corrected by the next frame or a
8
+ * reload, which is the trade a 12-character digest makes for a small frame.
9
+ */
10
+
11
+ import { createHash } from "node:crypto";
12
+
13
+ /**
14
+ * A digest of the parts, cut to 12 hex characters. The parts are joined with NUL,
15
+ * which no filename can contain, so two different lists cannot run together into
16
+ * the same text.
17
+ */
18
+ function digest(parts: readonly string[]): string {
19
+ return createHash("sha1").update(parts.join("\u0000")).digest("hex").slice(0, 12);
20
+ }
21
+
22
+ /** The change set a frame carries: its totals and paths, in a stable order. */
23
+ export interface RevisionInput {
24
+ count: number;
25
+ insertions: number;
26
+ deletions: number;
27
+ files: readonly string[];
28
+ dirs: readonly string[];
29
+ }
30
+
31
+ /**
32
+ * The revision of a change set. The totals are part of it, so an edit that moves
33
+ * no path still moves the revision and the chip's `+A −D` stays honest.
34
+ */
35
+ export function changeRevision(changes: RevisionInput): string {
36
+ return digest([`${changes.count}:${changes.insertions}:${changes.deletions}`, ...changes.files, ...changes.dirs]);
37
+ }
38
+
39
+ /** The revision of a listing's names, independent of the order they arrived in. */
40
+ export function listingRevision(names: readonly string[]): string {
41
+ return digest([...names].sort());
42
+ }
@@ -22,6 +22,7 @@ import {
22
22
  } from "../../lib/http/index.ts";
23
23
  import { THEME_JS, THEME_JS_PATH } from "../../lib/web/index.ts";
24
24
  import { CLIENT_JS } from "./client.ts";
25
+ import type { LivePage } from "./chrome.ts";
25
26
  import type { ServeConfig } from "./config.ts";
26
27
  import { isDiffMode, type DiffMode } from "./diff.ts";
27
28
  import type { DiffList, GitDiffProvider, GitStatus, GitStatusProvider } from "./git.ts";
@@ -43,6 +44,7 @@ import { fuzzySearch } from "./fuzzy.ts";
43
44
  import { renderDirectoryPage, renderDiffPage, renderErrorPage, renderFileDiffPage, renderFilePage } from "./html.ts";
44
45
  import type { ChangeSummary } from "./html.ts";
45
46
  import { encodePath, HttpError, relativePath, resolveDecodedPath, resolveRequestPath } from "./paths.ts";
47
+ import { listingRevision } from "./rev.ts";
46
48
 
47
49
  export interface ServeContext {
48
50
  root: string;
@@ -108,9 +110,13 @@ function summaryOf(list: DiffList): ChangeSummary {
108
110
  };
109
111
  }
110
112
 
111
- /** The live chrome a page renders, or `undefined` when live status is off. */
112
- function liveOf(ctx: ServeContext): { intervalMs: number } | undefined {
113
- return ctx.config.live && ctx.live ? { intervalMs: ctx.config.liveIntervalMs } : undefined;
113
+ /**
114
+ * The live chrome a page renders, or `undefined` when live status is off. A
115
+ * listing page also passes the revision of what it drew, which is what its
116
+ * reload control compares against; every other page has nothing to compare.
117
+ */
118
+ function liveOf(ctx: ServeContext, folder?: { dir: string; rev: string }): LivePage | undefined {
119
+ return ctx.config.live && ctx.live ? { intervalMs: ctx.config.liveIntervalMs, ...folder } : undefined;
114
120
  }
115
121
 
116
122
  /** The `head` change summary, for the header chip, tree markers, and file action. */
@@ -184,7 +190,10 @@ async function directoryResponse(ctx: ServeContext, rel: string): Promise<ServeR
184
190
  maxThumbBytes: ctx.config.maxThumbBytes,
185
191
  git,
186
192
  summary: changes,
187
- live: liveOf(ctx),
193
+ live: liveOf(
194
+ ctx,
195
+ listing.truncated ? undefined : { dir: rel, rev: listingRevision(listing.entries.map((entry) => entry.name)) },
196
+ ),
188
197
  }),
189
198
  );
190
199
  }
@@ -312,6 +321,17 @@ async function treeApi(ctx: ServeContext, decodedPath: string): Promise<ServeRes
312
321
  return jsonResponse(200, { path: rel, entries: listing.entries.map(treeApiEntry) });
313
322
  }
314
323
 
324
+ /** The revision of one folder's listing: what a page's reload control compares against. */
325
+ async function dirRevApi(ctx: ServeContext, decodedPath: string): Promise<ServeResponse> {
326
+ const abs = await resolveDecodedPath(ctx.root, decodedPath);
327
+ const rel = relativePath(ctx.root, abs);
328
+ const listing = await listDirectory(ctx.root, rel, ctx.config.maxDirEntries);
329
+ // A truncated listing is a prefix whose boundary moves, so its revision is not
330
+ // comparable with what the page drew: `null` says "cannot tell".
331
+ const names = listing.entries.map((entry) => entry.name);
332
+ return jsonResponse(200, { path: rel, rev: listing.truncated ? null : listingRevision(names) });
333
+ }
334
+
315
335
  /** Fuzzy file search for the quick-open palette, over the cached index. */
316
336
  async function filesApi(ctx: ServeContext, query: string): Promise<ServeResponse> {
317
337
  const index = ctx.index ? await ctx.index() : await buildFileIndex(ctx.root, ctx.config.maxIndexEntries);
@@ -355,6 +375,7 @@ export async function handleRequest(
355
375
  if (pathname === THEME_JS_PATH) return { status: 200, headers: scriptHeaders(), body: THEME_JS };
356
376
  if (pathname === "/favicon.ico") return { status: 204, headers: pageHeaders(), body: "" };
357
377
  if (pathname === "/api/tree") return await treeApi(ctx, search.get("path") ?? "");
378
+ if (pathname === "/api/dir-rev") return await dirRevApi(ctx, search.get("path") ?? "");
358
379
  if (pathname === "/api/files") return await filesApi(ctx, search.get("q") ?? "");
359
380
  if (pathname === "/events") return liveEvents(ctx);
360
381
  const rawMode = search.get("mode");
@@ -103,6 +103,13 @@ export async function createFileServer(options: FileServerOptions): Promise<File
103
103
  respond(request, response, result);
104
104
  return;
105
105
  }
106
+ // Checked before the head is written: a refusal must not leave a stream the
107
+ // client would wait on. `204` is the one status an event source does not
108
+ // reconnect after, so a page beyond the cap goes quiet instead of retrying.
109
+ if (live.atCapacity()) {
110
+ respond(request, response, { status: 204, headers: { "Cache-Control": "no-store" }, body: "" });
111
+ return;
112
+ }
106
113
  openEventStream(response, result);
107
114
  const detach = live.attach({
108
115
  write: (frame) => {
@@ -47,6 +47,8 @@ body.palette-open{overflow:hidden}
47
47
  swap only changes the colour, and the footer line reads as one muted clause. */
48
48
  .browse-live{font-family:var(--font-mono);border:1px solid currentColor;border-radius:var(--radius-pill);padding:2px 10px}
49
49
  .browse-live__glyph{font-size:9px;line-height:1}
50
+ .browse-reload{border:1px solid currentColor;border-radius:var(--radius-pill);padding:2px 10px;cursor:pointer;font-family:var(--font-mono)}
51
+ .browse-reload:hover{text-decoration:underline}
50
52
  .browse-live-line{color:var(--muted);font-variant-numeric:tabular-nums}
51
53
  .browse-live-line::after{content:" · "}
52
54
  .browse-diff-totals{font-family:var(--font-mono)}
@@ -19,7 +19,8 @@
19
19
 
20
20
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
21
21
  import { isPursuedGoal } from "../../lib/goal-state.ts";
22
- import { PLAN_CONSENT, requestPlanMode } from "../../lib/plan-entry.ts";
22
+ import { isRemoteTurn, remoteLanguage } from "../../lib/interaction.ts";
23
+ import { PLAN_CONSENT, PLAN_CONSENT_SHORT, PLAN_CONSENT_SHORT_ZH, requestPlanMode } from "../../lib/plan-entry.ts";
23
24
  import { isPlanModeEnabled } from "../../lib/plan-state.ts";
24
25
  import { PLAN_ENTER_TOOL } from "../../lib/tool-names.ts";
25
26
  import type { Goal, GoalStatus } from "./types.ts";
@@ -45,7 +46,12 @@ export async function goalPhase(
45
46
  if (!options.ask || !ctx.hasUI) return "active";
46
47
  // Do not ask about a phase this session cannot enter.
47
48
  if (!pi.getAllTools().some((tool) => tool.name === PLAN_ENTER_TOOL)) return "active";
48
- const approved = await ctx.ui.confirm(PLAN_ASK, PLAN_CONSENT);
49
+ const consent = isRemoteTurn(ctx)
50
+ ? remoteLanguage(ctx) === "zh"
51
+ ? PLAN_CONSENT_SHORT_ZH
52
+ : PLAN_CONSENT_SHORT
53
+ : PLAN_CONSENT;
54
+ const approved = await ctx.ui.confirm(PLAN_ASK, consent);
49
55
  if (!approved) return "active";
50
56
  return (await requestPlanMode(pi, ctx)) ? "planning" : "active";
51
57
  }
@@ -37,7 +37,8 @@ How the `job` call and its result look in the transcript — the call line as a
37
37
  chip for this session's jobs, plus a `/jobs` screen for selecting, tailing,
38
38
  killing, and clearing.
39
39
  - Reports finished jobs to the model at the start of the next turn, or — for
40
- jobs started with `wake: true` — by triggering one turn.
40
+ jobs started with `wake: true` — by triggering one turn as soon as the agent is
41
+ idle, including right after a running turn settles.
41
42
  - Kills session-owned jobs on shutdown so no process is left behind; on POSIX,
42
43
  start a job with `detached: true` to keep it running and reattach next session.
43
44
  A detached job points its own output straight at its log file, so it keeps
@@ -338,13 +339,14 @@ rewritten.
338
339
  extension keeps only what is job-shaped — the durable registry, rotation,
339
340
  stdin, and the job vocabulary. The report coordinator is also why a wake can
340
341
  never stack two turns: it owns the guard for the window between the triggered
341
- send and `agent_start`.
342
+ send and `agent_start`, and it retries a wake the running turn suppressed.
342
343
  - **Notify by default, wake opt-in.** A failed job raises one `ctx.ui.notify`
343
344
  (a clean exit is left to the list and chips). Finished jobs are also posted
344
345
  as a shared transcript notice — model-facing text plus the notice block — at the
345
- next `before_agent_start`. A `wake: true` job (or `wakeOnFinish`) additionally
346
- triggers one turn when the agent is idle; auto-wake is not the default because
347
- it easily loops and burns tokens.
346
+ next `before_agent_start`, or in the wake turn it raises. A `wake: true` job (or
347
+ `wakeOnFinish`) additionally triggers one turn as soon as the agent is idle,
348
+ including right after a running turn settles; auto-wake is not the default
349
+ because it easily loops and burns tokens.
348
350
  - **Width-1 glyphs and cached latest line.** The running chip is `▸ N` and the
349
351
  failure chip `✗ N`, each a two-token badge under its own status key, so the
350
352
  status bar compacts each independently (`▸N` / `✗N`) and an unreported
@@ -5,7 +5,8 @@
5
5
  * processes with their output streamed to log files; the status chip shows
6
6
  * running and unreported-failure counts, and finished jobs are reported to the
7
7
  * model — and shown as a bytefly notice — at the next turn, or by triggering one
8
- * turn for a `wake` job.
8
+ * turn for a `wake` job as soon as the agent is idle, including right after a
9
+ * running turn settles.
9
10
  *
10
11
  * Load with: pi --extension ./extensions/job
11
12
  */
@@ -13,7 +14,7 @@
13
14
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
15
  import { isExtensionEnabled } from "../../lib/env.ts";
15
16
  import { JOB_FINISHED_CHANNEL, type JobFinishedEvent } from "../../lib/events.ts";
16
- import { noticeMessage, type NoticeMessage } from "../../lib/notice.ts";
17
+ import { burstText, noticeMessage, type NoticeMessage } from "../../lib/notice.ts";
17
18
  import { createPendingReporter, registerPendingChannel } from "../../lib/report.ts";
18
19
  import { registerCommands } from "./commands.ts";
19
20
  import { formatCompletion, jobOutcome, shortLabel } from "./format.ts";
@@ -25,8 +26,13 @@ import type { JobRecord } from "./types.ts";
25
26
  const JOB_CONTEXT_MARKER = "[BACKGROUND JOBS]";
26
27
 
27
28
  function buildContext(jobs: JobRecord[]): string {
28
- const lines = jobs.map((job) => formatCompletion(job));
29
- return `${JOB_CONTEXT_MARKER}\n${jobs.length} background job${jobs.length === 1 ? "" : "s"} finished:\n${lines.join("\n")}\nRun \`job logs <id>\` for output.`;
29
+ const count = jobs.length;
30
+ return burstText({
31
+ marker: JOB_CONTEXT_MARKER,
32
+ headline: `${count} background job${count === 1 ? "" : "s"} finished`,
33
+ lines: jobs.map((job) => formatCompletion(job)),
34
+ hint: "Run `job logs <id>` for output.",
35
+ });
30
36
  }
31
37
 
32
38
  /** The shared notice for pending completions: model text plus renderer details. */
@@ -66,15 +72,17 @@ export default function jobs(pi: ExtensionAPI, deps: JobsDeps = {}): void {
66
72
  if (!isExtensionEnabled("job")) return;
67
73
  const runtime = deps.runtime ?? createJobsRuntime();
68
74
 
69
- // Opt-in wake: a `wake` job finished while the agent is idle. The reporter
70
- // reports *all* pending completions, not just the waking one, so a job that
71
- // finished earlier is not silently marked seen and dropped.
75
+ // Opt-in wake: a `wake` job finished. The reporter reports *all* pending
76
+ // completions, not just the waking one, so a job that finished earlier is not
77
+ // silently marked seen and dropped, and it retries a completion that arrived
78
+ // mid-turn once the run settles.
72
79
  const reporter = createPendingReporter<JobRecord>({
73
80
  wake: (notice) => pi.sendMessage(notice, { triggerTurn: true }),
74
81
  pending: () => runtime.pendingCompletions(),
75
82
  markReported: (records) => runtime.markReported(records.map((job) => job.id)),
76
83
  buildNotice: jobNotice,
77
84
  source: "job",
85
+ wakeRequested: () => runtime.pendingCompletions().some((job) => job.wake),
78
86
  });
79
87
 
80
88
  registerTools(pi, runtime);
@@ -169,9 +169,9 @@ skill. Mitigations:
169
169
  injection, and rendering.
170
170
  - Reads are capped at 256 KiB and the injected body at `maxInjectBytes`.
171
171
  - The path is a fixed constant; a symlink at the memory path is refused.
172
- - The files are ordinary user-owned markdown. They are **not** self-ignored like
173
- `.pi/plans`, so committing or sharing them is the user's choice; review
174
- project memory before trusting a repository.
172
+ - The files are ordinary user-owned markdown, so committing or sharing them is
173
+ the user's choice; review project memory before trusting a repository. Notes
174
+ stay in the project, unlike plans, which live in the agent directory.
175
175
 
176
176
  ## Limitations
177
177