@gavin-hu/my-pi-agent 0.11.4 → 0.12.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 (81) hide show
  1. package/README.md +13 -8
  2. package/README.zh-CN.md +5 -3
  3. package/assets/chrome.png +0 -0
  4. package/assets/plan-review.png +0 -0
  5. package/extensions/bytefly/README.md +291 -207
  6. package/extensions/bytefly/doctor/runtime.ts +16 -0
  7. package/extensions/bytefly/doctor/tools.ts +1 -3
  8. package/extensions/bytefly/footer/price/config.ts +54 -15
  9. package/extensions/bytefly/footer/price/price.ts +20 -7
  10. package/extensions/bytefly/footer/price/types.ts +13 -7
  11. package/extensions/bytefly/footer/render/format.ts +3 -2
  12. package/extensions/bytefly/footer/render/segments.ts +3 -2
  13. package/extensions/bytefly/footer/types.ts +1 -0
  14. package/extensions/bytefly/header/component.ts +21 -6
  15. package/extensions/bytefly/header/config.ts +3 -0
  16. package/extensions/bytefly/header/register.ts +34 -3
  17. package/extensions/bytefly/index.ts +13 -3
  18. package/extensions/bytefly/panel/settings-runtime.ts +71 -28
  19. package/extensions/bytefly/panel/settings.ts +201 -38
  20. package/extensions/bytefly/panel/tui/entries.ts +58 -0
  21. package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
  22. package/extensions/bytefly/panel/tui/frame.ts +71 -0
  23. package/extensions/bytefly/panel/tui/nav.ts +75 -0
  24. package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
  25. package/extensions/bytefly/panel/tui.ts +205 -319
  26. package/extensions/bytefly/picker/runtime.ts +61 -2
  27. package/extensions/bytefly/picker/toggle.ts +56 -0
  28. package/extensions/bytefly/update/check.ts +59 -0
  29. package/extensions/bytefly/update/format.ts +32 -0
  30. package/extensions/bytefly/update/register.ts +72 -0
  31. package/extensions/bytefly/update/state.ts +28 -0
  32. package/extensions/bytefly/update/version.ts +83 -0
  33. package/extensions/document/README.md +2 -2
  34. package/extensions/document/tool.ts +1 -3
  35. package/extensions/file-open/README.md +30 -15
  36. package/extensions/file-open/index.ts +4 -3
  37. package/extensions/file-open/open.ts +52 -21
  38. package/extensions/goal/README.md +39 -13
  39. package/extensions/goal/clarify-prompt.ts +5 -36
  40. package/extensions/goal/clarify.ts +55 -86
  41. package/extensions/goal/commands.ts +41 -35
  42. package/extensions/goal/format.ts +12 -4
  43. package/extensions/goal/tools.ts +28 -8
  44. package/extensions/goal/tui.ts +1 -1
  45. package/extensions/memory/README.md +1 -1
  46. package/extensions/plan/README.md +24 -8
  47. package/extensions/plan/blocks.ts +1 -35
  48. package/extensions/plan/commands.ts +18 -12
  49. package/extensions/plan/plans.ts +1 -1
  50. package/extensions/plan/policy.ts +4 -3
  51. package/extensions/plan/review.ts +243 -0
  52. package/extensions/plan/runtime.ts +4 -0
  53. package/extensions/plan/steps.ts +34 -23
  54. package/extensions/plan/todos.ts +5 -58
  55. package/extensions/plan/tools.ts +66 -222
  56. package/extensions/plan/tui.ts +17 -9
  57. package/extensions/plan/types.ts +3 -0
  58. package/extensions/plan/web-client.ts +51 -18
  59. package/extensions/plan/web-page.ts +5 -0
  60. package/extensions/plan/web-router.ts +11 -3
  61. package/extensions/plan/web.ts +100 -36
  62. package/extensions/question/README.md +1 -1
  63. package/extensions/question/tools.ts +1 -1
  64. package/extensions/subagent/index.ts +0 -2
  65. package/extensions/todo/README.md +19 -11
  66. package/extensions/todo/index.ts +5 -3
  67. package/extensions/todo/nudge.ts +58 -9
  68. package/extensions/todo/runtime.ts +28 -17
  69. package/extensions/web-access/README.md +3 -3
  70. package/extensions/web-access/fetch/tool.ts +1 -3
  71. package/extensions/web-access/search/tool.ts +1 -3
  72. package/extensions/wechat/README.md +5 -4
  73. package/extensions/wechat/tools.ts +0 -2
  74. package/lib/README.md +2 -1
  75. package/lib/config.ts +43 -1
  76. package/lib/env.ts +12 -0
  77. package/lib/requirements.ts +76 -2
  78. package/lib/todo-state.ts +62 -0
  79. package/lib/tui/glyphs.ts +1 -0
  80. package/package.json +1 -1
  81. package/assets/bytefly.schema.json +0 -785
