@gavin-hu/my-pi-agent 0.11.4 → 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 (197) hide show
  1. package/README.md +38 -26
  2. package/README.zh-CN.md +33 -23
  3. package/extensions/bytefly/README.md +381 -225
  4. package/extensions/bytefly/chrome.ts +7 -2
  5. package/extensions/bytefly/doctor/register.ts +6 -0
  6. package/extensions/bytefly/doctor/runtime.ts +52 -6
  7. package/extensions/bytefly/doctor/tools.ts +1 -3
  8. package/extensions/bytefly/footer/collect/snapshot.ts +12 -2
  9. package/extensions/bytefly/footer/config.ts +7 -1
  10. package/extensions/bytefly/footer/delegation.ts +100 -0
  11. package/extensions/bytefly/footer/price/config.ts +54 -15
  12. package/extensions/bytefly/footer/price/price.ts +20 -7
  13. package/extensions/bytefly/footer/price/types.ts +13 -7
  14. package/extensions/bytefly/footer/register.ts +13 -2
  15. package/extensions/bytefly/footer/render/format.ts +3 -2
  16. package/extensions/bytefly/footer/render/lines.ts +2 -0
  17. package/extensions/bytefly/footer/render/segments.ts +30 -2
  18. package/extensions/bytefly/footer/types.ts +12 -0
  19. package/extensions/bytefly/header/component.ts +21 -6
  20. package/extensions/bytefly/header/config.ts +3 -0
  21. package/extensions/bytefly/header/register.ts +34 -3
  22. package/extensions/bytefly/index.ts +15 -4
  23. package/extensions/bytefly/notify/config.ts +2 -0
  24. package/extensions/bytefly/notify/format.ts +19 -1
  25. package/extensions/bytefly/notify/register.ts +37 -13
  26. package/extensions/bytefly/notify/types.ts +2 -0
  27. package/extensions/bytefly/panel/commands.ts +34 -13
  28. package/extensions/bytefly/panel/settings-runtime.ts +71 -28
  29. package/extensions/bytefly/panel/settings.ts +293 -48
  30. package/extensions/bytefly/panel/tui/entries.ts +58 -0
  31. package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
  32. package/extensions/bytefly/panel/tui/frame.ts +71 -0
  33. package/extensions/bytefly/panel/tui/nav.ts +75 -0
  34. package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
  35. package/extensions/bytefly/panel/tui.ts +205 -319
  36. package/extensions/bytefly/picker/runtime.ts +61 -2
  37. package/extensions/bytefly/picker/toggle.ts +56 -0
  38. package/extensions/bytefly/update/cache.ts +73 -0
  39. package/extensions/bytefly/update/check.ts +59 -0
  40. package/extensions/bytefly/update/format.ts +87 -0
  41. package/extensions/bytefly/update/register.ts +158 -0
  42. package/extensions/bytefly/update/state.ts +40 -0
  43. package/extensions/bytefly/update/version.ts +83 -0
  44. package/extensions/document/README.md +24 -14
  45. package/extensions/document/config.ts +4 -0
  46. package/extensions/document/deadline.ts +61 -0
  47. package/extensions/document/tool.ts +14 -5
  48. package/extensions/file-browse/README.md +12 -8
  49. package/extensions/file-browse/client.ts +6 -7
  50. package/extensions/file-browse/files.ts +0 -4
  51. package/extensions/file-browse/git.ts +9 -10
  52. package/extensions/file-browse/html.ts +17 -7
  53. package/extensions/file-browse/router.ts +24 -15
  54. package/extensions/file-open/README.md +30 -15
  55. package/extensions/file-open/index.ts +4 -3
  56. package/extensions/file-open/open.ts +52 -21
  57. package/extensions/goal/README.md +45 -13
  58. package/extensions/goal/clarify-prompt.ts +5 -36
  59. package/extensions/goal/clarify.ts +55 -86
  60. package/extensions/goal/commands.ts +41 -35
  61. package/extensions/goal/format.ts +12 -4
  62. package/extensions/goal/schema.ts +6 -2
  63. package/extensions/goal/tools.ts +28 -8
  64. package/extensions/goal/tui.ts +1 -1
  65. package/extensions/goal/types.ts +6 -2
  66. package/extensions/job/README.md +16 -12
  67. package/extensions/job/runtime.ts +23 -6
  68. package/extensions/job/status.ts +4 -2
  69. package/extensions/memory/README.md +56 -14
  70. package/extensions/memory/commands.ts +20 -2
  71. package/extensions/memory/doctor.ts +137 -0
  72. package/extensions/memory/format.ts +24 -7
  73. package/extensions/memory/runtime.ts +49 -0
  74. package/extensions/memory/store.ts +31 -0
  75. package/extensions/memory/tui.ts +136 -12
  76. package/extensions/plan/README.md +83 -36
  77. package/extensions/plan/blocks.ts +1 -35
  78. package/extensions/plan/commands.ts +84 -19
  79. package/extensions/plan/index.ts +35 -11
  80. package/extensions/plan/plan-index.ts +234 -0
  81. package/extensions/plan/plan-text.ts +95 -0
  82. package/extensions/plan/plans.ts +91 -2
  83. package/extensions/plan/policy.ts +4 -3
  84. package/extensions/plan/review.ts +246 -0
  85. package/extensions/plan/runtime.ts +73 -24
  86. package/extensions/plan/steps.ts +60 -39
  87. package/extensions/plan/todos.ts +29 -60
  88. package/extensions/plan/tools.ts +188 -244
  89. package/extensions/plan/tui.ts +18 -31
  90. package/extensions/plan/types.ts +12 -0
  91. package/extensions/plan/web-client.ts +51 -18
  92. package/extensions/plan/web-page.ts +7 -2
  93. package/extensions/plan/web-router.ts +11 -3
  94. package/extensions/plan/web.ts +106 -41
  95. package/extensions/question/README.md +1 -1
  96. package/extensions/question/tools.ts +1 -1
  97. package/extensions/rewind/README.md +28 -8
  98. package/extensions/rewind/commands.ts +1 -1
  99. package/extensions/rewind/format.ts +4 -4
  100. package/extensions/rewind/git.ts +56 -18
  101. package/extensions/rewind/index.ts +3 -5
  102. package/extensions/rewind/path-picker.ts +198 -0
  103. package/extensions/rewind/paths.ts +128 -0
  104. package/extensions/rewind/restore.ts +69 -28
  105. package/extensions/rewind/rewind.ts +80 -3
  106. package/extensions/rewind/runtime.ts +13 -5
  107. package/extensions/rewind/snapshot-run.ts +5 -2
  108. package/extensions/rewind/types.ts +2 -0
  109. package/extensions/subagent/README.md +7 -1
  110. package/extensions/subagent/accounting.ts +34 -0
  111. package/extensions/subagent/index.ts +23 -3
  112. package/extensions/subagent/stream.ts +2 -0
  113. package/extensions/subagent/types.ts +2 -0
  114. package/extensions/todo/README.md +19 -11
  115. package/extensions/todo/index.ts +5 -3
  116. package/extensions/todo/nudge.ts +58 -9
  117. package/extensions/todo/runtime.ts +28 -17
  118. package/extensions/watch/README.md +8 -3
  119. package/extensions/watch/index.ts +19 -0
  120. package/extensions/web-access/README.md +46 -16
  121. package/extensions/web-access/charset.ts +58 -0
  122. package/extensions/web-access/fetch/find.ts +11 -7
  123. package/extensions/web-access/fetch/format.ts +64 -18
  124. package/extensions/web-access/fetch/page.ts +5 -1
  125. package/extensions/web-access/fetch/tool.ts +20 -21
  126. package/extensions/web-access/http.ts +49 -8
  127. package/extensions/web-access/search/facts.ts +38 -0
  128. package/extensions/web-access/search/tool.ts +14 -3
  129. package/extensions/web-access/text.ts +50 -11
  130. package/extensions/wechat/README.md +5 -4
  131. package/extensions/wechat/tools.ts +0 -2
  132. package/extensions/worktree/README.md +74 -15
  133. package/extensions/worktree/config.ts +11 -0
  134. package/extensions/worktree/exit.ts +35 -3
  135. package/extensions/worktree/gaps.ts +74 -0
  136. package/extensions/worktree/guard.ts +50 -4
  137. package/extensions/worktree/handoff.ts +55 -0
  138. package/extensions/worktree/index.ts +12 -2
  139. package/extensions/worktree/runtime.ts +20 -0
  140. package/extensions/worktree/status.ts +15 -2
  141. package/extensions/worktree/tools.ts +3 -2
  142. package/lib/README.md +12 -9
  143. package/lib/config.ts +43 -1
  144. package/lib/env.ts +12 -0
  145. package/lib/events.ts +86 -1
  146. package/lib/goal-state.ts +95 -0
  147. package/lib/requirements.ts +340 -46
  148. package/lib/todo-state.ts +62 -0
  149. package/lib/tui/README.md +91 -0
  150. package/lib/tui/glyphs.ts +2 -0
  151. package/lib/web/layout.ts +12 -2
  152. package/package.json +5 -3
  153. package/themes/bytefly-dark.json +1 -1
  154. package/assets/bytefly.schema.json +0 -785
  155. package/assets/chrome.png +0 -0
  156. package/assets/file-browse.png +0 -0
  157. package/assets/job.png +0 -0
  158. package/assets/plan-review.png +0 -0
  159. package/assets/question.png +0 -0
  160. package/assets/rewind.png +0 -0
  161. package/assets/todo.png +0 -0
  162. package/assets/tool-renderings.png +0 -0
  163. package/assets/tools/ask_user_question-cancelled.png +0 -0
  164. package/assets/tools/ask_user_question.png +0 -0
  165. package/assets/tools/bash.png +0 -0
  166. package/assets/tools/edit.png +0 -0
  167. package/assets/tools/enter_plan_mode.png +0 -0
  168. package/assets/tools/enter_worktree.png +0 -0
  169. package/assets/tools/exit_plan_mode.png +0 -0
  170. package/assets/tools/exit_worktree.png +0 -0
  171. package/assets/tools/find.png +0 -0
  172. package/assets/tools/goal.png +0 -0
  173. package/assets/tools/grep.png +0 -0
  174. package/assets/tools/job.png +0 -0
  175. package/assets/tools/list_worktrees.png +0 -0
  176. package/assets/tools/ls.png +0 -0
  177. package/assets/tools/memory-long-subject.png +0 -0
  178. package/assets/tools/memory.png +0 -0
  179. package/assets/tools/notice.png +0 -0
  180. package/assets/tools/open_path.png +0 -0
  181. package/assets/tools/prune_worktrees.png +0 -0
  182. package/assets/tools/read.png +0 -0
  183. package/assets/tools/read_doc-running.png +0 -0
  184. package/assets/tools/read_doc.png +0 -0
  185. package/assets/tools/run_doctor.png +0 -0
  186. package/assets/tools/send_wechat.png +0 -0
  187. package/assets/tools/set_plan_steps.png +0 -0
  188. package/assets/tools/subagent.png +0 -0
  189. package/assets/tools/todo.png +0 -0
  190. package/assets/tools/turn-rule.png +0 -0
  191. package/assets/tools/user-message.png +0 -0
  192. package/assets/tools/watch.png +0 -0
  193. package/assets/tools/web_fetch-rejected.png +0 -0
  194. package/assets/tools/web_fetch.png +0 -0
  195. package/assets/tools/web_search.png +0 -0
  196. package/assets/tools/write.png +0 -0
  197. package/assets/tools/write_plan.png +0 -0
@@ -1,24 +1,24 @@
1
1
  /**
2
- * Host orchestration for the goal comprehension step: the nested clarifier
3
- * calls, their cancellable TUI loaders, the in-command questionnaire, and the
4
- * confirm/edit dialog.
2
+ * Host orchestration for the goal comprehension step: the clarifier call, its
3
+ * cancellable TUI loader, the in-command questionnaire, and the confirm/edit
4
+ * dialog.
5
5
  *
6
- * The loop's target is understanding what the user wants. The first call asks
7
- * the model whether it understands the goal and, if not, what it needs to know;
8
- * the command asks those questions through host dialogs; a second call folds the
9
- * answers into the final restatement; the editor lets the user confirm or
10
- * correct it. The flow never throws and never half-applies: a failed,
11
- * timed-out, cancelled, or unusable comprehension falls back to the text the
12
- * user typed, and a dismissed dialog cancels the whole command.
6
+ * The loop's target is understanding what the user wants. One call asks the
7
+ * model whether it understands the goal and, when it cannot yet, what it needs
8
+ * to know; the command asks those questions through host dialogs and carries
9
+ * the answers into the editor, where the user confirms or corrects the goal.
10
+ * The flow never throws and never half-applies: a failed, timed-out, cancelled,
11
+ * or unusable comprehension falls back to the text the user typed, and a
12
+ * dismissed dialog cancels the whole command.
13
13
  */