@@ -18,12 +18,15 @@ import { TODO_TOOL } from "../../lib/tool-names.ts";
18
18
  import { currentTodo, hasOpenTodos } from "./state.ts";
19
19
  import type { Todo } from "./types.ts";
20
20
 
21
+ /** Tools that run a command, where reading and writing look the same by name. */
22
+ const SHELL_TOOL_NAMES: ReadonlySet<string> = new Set(["bash", "powershell"]);
23
+
21
24
  /**
22
25
  * Tools whose calls mean the working tree may have changed: the built-in
23
- * writers and shells. Kept explicit so a reader or an MCP tool that does not
26
+ * writers and the shells. Kept explicit so a reader or an MCP tool that does not
24
27
  * declare a hint cannot force a continuation.
25
28
  */
26
- export const MUTATING_TOOL_NAMES: ReadonlySet<string> = new Set(["write", "edit", "bash", "powershell"]);
29
+ export const MUTATING_TOOL_NAMES: ReadonlySet<string> = new Set(["write", "edit", ...SHELL_TOOL_NAMES]);
27
30
 
28
31
  /**
29
32
  * Whether a finished tool call counts as work for lag detection. The `todo`
@@ -37,22 +40,68 @@ export function isMutatingTool(toolName: string, annotations?: ToolAnnotations):
37
40
  }
38
41
 
39
42
  /**
40
- * Whether a reminder is due: work happened since the last update, no reminder
41
- * was sent for it yet, and the list still has an unfinished item.
43
+ * Whether a finished tool call can have changed files, as opposed to a shell
44
+ * command that may only have read them. The reminder for a run that never
45
+ * started a list needs this: three `rg` calls are investigation, not unplanned
46
+ * work, and a shell call cannot be told apart from one that wrote. So it is a
47
+ * mutating call that is not a shell.
48
+ */
49
+ export function isFileWrite(toolName: string, annotations?: ToolAnnotations): boolean {
50
+ if (toolName === TODO_TOOL || SHELL_TOOL_NAMES.has(toolName)) return false;
51
+ return isMutatingTool(toolName, annotations);
52
+ }
53
+
54
+ /**
55
+ * Successful mutating calls needed before a run with no list at all is worth a
56
+ * reminder. A plan that was never started has no item to fall behind, and a
57
+ * false positive costs a whole extra model request, so one edit is not enough
58
+ * evidence that the work needed a list.
59
+ */
60
+ export const EMPTY_LIST_MUTATIONS = 3;
61
+
62
+ /** What the current turn knows about its work and the list. */
63
+ export interface NudgeState {
64
+ /** Successful mutating calls since the turn began or the list was last written. */
65
+ mutations: number;
66
+ /** How many of those can change files, as opposed to a shell command that may have read. */
67
+ writes: number;
68
+ /** Whether the model wrote or cleared the list since the turn began. */
69
+ touched: boolean;
70
+ /** Whether a reminder was already sent this turn. */
71
+ nudged: boolean;
72
+ /** The current list. */
73
+ todos: Todo[];
74
+ }
75
+
76
+ /**
77
+ * Whether a reminder is due. With an unfinished item, one mutation is enough.
78
+ * With no list at all, the turn must also have written something: repeated
79
+ * inspection with no file change is not unplanned work. A list the model wrote
80
+ * or cleared this turn is its own answer, so an explicit clear is never
81
+ * second-guessed.
42
82
  */