14
14
 
15
15
  import { BorderedLoader, type ExtensionContext } from "@earendil-works/pi-coding-agent";
16
16
  import type { UserMessage } from "@earendil-works/pi-ai";
17
17
  import { sanitizeLine } from "../../lib/format.ts";
18
18
  import { type Clarification, type ClarifyAnswer, type ClarifyQuestion, parseClarification } from "./clarify-parse.ts";
19
- import { buildClarifyMessages, buildRefineMessages } from "./clarify-prompt.ts";
19
+ import { buildClarifyMessages } from "./clarify-prompt.ts";
20
20
 
21
- /** Bound on each clarifier model call, in milliseconds. */
21
+ /** Bound on the clarifier model call, in milliseconds. */
22
22
  const CLARIFY_TIMEOUT_MS = 30_000;
23
23
 
24
24
  /** The slice of the context the clarify step reads. */
@@ -27,9 +27,6 @@ export type ClarifyContext = Pick<ExtensionContext, "mode" | "ui" | "signal" | "
27
27
  /** Loader text shown while the comprehension call runs. */
28
28
  const LOADER_MESSAGE = "Understanding goal…";
29
29
 
30
- /** Loader text shown while the goal is restated from the answers. */
31
- const REFINE_LOADER_MESSAGE = "Updating the goal…";
32
-
33
30
  /** The confirm/edit dialog title, reading as a confirmation of understanding. */