43
- export function shouldNudge(dirty: boolean, nudged: boolean, todos: Todo[]): boolean {
44
- return dirty && !nudged && hasOpenTodos(todos);
83
+ export function shouldNudge({ mutations, writes, touched, nudged, todos }: NudgeState): boolean {
84
+ if (nudged || mutations <= 0) return false;
85
+ if (hasOpenTodos(todos)) return true;
86
+ return todos.length === 0 && !touched && writes > 0 && mutations >= EMPTY_LIST_MUTATIONS;
45
87
  }
46
88
 
47
89
  /** Marker on the injected reminder, so it is recognizable and filterable. */
48
90
  export const TODO_NUDGE_MARKER = "[TODO SYNC]";
49
91
 
50
92
  /**
51
- * Model-facing reminder injected before settlement. Points at the item the list
52
- * currently considers active, so the model can correct it without rereading the
53
- * whole list.
93
+ * Model-facing reminder injected before settlement. For a list with unfinished
94
+ * work it points at the item the list currently considers active, so the model
95
+ * can correct it without rereading the whole list; for no list at all it asks
96
+ * for one.
54
97
  */
55
98
  export function formatNudge(todos: Todo[]): string {
99
+ if (todos.length === 0) {
100
+ return (
101
+ `${TODO_NUDGE_MARKER} Work happened but no todo list was recorded. ` +
102
+ "Call the todo tool with the steps of this work, then continue."
103
+ );
104
+ }
56
105
  const current = currentTodo(todos);
57
106
  const item = current ? (current.activeForm ?? current.content) : undefined;
58
107
  const pointer = item ? ` The list still points at "${item}".` : "";
@@ -4,10 +4,10 @@
4
4
  *
5
5
  * The list is reconstructed from the active branch on session start and tree
6
6
  * navigation, and the persistent widget mirrors it whenever it changes. Only
7
- * interactive (`tui`) sessions get a widget; the widget's row budget and
8
- * whether a finished list stays visible come from the todo config. The same
9
- * runtime carries the lag-tracking flags the `agent_before_settle` reminder
10
- * reads, so they reset with the session and the branch.
7
+ * interactive (`tui`) sessions get a widget; whether a finished list stays
8
+ * visible comes from the todo config. The same runtime carries the lag-tracking
9
+ * counters the `agent_before_settle` reminder reads, so they reset with the
10
+ * session and the branch.
11
11
  */
12
12
 
13
13
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -27,8 +27,8 @@ export interface TodoRuntime {
27
27
  reconstruct(ctx: ExtensionContext): void;
28
28
  /** Apply the widget config (loaded per session). */
29
29
  setConfig(config: TodoConfig): void;
30
- /** Record that a mutating tool ran since the last update. */
31
- noteWork(): void;
30
+ /** Record a successful mutating call; `fileWrite` marks one that can change files. */
31
+ noteWork(fileWrite?: boolean): void;
32
32
  /** Whether a lag reminder is due and has not been sent for it yet. */
33
33
  nudgeDue(): boolean;
34
34
  /** Mark the current lag as reminded, activating the injected reminder. */
@@ -53,11 +53,15 @@ export function createTodoRuntime(pi?: Pick<ExtensionAPI, "events">): TodoRuntim
53
53
  let lastTuiCtx: ExtensionContext | undefined;
54
54
  // True while a dock screen owns the editor slot; the rail stays hidden.
55
55
  let suppressed = false;
56
- // Lag tracking: `dirty` is set when a mutating tool runs and cleared by a
57
- // todo update, `nudged` caps the reminder to one per user turn, and
58
- // `nudgeActive` marks an injected reminder as current so `context` keeps it
59
- // for the continuation and drops it on the next user turn.
60
- let dirty = false;
56
+ // Lag tracking: `mutations` counts successful mutating calls since the list
57
+ // was last written and `writes` how many of those can change files,
58
+ // `touched` records that the model wrote or cleared the list this turn (so an
59
+ // explicit clear is not second-guessed), `nudged` caps the reminder to one per
60
+ // user turn, and `nudgeActive` marks an injected reminder as current so
61
+ // `context` keeps it for the continuation and drops it on the next user turn.
62
+ let mutations = 0;
63
+ let writes = 0;
64
+ let touched = false;
61
65
  let nudged = false;
62
66
  let nudgeActive = false;
63
67
  // Screens watching the list (for example the open `/todos` screen) so they
@@ -104,13 +108,17 @@ export function createTodoRuntime(pi?: Pick<ExtensionAPI, "events">): TodoRuntim
104
108
  todos = next;
105
109
  // A todo update is the model syncing the list, so the lag is cleared.
106
110
  // `nudged` is not: the reminder stays capped at one per user turn.
107
- dirty = false;
111
+ mutations = 0;
112
+ writes = 0;
113
+ touched = true;
108
114
  syncWidget(ctx);
109
115
  notify();
110
116
  },
111
117
  reconstruct: (ctx) => {
112
118
  todos = reconstructTodos(ctx.sessionManager.getBranch());
113
- dirty = false;
119
+ mutations = 0;
120
+ writes = 0;
121
+ touched = false;
114
122
  nudged = false;
115
123
  nudgeActive = false;
116
124
  syncWidget(ctx);
@@ -119,10 +127,11 @@ export function createTodoRuntime(pi?: Pick<ExtensionAPI, "events">): TodoRuntim
119
127
  setConfig: (next) => {
120
128
  config = next;
121
129
  },
122
- noteWork: () => {
123
- dirty = true;
130
+ noteWork: (fileWrite = false) => {
131
+ mutations++;
132
+ if (fileWrite) writes++;
124
133
  },
125
- nudgeDue: () => shouldNudge(dirty, nudged, todos),
134
+ nudgeDue: () => shouldNudge({ mutations, writes, touched, nudged, todos }),
126
135
  markNudged: () => {
127
136
  nudged = true;
128
137
  nudgeActive = true;
@@ -132,7 +141,9 @@ export function createTodoRuntime(pi?: Pick<ExtensionAPI, "events">): TodoRuntim
132
141
  },
133
142
  isNudgeActive: () => nudgeActive,
134
143
  resetNudge: () => {
135
- dirty = false;
144
+ mutations = 0;
145
+ writes = 0;
146
+ touched = false;
136
147
  nudged = false;
137
148
  },
138
149
  onChange: (listener) => {
@@ -96,7 +96,7 @@ and unknown keys are ignored.
96
96
  {
97
97
  "search": {
98
98
  "provider": "auto", // "auto" | "duckduckgo" | "searxng" | "brave"
99
- "endpoint": "http://localhost:8080", // SearXNG base URL; "" means SearXNG is unavailable
99
+ "endpoint": "", // SearXNG base URL, e.g. "http://localhost:8080"; "" means SearXNG is unavailable
100
100
  "language": "auto", // SearXNG: "auto" (zh-CN for Han, else en) | language code
101
101
  "safeSearch": 0, // SearXNG: 0 off, 1 moderate, 2 strict
102
102
  "categories": "general", // SearXNG categories
@@ -264,9 +264,9 @@ the content type and size.
264
264
 
265
265
  | Integration point | Value |
266
266
  |---|---|
267
- | Tools | `web_search`, `web_fetch`; `exposure: "direct"`, both `defaultActive: true` |
267
+ | Tools | `web_search`, `web_fetch`; direct exposure, active on registration (the defaults, so not declared) |
268
268
  | Execution mode | `web_search`: `sequential`; `web_fetch`: default |
269
- | Annotations | both `readOnlyHint: true`, `openWorldHint: true`, `destructiveHint: false` |
269
+ | Annotations | both `readOnlyHint: true`, `openWorldHint: true`; a read-only tool declares no `destructiveHint` |
270
270
  | Output | `outputSchema` (`WebSearchOutput` / `WebFetchOutput`) plus matching `structuredContent` |
271
271
  | State | none in the session branch; one fetch page cache per registration (`web_search` keeps a per-instance throttle) |
272
272
  | Lifecycle | the factory only registers (`index.ts`); `registerFetchTool` owns the page cache and clears it on `session_start` and idempotent `session_shutdown` |
@@ -191,9 +191,7 @@ export function registerFetchTool(pi: ExtensionAPI, deps: FetchToolDeps = {}): v
191
191
  ],
192
192
  parameters: WebFetchParams,
193
193
  outputSchema: WebFetchOutput,
194
- exposure: "direct",
195
- defaultActive: true,
196
- annotations: { readOnlyHint: true, openWorldHint: true, destructiveHint: false },
194
+ annotations: { readOnlyHint: true, openWorldHint: true },
197
195
  // The page cache is shared in-memory state created per registration, so
198
196
  // sibling calls must not run concurrently (matches web_search's throttle).
199
197
  executionMode: "sequential",
@@ -46,9 +46,7 @@ export function registerSearchTool(pi: ExtensionAPI, deps: SearchDeps = {}): voi
46
46
  ],
47
47
  parameters: WebSearchParams,
48
48
  outputSchema: WebSearchOutput,
49
- exposure: "direct",
50
- defaultActive: true,
51
- annotations: { readOnlyHint: true, openWorldHint: true, destructiveHint: false },
49
+ annotations: { readOnlyHint: true, openWorldHint: true },
52
50
  executionMode: "sequential",
53
51
 
54
52
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
@@ -272,7 +272,7 @@ overridden by the project `<cwd>/.pi/bytefly.json` (project wins):
272
272
  "maxInboundChars": 8000, // inbound text longer than this is truncated
273
273
  "typingIndicator": true, // show typing while a turn is generated
274
274
  "remotePrompts": true, // answer interactive dialogs on WeChat during a WeChat turn
275
- "pollHoldMs": 0 // experimental: ask the server for a shorter long-poll hold
275
+ // "pollHoldMs": 3000 // experimental: request a shorter long-poll hold; unset by default
276
276
  }
277
277
  ```
278
278
 
@@ -392,8 +392,9 @@ never touches the stored cursor.
392
392
  supplies a `context_token` only then).
393
393
  - Slash commands are fixed built-ins; there is no user-defined snippet or action
394
394
  list yet, and `/new`, `/stop`, `/status`, and `/help` are registered.
395
- - There is no pause. The bridge runs whenever an account is stored, and
396
- `/wechat logout` is the only way to stop it (and it discards the credentials).
395
+ - Pause is runtime-local. `/wechat pause` stops polling and releases the lock,
396
+ but a reload, `/new`, or restart builds a fresh running bridge; only
397
+ `/wechat logout` stops it persistently (and discards the credentials).
397
398
 
398
399
  ## Non-goals
399
400
 
@@ -406,7 +407,7 @@ sessions are in-process).
406
407
  | Integration point | Detail |
407
408
  |---|---|
408
409
  | Command | `pi.registerCommand("wechat", …)` — `login`, `new`, `status`, `pause`, `resume`, `doctor`, `stop <peer>`, `reset <peer>`, `history <peer> [n]`, `allow [peer]`, `logout`, `help`; bare `/wechat` opens the control screen. |
409
- | Tools | `send_wechat` — owner-only proactive text; registered at factory time with `exposure: "direct"`, `executionMode: "sequential"`, `openWorldHint: true`. |
410
+ | Tools | `send_wechat` — owner-only proactive text; registered at factory time with direct exposure (the default), `executionMode: "sequential"`, `openWorldHint: true`. |
410
411
  | Events | `session_start` (record the live context, auto-start the bridge when an account exists); `session_shutdown` (idempotent stop). No host run events: peer turns are not the host session's runs. |
411
412
  | Sessions | `openPeerSession` creates one SDK `AgentSession` per peer (`createAgentSession`, `SessionManager.continueRecent` under `<agent-dir>/wechat/sessions/<peer>/`), inheriting the host's model and thinking level. `DefaultResourceLoader` standard discovery minus this bridge (`withoutWechatExtensions`), so a peer session cannot start a second poller, plus a short channel contract appended to the system prompt so the model knows it is answering in a phone chat window. When `peers[<id>].tools` is set, only those names stay active. |
412
413
  | Turn ownership | `peer-worker.ts`: a serial queue per peer, `session.prompt()`/`session.abort()`, `getLastAssistantText()` for the answer, and one idle sweep per session. `concurrency` defaults to 1 so a forwarded dialog has one owner. |
@@ -68,8 +68,6 @@ export function registerTools(pi: ExtensionAPI, deps: ToolDeps): void {
68
68
  ],
69
69
  parameters: WechatSendParams,
70
70
  outputSchema: WechatSendResult,
71
- exposure: "direct",
72
- defaultActive: true,
73
71
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
74
72
  executionMode: "sequential",
75
73
 
package/lib/README.md CHANGED
@@ -16,7 +16,7 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
16
16
  | Module | Responsibility |
17
17
  |---|---|
18
18
  | [`browser.ts`](./browser.ts) | Cross-platform default-browser opener shared by `file-browse` and `plan`: pure `browserCommand(url, platform)` plus best-effort `openInBrowser(url, { platform, spawn })` with an injectable spawn. |
19
- | [`config.ts`](./config.ts) | Config loading, two ways: `loadConfigFile` for a whole file, and the shared `bytefly.json` (`SHARED_CONFIG_FILE`) via `configSection` / `loadConfigSection` (global, then project), plus the atomic `writeConfigFile` / `writeConfigSection`. Includes `clampInteger` / `clampNumber` / `cleanString` / `asRecord` / `readJson` / `readJsonResult`. |
19
+ | [`config.ts`](./config.ts) | Config loading, two ways: `loadConfigFile` for a whole file, and the shared `bytefly.json` (`SHARED_CONFIG_FILE`) via `configSection` / `configValueAt` / `loadConfigSection` (global, then project), plus the atomic `writeConfigFile` / `writeConfigSection` / `writeConfigValue` / `writeConfigValueAt` (the last two edit one leaf, flat or nested, preserving siblings and metadata). Includes `clampInteger` / `clampNumber` / `cleanString` / `asRecord` / `readJson` / `readJsonResult`. |
20
20
  | [`env.ts`](./env.ts) | Env-var contracts shared across extensions: the worktree environment (`ENV_ROOT`, `worktreeRoot`, `resolveEffectiveCwd`) used by worktree, file-browse, job, and rewind, plus `isExtensionEnabled` / `ENV_DISABLED_EXTENSIONS` for the package-wide disable list. |
21
21
  | [`escape.ts`](./escape.ts) | `escapeHtml`: the single HTML text/attribute escape used by the browse page renderers and the Markdown and syntax highlighters. |
22
22
  | [`events.ts`](./events.ts) | Inter-extension `pi.events` channels and payloads: `JOB_FINISHED_CHANNEL` and `JobFinishedEvent`, published by `job` and consumed by bytefly's notify capability. |
@@ -32,6 +32,7 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
32
32
  | [`rails.ts`](./rails.ts) | Above-editor widget ordering (`goal` → `todo`) and dock-screen suppression, carried on `pi.events`. |
33
33
  | [`requirements.ts`](./requirements.ts) | Per-extension runtime prerequisite table plus a pure `probeExtension(seams)` over injected seams (exec, config reads, optional imports): bytefly's doctor surface reports on it and the extensions that own a dependency format their errors from it. |
34
34
  | [`shell.ts`](./shell.ts) | Shell escape rule shared by the worktree isolation guard and plan mode's read-only git guard: `SHELL_ESCAPABLE`, `isShellEscapable`, `hasShellEscape`. |
35
+ | [`todo-state.ts`](./todo-state.ts) | The published `todo` state replayed from the session branch: `ExistingTodo` and `lastTodos(entries)` (the last valid list, skipping a rejected call), so `plan` can merge an approved plan's steps into it and `goal` can name the items still open when a goal is marked achieved — with no extension importing another's modules. |
35
36
  | [`tool-names.ts`](./tool-names.ts) | Tool names and one parameter name that more than one extension must agree on, so an orchestrating `ctx.executeTool()` call or schema probe breaks the build on a rename. |
36
37
  | [`web/`](./web/) | The Bytefly web brand system shared by the browser surfaces: [`web/tokens.ts`](./web/tokens.ts) (palette derived from the Bytefly terminal themes, scales, and the `tok-*` map), [`web/base.ts`](./web/base.ts), [`web/components.ts`](./web/components.ts), [`web/layout.ts`](./web/layout.ts), [`web/runtime.ts`](./web/runtime.ts) (`WEB_THEME_STORAGE_KEY`, `THEME_JS`, `THEME_TOGGLE_JS`), [`web/markup.ts`](./web/markup.ts), and the [`web/index.ts`](./web/index.ts) barrel. |
37
38
 
package/lib/config.ts CHANGED
@@ -139,6 +139,22 @@ export function configSection(
139
139
  return asRecord(raw?.[key]);
140
140
  }
141
141
 
142
+ /**
143
+ * The value at `path` in a document or section, or `undefined` when a step is
144
+ * absent or not an object. A one-element path reads a top-level key, so this
145
+ * also answers "is this setting stated here" for the flat case. `raw` may be an
146
+ * untrusted JSON value or an already-normalized config object.
147
+ */
148
+ export function configValueAt(raw: unknown, path: readonly string[]): unknown {
149
+ let current: unknown = raw;
150
+ for (const key of path) {
151
+ const record = asRecord(current);
152
+ if (!record) return undefined;
153
+ current = record[key];
154
+ }
155
+ return current;
156
+ }
157
+
142
158
  /** Options for {@link loadConfigSection}. */
143
159
  export interface SectionOptions {
144
160
  /** Agent directory override; `worktree` resolves it explicitly. */
@@ -197,12 +213,38 @@ function readEditableDocument(path: string): Record<string, unknown> {
197
213
  * malformed document.
198
214
  */
199
215
  export function writeConfigValue(path: string, section: string, key: string, value: unknown): void {
216
+ writeConfigValueAt(path, section, [key], value);
217
+ }
218
+
219
+ /**
220
+ * Set the leaf at `keyPath` in one top-level section, with the same guarantees
221
+ * as {@link writeConfigValue}: siblings, other sections, and metadata survive,
222
+ * and the file and the section are created when absent. Intermediate objects
223
+ * are created as needed; a non-object in the way is replaced, because a refused
224
+ * edit would leave a hand-written typo unfixable from the screen. Refuses a
225
+ * malformed document and an empty path.
226
+ */
227
+ export function writeConfigValueAt(path: string, section: string, keyPath: readonly string[], value: unknown): void {
228
+ if (keyPath.length === 0) throw new Error(`Cannot update ${path}: empty key path`);
200
229
  const document = readEditableDocument(path);
201
230
  const current = configSection(document, section) ?? {};
202
- document[section] = { ...current, [key]: value };
231
+ document[section] = setPath(current, keyPath, value);
203
232
  writeConfigFile(path, document);
204
233
  }
205
234
 
235
+ /** Copy `source` with the leaf at `path` replaced; every sibling key is kept. */
236
+ function setPath(source: Record<string, unknown>, path: readonly string[], value: unknown): Record<string, unknown> {
237
+ const [head] = path;
238
+ const next = { ...source };
239
+ if (head === undefined) return next;
240
+ if (path.length === 1) {
241
+ next[head] = value;
242
+ return next;
243
+ }
244
+ next[head] = setPath(asRecord(source[head]) ?? {}, path.slice(1), value);
245
+ return next;
246
+ }
247
+
206
248
  /**
207
249
  * Delete one key from one top-level section, preserving its siblings and the
208
250
  * rest of the document. An emptied section is removed rather than left as `{}`.
package/lib/env.ts CHANGED
@@ -16,6 +16,9 @@ export const ENV_ROOT = "PI_WORKTREE_ROOT";
16
16
  */
17
17
  export const ENV_DISABLED_EXTENSIONS = "PI_DISABLED_EXTENSIONS";
18
18
 
19
+ /** Env var that turns off Pi's automatic network activity, including our own. */
20
+ export const ENV_OFFLINE = "PI_OFFLINE";
21
+
19
22
  /** False when `name` appears in `PI_DISABLED_EXTENSIONS`, true otherwise. */
20
23
  export function isExtensionEnabled(name: string): boolean {
21
24
  const raw = process.env[ENV_DISABLED_EXTENSIONS];
@@ -29,6 +32,15 @@ export function isExtensionEnabled(name: string): boolean {
29
32
  return !disabled.has(name.toLowerCase());
30
33
  }
31
34
 
35
+ /**
36
+ * Whether `PI_OFFLINE` is on, read the way Pi reads it: `1`, `true`, or `yes`
37
+ * (case-insensitive). Any other value, including empty, is off.
38
+ */
39
+ export function isOffline(): boolean {
40
+ const raw = process.env[ENV_OFFLINE]?.trim().toLowerCase();
41
+ return raw === "1" || raw === "true" || raw === "yes";
42
+ }
43
+
32
44
  /** The active worktree root from the environment, when set and still on disk. */
33
45
  export function worktreeRoot(): string | undefined {
34
46
  const root = process.env[ENV_ROOT];
@@ -61,7 +61,19 @@ export interface InvocationCheck {
61
61
  hint: string;
62
62
  }
63
63
 
64
- export type RequirementCheck = BinaryCheck | ModuleCheck | StateCheck | InvocationCheck;
64
+ /**
65
+ * The notification delivery bytefly is configured for.
66
+ *
67
+ * The configured method belongs to the notify capability, so it arrives through
68
+ * {@link ProbeSeams.notify}; whether this host can honour it is decided here.
69
+ */
70
+ export interface NotifyCheck {
71
+ kind: "notify";
72
+ enables: string;
73
+ hint: string;
74
+ }
75
+
76
+ export type RequirementCheck = BinaryCheck | ModuleCheck | StateCheck | InvocationCheck | NotifyCheck;
65
77
 
66
78
  /** What an extension needs, split by how badly it needs it. */
67
79
  export interface ExtensionRequirements {
@@ -173,7 +185,8 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
173
185
  "file-open": {
174
186
  notes: [
175
187
  "The `/open` command and its notification need an interactive UI; the `open_path` tool works without one.",
176
- "Launching the OS handler is best-effort: a failure is reported, and the tool result still names the target.",
188
+ "Launching the OS handler is best-effort: a launcher that fails to start, or exits non-zero on macOS and Linux, is reported; Windows `explorer` can exit non-zero after a successful open.",
189
+ "The tool result names the target even when the launch fails.",
177
190
  ],
178
191
  },
179
192
  memory: {
@@ -193,6 +206,13 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
193
206
  },
194
207
  bytefly: {
195
208
  configFiles: [SHARED_CONFIG_FILE],
209
+ optional: [
210
+ {
211
+ kind: "notify",
212
+ enables: "the configured notification delivery",
213
+ hint: 'set "notify.method" to "auto" in bytefly.json, or choose one this host can deliver.',
214
+ },
215
+ ],
196
216
  notes: [
197
217
  "TUI only for the header, footer, and turn rule; `/bytefly` prints a listing and `/bytefly doctor` the report in RPC, JSON, and print modes.",
198
218
  "The panel writes extension enablement through Pi's own settings; project scope requires a trusted project.",
@@ -229,6 +249,35 @@ export interface ProbeSeams {
229
249
  loadModule?: (name: string) => Promise<unknown>;
230
250
  /** Resolve the nested-pi command; defaults to {@link getPiInvocation}. */
231
251
  invocation?: () => PiInvocation;
252
+ /** The notify capability's configured delivery, or absent when it is not in play. */
253
+ notify?: () => { enabled: boolean; method: string } | undefined;
254
+ /** Whether stdout is a terminal, which the OSC methods need; defaults to true. */
255
+ isTTY?: boolean;
256
+ }
257
+
258
+ /**
259
+ * Whether `method` can deliver on this host.
260
+ *
261
+ * `auto` inspects the environment itself, so it always can; the OSC methods need
262
+ * a terminal to write to; the rest are platform-specific. An unknown id counts
263
+ * as deliverable because bytefly's own loader already rejects one.
264
+ */
265
+ export function canDeliver(method: string, env: { platform: NodeJS.Platform; isTTY: boolean }): boolean {
266
+ switch (method) {
267
+ case "auto":
268
+ return true;
269
+ case "osc777":
270
+ case "osc99":
271
+ return env.isTTY;
272
+ case "toast":
273
+ return env.platform === "win32";
274
+ case "osascript":
275
+ return env.platform === "darwin";
276
+ case "notify-send":
277
+ return env.platform === "linux";
278
+ default:
279
+ return true;
280
+ }
232
281
  }
233
282
 
234
283
  const defaultLoadModule = (name: string): Promise<unknown> => import(name);
@@ -317,6 +366,31 @@ async function runCheck(
317
366
  hint: check.hint,
318
367
  };
319
368
  }
369
+ case "notify": {
370
+ const notify = seams.notify?.();
371
+ if (!notify?.enabled) {
372
+ return { extension, check: "notify.method", severity: "ok", detail: "notifications are off" };
373
+ }
374
+ const env = { platform: seams.platform, isTTY: seams.isTTY ?? true };
375
+ if (canDeliver(notify.method, env)) {
376
+ return {
377
+ extension,
378
+ check: "notify.method",
379
+ severity: "ok",
380
+ detail:
381
+ notify.method === "auto"
382
+ ? "auto picks a route from the environment"
383
+ : `"${notify.method}" delivers on ${seams.platform}`,
384
+ };
385
+ }
386
+ return {
387
+ extension,
388
+ check: "notify.method",
389
+ severity: failSeverity,
390
+ detail: `"${notify.method}" cannot deliver on ${seams.platform} — ${check.enables}`,
391
+ hint: check.hint,
392
+ };
393
+ }
320
394
  }
321
395
  }
322
396
 
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The published `todo` state, read back from the session branch (pure).
3
+ *
4
+ * The todo list is a whole-list replacement stored in each `todo` tool result's
5
+ * `details`, so replaying the branch is how another extension reads it without
6
+ * importing the todo extension. `plan` merges an approved plan's steps into the
7
+ * last list, and `goal` mentions the items still open when a goal is marked
8
+ * achieved.
9
+ */
10
+
11
+ import { TODO_TOOL } from "./tool-names.ts";
12
+
13
+ /** One item in a `todo` list, as the todo tool stores it. */
14
+ export interface ExistingTodo {
15
+ content: string;
16
+ status: "pending" | "in_progress" | "completed";
17
+ /** Present-continuous label the todo tool requires for an in-progress item. */
18
+ activeForm?: string;
19
+ }
20
+
21
+ /** Minimal shape of a session entry, used for runtime narrowing. */
22
+ interface BranchEntryLike {
23
+ type?: string;
24
+ message?: {
25
+ role?: string;
26
+ toolName?: string;
27
+ details?: unknown;
28
+ };
29
+ }
30
+
31
+ function isStatus(value: unknown): value is ExistingTodo["status"] {
32
+ return value === "pending" || value === "in_progress" || value === "completed";
33
+ }
34
+
35
+ /**
36
+ * The last valid todo list written on the branch, or an empty list. A rejected
37
+ * call carries the unchanged list and is not a state write, so it is skipped;
38
+ * an entry that does not re-validate is ignored rather than wiping a good list.
39
+ */
40
+ export function lastTodos(entries: Iterable<unknown>): ExistingTodo[] {
41
+ let todos: ExistingTodo[] = [];
42
+ for (const raw of entries) {
43
+ const entry = raw as BranchEntryLike;
44
+ if (entry?.type !== "message") continue;
45
+ const message = entry.message;
46
+ if (message?.role !== "toolResult" || message.toolName !== TODO_TOOL) continue;
47
+ const details = message.details as { todos?: unknown; error?: unknown } | undefined;
48
+ if (!details || details.error) continue;
49
+ if (!Array.isArray(details.todos)) continue;
50
+ todos = details.todos.flatMap((item): ExistingTodo[] => {
51
+ if (typeof item !== "object" || item === null) return [];
52
+ const value = item as { content?: unknown; status?: unknown; activeForm?: unknown };
53
+ if (typeof value.content !== "string" || !isStatus(value.status)) return [];
54
+ const content = value.content.trim();
55
+ if (!content) return [];
56
+ const activeForm =
57
+ typeof value.activeForm === "string" && value.activeForm.trim() ? value.activeForm.trim() : undefined;
58
+ return [activeForm ? { content, status: value.status, activeForm } : { content, status: value.status }];
59
+ });
60
+ }
61
+ return todos;
62
+ }
package/lib/tui/glyphs.ts CHANGED
@@ -48,6 +48,7 @@ export const GLYPHS = {
48
48
  boxOff: "◻",
49
49
  prev: "‹",
50
50
  next: "›",
51
+ update: "↑",
51
52
  prompt: "❯",
52
53
  } as const;
53
54
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gavin-hu/my-pi-agent",
3
- "version": "0.11.4",
3
+ "version": "0.12.0",
4
4
  "description": "Personal Pi customizations, packaged as a Pi package: worktree isolation and rewind snapshots, question, todo, goal, plan, subagent, job, watch, web-access, file-browse, file-open, document, memory, wechat, and bytefly.",
5
5
  "keywords": [
6
6
  "pi-package",