34
31
  const EDITOR_TITLE = "Confirm the goal — Enter to save, Esc to cancel";
35
32
 
@@ -79,74 +76,54 @@ function assistantText(response: { content?: unknown }): string {
79
76
  return parts.join("\n");
80
77
  }
81
78
 
82
- /** Runs a nested operation, optionally behind the TUI loader. */
83
- type CompleterRun = <T>(fn: (signal: AbortSignal | undefined) => Promise<T>) => Promise<T | undefined>;
84
-
85
- /** The context slice the completer needs. */
86
- type CompleterContext = Pick<ExtensionContext, "model" | "modelRegistry" | "signal">;
87
-
88
79
  /**
89
- * Build one clarifier caller.
80
+ * One clarifier model call, behind the TUI loader.
90
81
  *
91
- * `buildMessages` produces the request; the call runs through `run` (the loader
92
- * wrapper) with an `AbortController` aborted on timeout, on the session signal,
93
- * or when the loader is cancelled. Any throw becomes `undefined`, so a provider
94
- * failure never blocks the command.
82
+ * The call is aborted on timeout, on the session signal, or when the loader is
83
+ * cancelled, and any failure becomes `undefined` so a provider error never
84
+ * blocks the command. Nothing from the conversation is sent: the model must not
85
+ * invent scope the user did not state.
95
86
  */
96
- function createCompleter(
97
- ctx: CompleterContext,
98
- timeoutMs: number,
99
- run: CompleterRun,
100
- buildMessages: () => { systemPrompt: string; messages: UserMessage[] },
101
- ): () => Promise<string | undefined> {
102
- return async () => {
103
- const model = ctx.model;
104
- if (!model) return undefined;
105
-
106
- const { systemPrompt, messages } = buildMessages();
107
- const controller = new AbortController();
108
- const timer = setTimeout(() => controller.abort(), timeoutMs);
109
- const onAbort = () => controller.abort();
110
- ctx.signal?.addEventListener("abort", onAbort, { once: true });
111
- try {
112
- return await run(async (signal) => {
113
- if (signal) signal.addEventListener("abort", () => controller.abort(), { once: true });
114
- const response = await ctx.modelRegistry.complete(
115
- model,
116
- { systemPrompt, messages },
117
- { signal: controller.signal, cacheRetention: "none" },
118
- );
119
- if (response.stopReason === "aborted") return undefined;
120
- return assistantText(response);
121
- });
122
- } catch {
123
- return undefined;
124
- } finally {
125
- clearTimeout(timer);
126
- ctx.signal?.removeEventListener("abort", onAbort);
127
- }
128
- };
129
- }
130
-
131
- /** Run one clarifier call behind its loader and return the raw reply. */
132
- async function completeOnce(
87
+ async function askClarifier(
133
88
  ctx: ClarifyContext,
134
- loaderMessage: string,
135
89
  buildMessages: () => { systemPrompt: string; messages: UserMessage[] },
136
90
  ): Promise<string | undefined> {
137
- return createCompleter(ctx, CLARIFY_TIMEOUT_MS, (run) => withLoader(ctx, loaderMessage, run), buildMessages)();
91
+ const model = ctx.model;
92
+ if (!model) return undefined;
93
+
94
+ const { systemPrompt, messages } = buildMessages();
95
+ const controller = new AbortController();
96
+ const timer = setTimeout(() => controller.abort(), CLARIFY_TIMEOUT_MS);
97
+ const onAbort = () => controller.abort();
98
+ ctx.signal?.addEventListener("abort", onAbort, { once: true });
99
+ try {
100
+ return await withLoader(ctx, LOADER_MESSAGE, async (signal) => {
101
+ if (signal) signal.addEventListener("abort", () => controller.abort(), { once: true });
102
+ const response = await ctx.modelRegistry.complete(
103
+ model,
104
+ { systemPrompt, messages },
105
+ { signal: controller.signal, cacheRetention: "none" },
106
+ );
107
+ if (response.stopReason === "aborted") return undefined;
108
+ return assistantText(response);
109
+ });
110
+ } catch {
111
+ return undefined;
112
+ } finally {
113
+ clearTimeout(timer);
114
+ ctx.signal?.removeEventListener("abort", onAbort);
115
+ }
138
116
  }
139
117
 
140
118
  /**
141
- * One comprehension call on the session model, parsed into a
142
- * {@link Clarification}.
119
+ * The comprehension call, parsed into a {@link Clarification}.
143
120
  *
144
121
  * Returns `undefined` when there is no dialog UI or no model, or when the call
145
122
  * fails, times out, is cancelled, or returns nothing usable.
146
123
  */
147
124
  export async function clarifyGoalPrompt(ctx: ClarifyContext, text: string): Promise<Clarification | undefined> {
148
125
  if (!ctx.hasUI || !ctx.model) return undefined;
149
- return parseClarification(await completeOnce(ctx, LOADER_MESSAGE, () => buildClarifyMessages(text)));
126
+ return parseClarification(await askClarifier(ctx, () => buildClarifyMessages(text)));
150
127
  }
151
128
 
152
129
  /**
@@ -155,7 +132,7 @@ export async function clarifyGoalPrompt(ctx: ClarifyContext, text: string): Prom
155
132
  * A question with options uses a selector; a free-form question uses a text
156
133
  * input. Resolves `undefined` when any dialog is dismissed, so the caller cancels
157
134
  * the whole command; an empty answer is skipped. The answer is sanitized to one
158
- * line, since it is folded into a model message and later the goal.
135
+ * line, since it is folded into the goal the editor holds.
159
136
  */
160
137
  async function askQuestions(
161
138
  ctx: Pick<ClarifyContext, "ui">,
@@ -178,19 +155,15 @@ async function askQuestions(
178
155
  }
179
156
 
180
157
  /**
181
- * Fold the user's answers into the final restatement of the goal.
158
+ * Carry the answers into the editor as notes under the restatement.
182
159
  *
183
- * Returns `undefined` on any failure, so the caller falls back to the first
184
- * restatement.
160
+ * No model call rewrites the goal from the answers, so they are shown verbatim
161
+ * with the question that asked for them: the user folds them into the sentence
162
+ * or deletes them, and whatever the editor holds is what gets recorded.
185
163
  */
186
- async function refineWithAnswers(
187
- ctx: ClarifyContext,
188
- original: string,
189
- goal: string,
190
- answers: readonly ClarifyAnswer[],
191
- ): Promise<string | undefined> {
192
- const reply = await completeOnce(ctx, REFINE_LOADER_MESSAGE, () => buildRefineMessages(original, goal, answers));
193
- return parseClarification(reply)?.goal || undefined;
164
+ function withAnswers(goal: string, answers: readonly ClarifyAnswer[]): string {
165
+ if (answers.length === 0) return goal;
166
+ return [goal, "", ...answers.map((answer) => `Also: ${answer.question} → ${answer.answer}`)].join("\n");
194
167
  }
195
168
 
196
169
  /** Open the confirm/edit dialog, or cancel when it is dismissed. */
@@ -201,7 +174,7 @@ async function editOrCancel(ctx: ClarifyContext, prefill: string): Promise<GoalP
201
174
 
202
175
  /**
203
176
  * Run the understanding loop for `/goal <prompt>`: comprehend, ask what is
204
- * missing, re-comprehend, then let the user confirm or correct the goal.
177
+ * missing, then let the user confirm or correct the goal.
205
178
  *
206
179
  * Without a dialog UI or a model the typed text is used unchanged, so the
207
180
  * command never blocks on the loop in print or JSON mode. A failed comprehension
@@ -224,11 +197,7 @@ export async function prepareGoalPrompt(ctx: ClarifyContext, typed: string): Pro
224
197
  ctx.ui.notify("Goal not set.", "info");
225
198
  return { action: "cancel" };
226
199
  }
227
- if (answers.length > 0) {
228
- const refined = await refineWithAnswers(ctx, typed, proposed, answers);
229
- if (refined) proposed = refined;
230
- else ctx.ui.notify("Could not update the goal from your answers; edit it below.", "info");
231
- }
200
+ proposed = withAnswers(proposed, answers);
232
201
  }
233
202
  return editOrCancel(ctx, proposed);
234
203
  }
@@ -23,23 +23,37 @@ import { GOAL_ENTRY_TYPE, type Goal } from "./types.ts";
23
23
  /** Title of the record-only edit dialog, spelling out both exits. */
24
24
  const EDIT_TITLE = "Goal — Enter to save, Esc to cancel";
25
25
 
26
- /** Completion values for the goal keywords: `set`/`edit`/`raw` take an argument. */
27
- const GOAL_COMPLETIONS = [
28
- { value: "set ", label: "set" },
29
- { value: "edit ", label: "edit" },
30
- { value: "raw ", label: "raw" },
31
- { value: "clear", label: "clear" },
32
- { value: "done", label: "done" },
33
- { value: "achieved", label: "achieved" },
34
- { value: "help", label: "help" },
35
- { value: "-h", label: "-h" },
36
- { value: "--help", label: "--help" },
26
+ /**
27
+ * One reserved `/goal` verb: the word, what a bare occurrence means, and whether
28
+ * it takes the rest of the line as its argument.
29
+ */
30
+ type GoalVerb =
31
+ | { word: string; kind: "usage" | "clear" | "done"; argument: "none" }
32
+ | { word: string; kind: "raw"; argument: "required" }
33
+ | { word: string; kind: "record"; argument: "required" | "optional" };
34
+
35
+ /**
36
+ * The single list behind the parser, the completions, and the help text, so a
37
+ * new verb cannot land in one of them and be missing from another.
38
+ * `set`/`edit` share the `record` kind; the two words are interchangeable.
39
+ */
40
+ export const GOAL_VERBS: readonly GoalVerb[] = [
41
+ { word: "set", kind: "record", argument: "optional" },
42
+ { word: "edit", kind: "record", argument: "optional" },
43
+ { word: "raw", kind: "raw", argument: "required" },
44
+ { word: "clear", kind: "clear", argument: "none" },
45
+ { word: "done", kind: "done", argument: "none" },
46
+ { word: "achieved", kind: "done", argument: "none" },
47
+ { word: "help", kind: "usage", argument: "none" },
48
+ { word: "-h", kind: "usage", argument: "none" },
49
+ { word: "--help", kind: "usage", argument: "none" },
37
50
  ];
38
51
 
39
- /** `/goal set <text>` and `/goal edit <text>`: `text` is optional. */
40
- const RECORD_PATTERN = /^(set|edit)(?:\s+([\s\S]*))?$/i;
41
- /** `/goal raw <text>`: `text` is required. */
42
- const RAW_PATTERN = /^raw(?:\s+([\s\S]*))?$/i;
52
+ /** Completion values: a verb that takes an argument completes with a space. */
53
+ const GOAL_COMPLETIONS = GOAL_VERBS.map((verb) => ({
54
+ value: verb.argument === "none" ? verb.word : `${verb.word} `,
55
+ label: verb.word,
56
+ }));
43
57
 
44
58
  /** One parsed `/goal` invocation. */
45
59
  export type GoalCommand =
@@ -55,30 +69,22 @@ export type GoalCommand =
55
69
  /**
56
70
  * Parse `/goal`'s argument into one command.
57
71
  *
58
- * Keywords match whole words, so `setfoo` and `edit-foo` are free-form prompts,
59
- * not malformed keywords. Only `help`/`clear`/`done`/`achieved` are reserved
60
- * without an argument; anything else is a prompt.
72
+ * Only the first word is looked up, so a keyword matches a whole word: `setfoo`
73
+ * and `edit-foo` are free-form prompts, not malformed keywords, and a verb that
74
+ * takes no argument leaves the whole line a prompt (`clear the cache`). Anything
75
+ * unlisted is a prompt.
61
76
  */
62
77
  export function parseGoalCommand(input: string): GoalCommand {
63
78
  const text = input.trim();
64
79
  if (!text) return { kind: "show" };
65
80
 
66
- const keyword = text.toLowerCase();
67
- if (keyword === "help" || keyword === "-h" || keyword === "--help") return { kind: "usage" };
68
- if (keyword === "clear") return { kind: "clear" };
69
- if (keyword === "done" || keyword === "achieved") return { kind: "done" };
70
-
71
- const record = RECORD_PATTERN.exec(text);
72
- if (record) {
73
- const objective = (record[2] ?? "").trim();
74
- return objective ? { kind: "record", text: objective } : { kind: "editor" };
75
- }
76
- const raw = RAW_PATTERN.exec(text);
77
- if (raw) {
78
- const objective = (raw[1] ?? "").trim();
79
- return objective ? { kind: "raw", text: objective } : { kind: "usage" };
80
- }
81
- return { kind: "prompt", text };
81
+ const head = text.split(/\s+/, 1)[0] ?? "";
82
+ const rest = text.slice(head.length).trim();
83
+ const verb = GOAL_VERBS.find((candidate) => candidate.word === head.toLowerCase());
84
+ if (!verb) return { kind: "prompt", text };
85
+ if (verb.argument === "none") return rest ? { kind: "prompt", text } : { kind: verb.kind };
86
+ if (verb.kind === "raw") return rest ? { kind: "raw", text: rest } : { kind: "usage" };
87
+ return rest ? { kind: "record", text: rest } : { kind: "editor" };
82
88
  }
83
89
 
84
90
  /**
@@ -87,7 +93,7 @@ export function parseGoalCommand(input: string): GoalCommand {
87
93
  * once the prefix contains a space (or the start of the prompt does not look
88
94
  * like a keyword) the argument is left to the user.
89
95
  */
90
- function completeGoalArg(prefix: string): Array<{ value: string; label: string }> | null {
96
+ export function completeGoalArg(prefix: string): Array<{ value: string; label: string }> | null {
91
97
  const text = prefix.trimStart();
92
98
  if (text.includes(" ")) return null;
93
99
  const items = GOAL_COMPLETIONS.filter((item) => item.label.startsWith(text));
@@ -51,10 +51,18 @@ export function goalObjective(goal: Goal, theme: Theme): string {
51
51
  return goal.status === "achieved" ? paint(theme, "faint", goal.objective) : paint(theme, "label", goal.objective);
52
52
  }
53
53
 
54
- /** Model-facing result text. */
55
- export function formatGoalText(goal: Goal | null): string {
54
+ /**
55
+ * Model-facing result text. `openTodos` counts the todo items the branch still
56
+ * has open, so marking a goal achieved can point at work the list still holds.
57
+ */
58
+ export function formatGoalText(goal: Goal | null, openTodos = 0): string {
56
59
  if (goal === null) return "Goal cleared.";
57
- if (goal.status === "achieved") return `Goal achieved: ${goal.objective}`;
60
+ if (goal.status === "achieved") {
61
+ const text = `Goal achieved: ${goal.objective}`;
62
+ if (openTodos <= 0) return text;
63
+ const items = openTodos === 1 ? "1 todo item is" : `${openTodos} todo items are`;
64
+ return `${text}\n${items} still open; close or clear the list if that work is done.`;
65
+ }
58
66
  return `Goal: ${goal.objective}\nKeep this objective in mind as you work, and mark it achieved with the goal tool when it is done.`;
59
67
  }
60
68
 
@@ -72,7 +80,7 @@ export const GOAL_HELP = [
72
80
  "Set a goal with /goal <what you want to accomplish> and start working on it.",
73
81
  "/goal raw <prompt> records your words verbatim, without confirming them first.",
74
82
  "/goal set <prompt> records the goal without starting work; /goal edit [<prompt>] changes it.",
75
- "/goal done · /goal clear · /goal help",
83
+ "/goal done (or achieved) · /goal clear · /goal help (or -h / --help)",
76
84
  ].join("\n");
77
85
 
78
86
  /** Truncate an objective to `max` display columns, appending `…` when cut. */
@@ -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".',
@@ -11,6 +11,7 @@ import type { JsonValue } from "@earendil-works/pi-ai";
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  import { sanitizeLine } from "../../lib/format.ts";
13
13
  import { GOAL_TOOL } from "../../lib/tool-names.ts";
14
+ import { lastTodos } from "../../lib/todo-state.ts";
14
15
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
15
16
  import { rejectionLine, reuseCallText, toolCallTitle } from "../../lib/tui/tool.ts";
16
17
  import { paint } from "../../lib/tui/tokens.ts";
@@ -18,7 +19,7 @@ import { GLYPHS } from "../../lib/tui/glyphs.ts";
18
19
  import { formatCallText, formatGoalText } from "./format.ts";
19
20
  import type { GoalRuntime } from "./runtime.ts";
20
21
  import { GoalParams, GoalResult, normalizeGoal, type GoalArgs } from "./schema.ts";
21
- import { GoalResult as GoalResultView } from "./tui.ts";
22
+ import { GoalHeader } from "./tui.ts";
22
23
  import type { GoalAction, GoalDetails } from "./types.ts";
23
24
 
24
25
  export const TOOL_NAME = GOAL_TOOL;
@@ -45,13 +46,30 @@ function toStructuredContent(details: GoalDetails): Record<string, JsonValue> {
45
46
  return content;
46
47
  }
47
48
 
49
+ /**
50
+ * How many items the branch's todo list still has open.
51
+ *
52
+ * `todo` is the *how* to the goal's *what*, so completing one while the other
53
+ * still holds work is worth saying out loud. The list is read from the branch
54
+ * (`lib/todo-state.ts`), so no todo module is imported.
55
+ */
56
+ function openTodoCount(entries: Iterable<unknown>): number {
57
+ return lastTodos(entries).filter((todo) => todo.status !== "completed").length;
58
+ }
59
+
48
60
  /**
49
61
  * Build the tool result for one call. A rejected call (`error` set) carries the
50
62
  * unchanged goal so the model can retry, and is marked `isError`.
51
63
  */
52
- function goalResult(details: GoalDetails, error?: string) {
64
+ function goalResult(details: GoalDetails, options: { error?: string; openTodos?: number } = {}) {
65
+ const { error, openTodos = 0 } = options;
53
66
  return {
54
- content: [{ type: "text" as const, text: error === undefined ? formatGoalText(details.goal) : `Error: ${error}` }],
67
+ content: [
68
+ {
69
+ type: "text" as const,
70
+ text: error === undefined ? formatGoalText(details.goal, openTodos) : `Error: ${error}`,
71
+ },
72
+ ],
55
73
  details,
56
74
  structuredContent: toStructuredContent(details),
57
75
  ...(error === undefined ? {} : { isError: true as const }),
@@ -82,10 +100,14 @@ export function registerTools(pi: ExtensionAPI, runtime: GoalRuntime): void {
82
100
  const goal = normalizeGoal(args);
83
101
  const action = goal === null ? "clear" : goal.status === "achieved" ? "achieve" : "set";
84
102
  runtime.setGoal(goal, ctx);
85
- return goalResult({ goal, action });
103
+ const openTodos = action === "achieve" ? openTodoCount(ctx.sessionManager.getBranch()) : 0;
104
+ return goalResult({ goal, action }, { openTodos });
86
105
  } catch (error) {
87
106
  const message = error instanceof Error ? error.message : String(error);
88
- return goalResult({ goal: runtime.getGoal(), action: attemptedAction(args), error: message }, message);
107
+ return goalResult(
108
+ { goal: runtime.getGoal(), action: attemptedAction(args), error: message },
109
+ { error: message },
110
+ );
89
111
  }
90
112
  },
91
113
 
@@ -115,9 +137,7 @@ export function registerTools(pi: ExtensionAPI, runtime: GoalRuntime): void {
115
137
  return text;
116
138
  }
117
139
  const view =
118
- context.lastComponent instanceof GoalResultView
119
- ? context.lastComponent
120
- : new GoalResultView(details.goal, theme);
140
+ context.lastComponent instanceof GoalHeader ? context.lastComponent : new GoalHeader(details.goal, theme);
121
141
  view.update(details.goal, theme);
122
142
  return view;
123
143
  },
@@ -44,7 +44,7 @@ export class GoalWidget implements Component {
44
44
  }
45
45
 
46
46
  /** Transcript result block: the status header, separated from the call line. */
47
- export class GoalResult implements Component {
47
+ export class GoalHeader implements Component {
48
48
  private goal: Goal;
49
49
  private theme: Theme;
50
50
 
@@ -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";