@gavin-hu/my-pi-agent 0.13.0 → 0.14.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 (64) hide show
  1. package/README.md +14 -11
  2. package/README.zh-CN.md +12 -12
  3. package/extensions/bytefly/README.md +163 -61
  4. package/extensions/bytefly/doctor/config.ts +4 -1
  5. package/extensions/bytefly/doctor/runtime.ts +2 -0
  6. package/extensions/bytefly/footer/budget.ts +64 -0
  7. package/extensions/bytefly/footer/collect/snapshot.ts +22 -4
  8. package/extensions/bytefly/footer/price/config.ts +18 -1
  9. package/extensions/bytefly/footer/price/types.ts +5 -0
  10. package/extensions/bytefly/footer/register.ts +34 -3
  11. package/extensions/bytefly/header/component.ts +30 -8
  12. package/extensions/bytefly/header/config.ts +22 -0
  13. package/extensions/bytefly/header/register.ts +2 -1
  14. package/extensions/bytefly/index.ts +7 -5
  15. package/extensions/bytefly/notify/config.ts +1 -1
  16. package/extensions/bytefly/notify/register.ts +15 -1
  17. package/extensions/bytefly/panel/commands.ts +1 -1
  18. package/extensions/bytefly/panel/format.ts +12 -19
  19. package/extensions/bytefly/panel/settings-runtime.ts +62 -18
  20. package/extensions/bytefly/panel/settings.ts +269 -42
  21. package/extensions/bytefly/panel/tui/settings-view.ts +49 -3
  22. package/extensions/bytefly/panel/tui.ts +87 -19
  23. package/extensions/bytefly/separator/component.ts +7 -6
  24. package/extensions/bytefly/separator/config.ts +15 -0
  25. package/extensions/bytefly/separator/format.ts +60 -6
  26. package/extensions/bytefly/separator/register.ts +68 -9
  27. package/extensions/bytefly/separator/types.ts +8 -0
  28. package/extensions/bytefly/working/config.ts +4 -5
  29. package/extensions/bytefly/working/format.ts +24 -7
  30. package/extensions/bytefly/working/register.ts +34 -3
  31. package/extensions/job/format.ts +4 -7
  32. package/extensions/memory/README.md +2 -0
  33. package/extensions/plan/README.md +12 -0
  34. package/extensions/subagent/README.md +60 -5
  35. package/extensions/subagent/config.ts +58 -0
  36. package/extensions/subagent/index.ts +27 -1
  37. package/extensions/subagent/orchestrate.ts +16 -3
  38. package/extensions/subagent/render.ts +12 -0
  39. package/extensions/subagent/run.ts +100 -37
  40. package/extensions/subagent/transcripts.ts +202 -0
  41. package/extensions/subagent/types.ts +12 -0
  42. package/extensions/todo/nudge.ts +5 -1
  43. package/extensions/watch/README.md +13 -5
  44. package/extensions/watch/commands.ts +4 -3
  45. package/extensions/watch/config.ts +1 -1
  46. package/extensions/watch/format.ts +4 -7
  47. package/extensions/watch/runtime.ts +83 -12
  48. package/extensions/wechat/README.md +2 -0
  49. package/extensions/worktree/README.md +7 -1
  50. package/extensions/worktree/enter.ts +10 -3
  51. package/extensions/worktree/exit.ts +25 -5
  52. package/extensions/worktree/git.ts +28 -0
  53. package/extensions/worktree/index.ts +2 -0
  54. package/extensions/worktree/root-tools.ts +1 -1
  55. package/extensions/worktree/runtime.ts +5 -0
  56. package/lib/README.md +5 -3
  57. package/lib/config.ts +33 -9
  58. package/lib/format.ts +6 -0
  59. package/lib/requirements.ts +80 -1
  60. package/lib/tui/columns.ts +17 -0
  61. package/lib/tui/tool.ts +6 -2
  62. package/package.json +2 -1
  63. package/themes/bytefly-dark.json +2 -3
  64. package/themes/bytefly-light.json +2 -3
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Opt-in per-run transcripts.
3
+ *
4
+ * The dispatching process writes these; a child never learns that one exists, so
5
+ * the read-only tool set and the project-agent trust gate stay exactly as strict
6
+ * as they are. Lines are buffered in memory up to {@link MAX_TRANSCRIPT_BYTES}
7
+ * and flushed once at the end of the run, which keeps the store ordered without a
8
+ * stream to close and still writes on the abort path.
9
+ *
10
+ * Every failure is reported, never thrown: an unwritable checkout must not fail a
11
+ * delegation that already did its work.
12
+ */
13
+
14
+ import { randomBytes } from "node:crypto";
15
+ import { mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+ import { repoRootFor } from "../../lib/git/index.ts";
19
+ import type { SingleResult } from "./types.ts";
20
+
21
+ /** Ceiling for one run's buffered transcript, stdout and stderr together. */
22
+ export const MAX_TRANSCRIPT_BYTES = 32 * 1024 * 1024;
23
+
24
+ /** `YYYYMMDD-HHMMSS-abcdef`; a name sort is chronological, so pruning never parses one. */
25
+ const RUN_ID_PATTERN = /^\d{8}-\d{6}-[0-9a-f]{6}$/;
26
+
27
+ const EVENTS_FILE = "events.jsonl";
28
+ const STDERR_FILE = "stderr.log";
29
+ const META_FILE = "meta.json";
30
+
31
+ /** What a transcript needs from the run that produced it. */
32
+ export interface TranscriptContext {
33
+ /** Working directory the child ran in. */
34
+ cwd: string;
35
+ /** Whether the run forced a reader-only tool set. */
36
+ readOnly: boolean;
37
+ }
38
+
39
+ /** The outcome of writing one run directory. */
40
+ export interface TranscriptWritten {
41
+ /** Directory written, absent when the write failed. */
42
+ path?: string;
43
+ /** Bytes buffered for this run. */
44
+ bytes: number;
45
+ /** Whether the size cap cut the transcript short. */
46
+ truncated: boolean;
47
+ /** Why the write failed, when it did. */
48
+ error?: string;
49
+ }
50
+
51
+ /** A run's buffered transcript, written once the run has finished. */
52
+ export interface Transcript {
53
+ /** Append one raw stdout line. */
54
+ line(raw: string): void;
55
+ /** Append stderr text, which may arrive in partial chunks. */
56
+ stderr(text: string): void;
57
+ /** Write the run directory under `dir`. Never throws. */
58
+ write(dir: string, result: SingleResult, context: TranscriptContext): TranscriptWritten;
59
+ }
60
+
61
+ /** The directory holding every transcript for `cwd`'s repository. */
62
+ export async function transcriptsDirFor(pi: Pick<ExtensionAPI, "exec">, cwd: string): Promise<string> {
63
+ const root = await repoRootFor(pi, cwd, 5_000);
64
+ return root ? join(root, CONFIG_DIR_NAME, "subagents") : join(getAgentDir(), "subagents");
65
+ }
66
+
67
+ /** A run id for `at` (epoch ms, rendered in UTC) with a six-hex suffix. */
68
+ export function runIdFor(at: number, suffix: string): string {
69
+ const date = new Date(at);
70
+ const pad = (value: number, width = 2) => String(value).padStart(width, "0");
71
+ const day = `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}`;
72
+ const time = `${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}`;
73
+ return `${day}-${time}-${suffix}`;
74
+ }
75
+
76
+ /** The metadata written beside a run's stream; enough to audit it without one. */
77
+ function metaFor(runId: string, result: SingleResult, context: TranscriptContext, truncated: boolean) {
78
+ return {
79
+ version: 1,
80
+ runId,
81
+ agent: result.agent,
82
+ agentSource: result.agentSource,
83
+ task: result.task,
84
+ step: result.step,
85
+ cwd: context.cwd,
86
+ readOnly: context.readOnly,
87
+ provider: result.provider,
88
+ model: result.model,
89
+ startedAt: result.startedAt,
90
+ finishedAt: result.finishedAt,
91
+ exitCode: result.exitCode,
92
+ timedOut: result.timedOut === true,
93
+ stopReason: result.stopReason,
94
+ errorMessage: result.errorMessage,
95
+ toolErrors: result.toolErrors ?? 0,
96
+ usage: result.usage,
97
+ transcript: { truncated },
98
+ };
99
+ }
100
+
101
+ /** Buffer one run's stream until it finishes. */
102
+ export function createTranscript(options: { maxBytes?: number } = {}): Transcript {
103
+ const maxBytes = options.maxBytes ?? MAX_TRANSCRIPT_BYTES;
104
+ const lines: string[] = [];
105
+ let stderr = "";
106
+ let bytes = 0;
107
+ let truncated = false;
108
+
109
+ const append = (text: string, kind: "line" | "stderr"): void => {
110
+ if (truncated || !text) return;
111
+ const size = Buffer.byteLength(text);
112
+ if (bytes + size > maxBytes) {
113
+ // Stop growing; the marker is added on write so the file stays parseable.
114
+ truncated = true;
115
+ return;
116
+ }
117
+ bytes += size;
118
+ if (kind === "line") lines.push(text);
119
+ else stderr += text;
120
+ };
121
+
122
+ return {
123
+ line: (raw) => append(raw, "line"),
124
+ stderr: (text) => append(text, "stderr"),
125
+
126
+ write(dir, result, context) {
127
+ const runId = runIdFor(result.startedAt ?? Date.now(), randomBytes(3).toString("hex"));
128
+ const runDir = join(dir, runId);
129
+ try {
130
+ mkdirSync(runDir, { recursive: true });
131
+ const body = truncated ? [...lines, JSON.stringify({ type: "transcript-truncated", bytes })] : lines;
132
+ if (body.length > 0) writeFileSync(join(runDir, EVENTS_FILE), `${body.join("\n")}\n`, { mode: 0o600 });
133
+ if (stderr) writeFileSync(join(runDir, STDERR_FILE), stderr, { mode: 0o600 });
134
+ // Written last: a directory without metadata is an interrupted write.
135
+ const meta = `${JSON.stringify(metaFor(runId, result, context, truncated), null, 2)}\n`;
136
+ writeFileSync(join(runDir, META_FILE), meta, { mode: 0o600 });
137
+ return { path: runDir, bytes, truncated };
138
+ } catch (error) {
139
+ return {
140
+ bytes,
141
+ truncated,
142
+ error: error instanceof Error ? error.message : String(error),
143
+ };
144
+ }
145
+ },
146
+ };
147
+ }
148
+
149
+ /** Which run directories a prune keeps and which it removes. */
150
+ export interface PruneOutcome {
151
+ removed: string[];
152
+ /** Runs still on disk after the prune. */
153
+ kept: number;
154
+ }
155
+
156
+ /**
157
+ * Remove aged and surplus run directories. Only names matching a run id are
158
+ * candidates, so an unrelated directory under the store is never touched.
159
+ */
160
+ export function pruneTranscripts(
161
+ dir: string,
162
+ options: { retentionDays: number; maxRuns: number; now?: number },
163
+ ): PruneOutcome {
164
+ const now = options.now ?? Date.now();
165
+ let names: string[];
166
+ try {
167
+ names = readdirSync(dir, { withFileTypes: true })
168
+ .filter((entry) => entry.isDirectory() && RUN_ID_PATTERN.test(entry.name))
169
+ .map((entry) => entry.name)
170
+ .sort();
171
+ } catch {
172
+ return { removed: [], kept: 0 };
173
+ }
174
+
175
+ const kept: string[] = [];
176
+ const stale: string[] = [];
177
+ for (const name of names) {
178
+ let modified: number;
179
+ try {
180
+ modified = statSync(join(dir, name)).mtimeMs;
181
+ } catch {
182
+ continue;
183
+ }
184
+ if (now - modified > options.retentionDays * 86_400_000) stale.push(name);
185
+ else kept.push(name);
186
+ }
187
+ while (kept.length > options.maxRuns) {
188
+ const oldest = kept.shift();
189
+ if (oldest) stale.push(oldest);
190
+ }
191
+
192
+ const removed: string[] = [];
193
+ for (const name of stale) {
194
+ try {
195
+ rmSync(join(dir, name), { recursive: true, force: true });
196
+ removed.push(name);
197
+ } catch {
198
+ // Best-effort: a locked directory is retried at the next session start.
199
+ }
200
+ }
201
+ return { removed, kept: kept.length };
202
+ }
@@ -50,6 +50,18 @@ export interface SingleResult {
50
50
  startedAt?: number;
51
51
  /** Epoch ms when the subprocess finished. */
52
52
  finishedAt?: number;
53
+ /** Whether the run hit its wall-clock limit, which fails the run without aborting it. */
54
+ timedOut?: boolean;
55
+ /** The limit that applied to this run in ms; absent when it had none. */
56
+ timeoutMs?: number;
57
+ /** Directory of the run's transcript, when one was written. */
58
+ artifactPath?: string;
59
+ /** Bytes buffered for the transcript. */
60
+ artifactBytes?: number;
61
+ /** Whether the transcript stopped at the size cap. */
62
+ artifactTruncated?: boolean;
63
+ /** Why the transcript could not be written, when it could not. */
64
+ artifactError?: string;
53
65
  }
54
66
 
55
67
  /** Structured result carried on the `subagent` tool result for rendering. */
@@ -86,7 +86,11 @@ export function shouldNudge({ mutations, writes, touched, nudged, todos }: Nudge
86
86
  return todos.length === 0 && !touched && writes > 0 && mutations >= EMPTY_LIST_MUTATIONS;
87
87
  }
88
88
 
89
- /** Marker on the injected reminder, so it is recognizable and filterable. */
89
+ /**
90
+ * Marker on the injected reminder, so it is recognizable and filterable.
91
+ *
92
+ * allow:todo the marker is text the model reads, not a to-do for a human.
93
+ */
90
94
  export const TODO_NUDGE_MARKER = "[TODO SYNC]";
91
95
 
92
96
  /**
@@ -33,8 +33,9 @@ How the `watch` call and its result look in the transcript — the call line as
33
33
  match is announced on `my-pi-agent/watch-match`, which bytefly's
34
34
  [notify](../bytefly/README.md#notifications) capability turns into a desktop
35
35
  notification when `notify.enabled` and `notify.matchFound` are on.
36
- - Watchers are session-only: `shutdown` flushes a partial line, kills every
37
- command's process tree, and clears the chips.
36
+ - Watchers are session-only: `shutdown` flushes a partial line, sends SIGTERM to
37
+ every command's process tree (escalating to SIGKILL after a short grace), and
38
+ clears the chips.
38
39
 
39
40
  ## Tool
40
41
 
@@ -42,7 +43,7 @@ How the `watch` call and its result look in the transcript — the call line as
42
43
  |---|---|
43
44
  | `name` | `watch` |
44
45
  | `action` | `add` \| `list` \| `status` \| `matches` \| `logs` \| `stop` \| `clear` |
45
- | `path` | File to tail (`add`); exactly one of `path`/`command`. |
46
+ | `path` | Regular file to tail (`add`); exactly one of `path`/`command`. |
46
47
  | `command` | Shell command whose output to tail (`add`). |
47
48
  | `cwd` | Working directory for a command source; defaults to the effective cwd. |
48
49
  | `pattern` | Regular expression a line must match (`add`, required). |
@@ -102,6 +103,8 @@ render the tail of the sanitized lines, with the hidden count noted above and
102
103
 
103
104
  ### Command: /watch
104
105
 
106
+ <img src="../../assets/watch.png" alt="The /watch screen: a Watch title rule, a `2 watchers · 1 running · 3 matches` summary, a selected file watcher and a finished command watcher, the focused watcher's path and pattern with its line and match counts, and the key hints" width="100%">
107
+
105
108
  A bare `/watch` opens a dock screen listing watchers. `j`/`k` (and the wheel)
106
109
  move, `Enter`/`l` opens a watcher's matches, `x`/`K` stops a running watcher
107
110
  behind a `y/N` confirm, `d` clears stopped ones behind a `y/N` confirm, `Esc`
@@ -131,7 +134,7 @@ value is clamped; a missing or malformed document is ignored.
131
134
 
132
135
  | Key | Default | Meaning |
133
136
  |---|---|---|
134
- | `enabled` | `true` | Track watchers at all. |
137
+ | `enabled` | `true` | Allow `add`; `false` refuses to start a watcher. |
135
138
  | `showStatus` | `true` | Show the two status chips. |
136
139
  | `wakeOnMatch` | `false` | Default for a new watcher's `wake`; a per-watcher `wake` overrides it. |
137
140
  | `maxWatchers` | `20` | Live watchers before `add` is refused. |
@@ -160,6 +163,8 @@ other.
160
163
 
161
164
  - A file watcher polls, so a new line can take up to `pollMs` to surface; it does
162
165
  not use `fs.watch`.
166
+ - `add` requires a path that exists and is a regular file; a directory, FIFO, or
167
+ a file created after the call is refused.
163
168
  - A watcher holds live state in memory and is stopped on shutdown; a detached or
164
169
  resumed session starts with no watchers.
165
170
  - `matches` and `logs` keep bounded histories (`maxMatches`/`maxLogLines`), so
@@ -200,7 +205,10 @@ UTF-8 sequence split across a read boundary is never mangled. State is
200
205
  session-only by design: a watcher is a live process or file position, not
201
206
  transcript state, so it cannot be faithfully rebuilt from a `/tree` fork. Match
202
207
  delivery mirrors `job` — reported at the next turn, with `wake` triggering one
203
- turn — so a match is never dropped when the wake window is busy.
208
+ turn — so a match is never dropped when the wake window is busy. Stopping a
209
+ command sends SIGTERM to its process group and escalates to SIGKILL after a 2 s
210
+ grace; shutdown waits the same grace, so a command that ignores SIGTERM cannot
211
+ outlive the session.
204
212
 
205
213
  ## Files
206
214
 
@@ -14,7 +14,7 @@ import type { WatchRuntime } from "./runtime.ts";
14
14
  import { WatchListComponent } from "./tui.ts";
15
15
 
16
16
  /** How often the open screen re-reads live state. */
17
- const POLL_MS = 500;
17
+ const REFRESH_MS = 500;
18
18
 
19
19
  const SUBCOMMANDS = [
20
20
  { value: "list", label: "list" },
@@ -125,12 +125,13 @@ export function registerCommands(pi: ExtensionAPI, runtime: WatchRuntime): void
125
125
  () => tui.terminal?.rows,
126
126
  );
127
127
 
128
+ // The runtime's own clock owns file polling; this only refreshes
129
+ // what the screen shows, so opening it cannot change the poll cadence.
128
130
  timer = setInterval(() => {
129
- runtime.tick();
130
131
  const open = component.currentLineId();
131
132
  if (open) component.refreshLines(open);
132
133
  tui.requestRender();
133
- }, POLL_MS);
134
+ }, REFRESH_MS);
134
135
  timer.unref?.();
135
136
 
136
137
  return component;
@@ -9,7 +9,7 @@
9
9
  import { clampInteger, loadConfigSection } from "../../lib/config.ts";
10
10
 
11
11
  export interface WatchConfig {
12
- /** Track watchers at all. Default: true. */
12
+ /** Allow starting watchers; false refuses `add`. Default: true. */
13
13
  enabled: boolean;
14
14
  /** Show the status chips while watchers run or a match is unreported. */
15
15
  showStatus: boolean;
@@ -10,15 +10,12 @@
10
10
  */
11
11
 
12
12
  import type { Theme } from "@earendil-works/pi-coding-agent";
13
- import { truncateToWidth } from "@earendil-works/pi-tui";
14
13
  import { sanitizeLine } from "../../lib/format.ts";
14
+ import { clip, labelLine } from "../../lib/tui/columns.ts";
15
15
  import { GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
16
16
  import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
17
17
  import type { PendingMatch, WatchRecord, WatchStatus } from "./types.ts";
18
18
 
19
- /** Longest label shown in the transcript or `/watch` screen. */
20
- const MAX_DISPLAY_LABEL = 48;
21
-
22
19
  /** Longest sanitized watched line kept for display; longer lines are clipped. */
23
20
  const MAX_WATCH_LINE = 200;
24
21
 
@@ -50,7 +47,7 @@ export function compileRegex(pattern: string, caseSensitive: boolean): RegExp {
50
47
 
51
48
  /** A label clipped to one safe display line. */
52
49
  export function clipLabel(text: string): string {
53
- return truncateToWidth(sanitizeWatchLine(text), MAX_DISPLAY_LABEL, "…");
50
+ return labelLine(text, sanitizeWatchLine);
54
51
  }
55
52
 
56
53
  /** What a watcher reads: a path or a command, clipped for display. */
@@ -177,9 +174,9 @@ export function formatCallText(action: string, args: Record<string, unknown>, ar
177
174
  const command = typeof args.command === "string" ? args.command.trim() : "";
178
175
  const source = path ? path : command ? `$ ${command}` : "";
179
176
  if (!source) return argsComplete ? "add" : "add …";
180
- const preview = truncateToWidth(sanitizeLine(source), MAX_DISPLAY_LABEL, "…");
177
+ const preview = labelLine(source);
181
178
  const pattern = typeof args.pattern === "string" ? args.pattern.trim() : "";
182
- return pattern ? `add ${preview} /${truncateToWidth(sanitizeLine(pattern), 24, "…")}/` : `add ${preview}`;
179
+ return pattern ? `add ${preview} /${clip(sanitizeLine(pattern), 24)}/` : `add ${preview}`;
183
180
  }
184
181
  case "status":
185
182
  case "matches":
@@ -22,6 +22,8 @@ import type { PendingMatch, WatchMatch, WatchRecord, WatchSource } from "./types
22
22
  const MAX_CHUNK = 1024 * 1024;
23
23
  /** A line without a newline is flushed once it grows past this. */
24
24
  const MAX_LINE_BYTES = 1024 * 1024;
25
+ /** Grace period before a stopped command is escalated to SIGKILL. */
26
+ const KILL_GRACE_MS = 2000;
25
27
 
26
28
  /** Internal watcher state: the public record plus non-serializable fields. */
27
29
  interface Internal {
@@ -50,6 +52,7 @@ interface Internal {
50
52
  rawLines: string[];
51
53
  matches: WatchMatch[];
52
54
  child?: SpawnedProcess;
55
+ killTimer?: ReturnType<typeof setTimeout>;
53
56
  notified: boolean;
54
57
  seq: number;
55
58
  }
@@ -95,6 +98,8 @@ export interface WatchRuntimeOptions {
95
98
  config?: WatchConfig;
96
99
  /** When false, never create a real interval; tests call `tick()` directly. */
97
100
  autoClock?: boolean;
101
+ /** Grace before SIGTERM escalates to SIGKILL; default {@link KILL_GRACE_MS}. */
102
+ killGraceMs?: number;
98
103
  }
99
104
 
100
105
  export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRuntime {
@@ -103,6 +108,7 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
103
108
  const now = options.now ?? (() => Date.now());
104
109
  const fixedConfig = options.config;
105
110
  const autoClock = options.autoClock ?? true;
111
+ const killGraceMs = options.killGraceMs ?? KILL_GRACE_MS;
106
112
 
107
113
  let config: WatchConfig = fixedConfig ?? DEFAULT_CONFIG;
108
114
  const watches = new Map<string, Internal>();
@@ -243,15 +249,60 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
243
249
  emit(w, rest, time);
244
250
  };
245
251
 
246
- const killChild = (w: Internal): void => {
252
+ const clearKillTimer = (w: Internal): void => {
253
+ if (w.killTimer) {
254
+ clearTimeout(w.killTimer);
255
+ w.killTimer = undefined;
256
+ }
257
+ };
258
+
259
+ const killChild = (w: Internal, signal: "SIGTERM" | "SIGKILL"): void => {
247
260
  const pid = w.pid;
248
261
  try {
249
- if (pid !== undefined) killTree(pid, "SIGTERM");
250
- else w.child?.kill("SIGTERM");
262
+ if (pid !== undefined) killTree(pid, signal, signal === "SIGKILL");
263
+ else w.child?.kill(signal);
251
264
  } catch {
252
265
  // Best-effort: the child may already be gone.
253
266
  }
254
- w.child = undefined;
267
+ };
268
+
269
+ /** SIGTERM a command, escalating to SIGKILL when it has not closed in time. */
270
+ const terminateChild = (w: Internal): void => {
271
+ clearKillTimer(w);
272
+ killChild(w, "SIGTERM");
273
+ if (w.pid === undefined && !w.child) return;
274
+ w.killTimer = setTimeout(() => {
275
+ w.killTimer = undefined;
276
+ if (w.child) killChild(w, "SIGKILL");
277
+ }, killGraceMs);
278
+ w.killTimer.unref?.();
279
+ };
280
+
281
+ /** Resolve when every command has closed, or after `ms`, whichever comes first. */
282
+ const waitForChildren = (watches: Internal[], ms: number): Promise<void> => {
283
+ const alive = watches.filter((w) => w.child !== undefined);
284
+ if (alive.length === 0) return Promise.resolve();
285
+ return new Promise((resolve) => {
286
+ let remaining = alive.length;
287
+ let timer: ReturnType<typeof setTimeout> | undefined;
288
+ const settle = (): void => {
289
+ if (--remaining > 0) return;
290
+ if (timer !== undefined) clearTimeout(timer);
291
+ resolve();
292
+ };
293
+ for (const w of alive) {
294
+ let done = false;
295
+ const once = (): void => {
296
+ if (done) return;
297
+ done = true;
298
+ settle();
299
+ };
300
+ w.child?.on("close", once);
301
+ w.child?.on("error", once);
302
+ }
303
+ timer = setTimeout(resolve, ms);
304
+ timer.unref?.();
305
+ });
255
306
  };
256
307
 
257
308
  const pollFile = (w: Internal, time: number): void => {
@@ -278,6 +329,9 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
278
329
  const read = readSync(fd, buffer, 0, length, w.offset);
279
330
  w.offset += read;
280
331
  consume(w, buffer.subarray(0, read), time);
332
+ } catch {
333
+ // The path can vanish or change type between stat and read (rotation, a
334
+ // replacement); skip this tick and pick it up next time.
281
335
  } finally {
282
336
  if (fd !== undefined) {
283
337
  try {
@@ -305,22 +359,24 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
305
359
  child.stdout?.on("data", onData);
306
360
  child.stderr?.on("data", onData);
307
361
  child.on("close", (code, signal) => {
362
+ clearKillTimer(w);
363
+ w.child = undefined;
308
364
  if (w.status !== "running") return;
309
365
  flushPending(w, now());
310
366
  w.status = "exited";
311
367
  w.exitCode = code;
312
368
  w.signal = signal;
313
369
  w.endedAt = now();
314
- w.child = undefined;
315
370
  ui.paint();
316
371
  if (runningCount() === 0) stopClock();
317
372
  });
318
373
  child.on("error", (error) => {
374
+ clearKillTimer(w);
375
+ w.child = undefined;
319
376
  if (w.status !== "running") return;
320
377
  w.status = "error";
321
378
  w.error = error.message;
322
379
  w.endedAt = now();
323
- w.child = undefined;
324
380
  ui.paint();
325
381
  if (runningCount() === 0) stopClock();
326
382
  });
@@ -337,6 +393,7 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
337
393
  };
338
394
 
339
395
  const add = (opts: AddWatchOptions, ctx: ExtensionContext): WatchRecord => {
396
+ if (!config.enabled) throw new Error("watch is disabled.");
340
397
  if (watches.size >= config.maxWatchers) {
341
398
  throw new Error(`at most ${config.maxWatchers} watchers; stop one first.`);
342
399
  }
@@ -349,11 +406,13 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
349
406
  cwd: opts.cwd ? (isAbsolute(opts.cwd) ? opts.cwd : resolve(base, opts.cwd)) : base,
350
407
  };
351
408
  if (source.kind === "file") {
409
+ let info: ReturnType<typeof statSync>;
352
410
  try {
353
- statSync(source.path ?? "");
411
+ info = statSync(source.path ?? "");
354
412
  } catch {
355
413
  throw new Error(`file not found: ${source.path}`);
356
414
  }
415
+ if (!info.isFile()) throw new Error(`not a regular file: ${source.path}`);
357
416
  }
358
417
  const caseSensitive = opts.caseSensitive ?? false;
359
418
  const regex = compileRegex(opts.pattern, caseSensitive);
@@ -423,7 +482,7 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
423
482
  const w = watches.get(id);
424
483
  if (!w || w.status !== "running") return false;
425
484
  flushPending(w, now());
426
- killChild(w);
485
+ terminateChild(w);
427
486
  w.status = "stopped";
428
487
  w.endedAt = now();
429
488
  ui.paint();
@@ -473,7 +532,13 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
473
532
  const load = (ctx: ExtensionContext): void => {
474
533
  disposed = false;
475
534
  stopClock();
476
- for (const w of watches.values()) if (w.status === "running") killChild(w);
535
+ for (const w of watches.values()) {
536
+ if (w.status !== "running") continue;
537
+ w.status = "stopped";
538
+ clearKillTimer(w);
539
+ killChild(w, "SIGKILL");
540
+ w.child = undefined;
541
+ }
477
542
  watches.clear();
478
543
  counter = 0;
479
544
  config = fixedConfig ?? loadConfig(ctx.cwd);
@@ -484,12 +549,18 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
484
549
  const shutdown = async (): Promise<void> => {
485
550
  disposed = true;
486
551
  stopClock();
487
- for (const w of watches.values()) {
488
- if (w.status !== "running") continue;
552
+ const running = [...watches.values()].filter((w) => w.status === "running");
553
+ for (const w of running) {
489
554
  flushPending(w, now());
490
- killChild(w);
491
555
  w.status = "stopped";
492
556
  w.endedAt = now();
557
+ clearKillTimer(w);
558
+ killChild(w, "SIGTERM");
559
+ }
560
+ await waitForChildren(running, killGraceMs);
561
+ for (const w of running) {
562
+ clearKillTimer(w);
563
+ if (w.child) killChild(w, "SIGKILL");
493
564
  }
494
565
  watches.clear();
495
566
  ui.detach();
@@ -116,6 +116,8 @@ polling. `l` logs in or reconnects, `n` starts a new session, `o` logs out,
116
116
  actions and the only route outside the TUI; an unknown subcommand reports the
117
117
  valid ones.
118
118
 
119
+ <img src="../../assets/wechat.png" alt="The /wechat control screen: account and an open connection, peer, queued, refused, and waiting-prompt rows, three conversation rows showing running, queued, and idle states, and the key hints" width="100%">
120
+
119
121
  Pause is per runtime and keeps the account and peer sessions: a running turn
120
122
  may finish and reply, a waiting prompt is withdrawn with the timeout notice,
121
123
  and the poller lock is released so another runtime can take over. Resume
@@ -122,9 +122,15 @@ exclude the name here to load alongside it. It is read when the extension
122
122
  loads, from the process working directory; reload after changing it. `/worktree`
123
123
  and `list_worktrees` list any overridden tool that is not re-rooted.
124
124
 
125
- Add the worktree directory to `.gitignore`:
125
+ `.gitignore` must cover the managed directory, or worktrees show up as untracked
126
+ files in the main checkout. `enter_worktree` offers once per session to append
127
+ `.pi/worktrees/`, and skips the offer when `git check-ignore` already reports the
128
+ directory as ignored, so a wider rule is enough and no literal line is needed:
126
129
 
127
130
  ```
131
+ # either, when the rest of `.pi/` is disposable
132
+ .pi/
133
+ # or, when other `.pi/` state must stay tracked
128
134
  .pi/worktrees/
129
135
  ```
130
136
 
@@ -17,6 +17,7 @@ import {
17
17
  currentBranch,
18
18
  fetchPrRef,
19
19
  hasCommits,
20
+ isIgnored,
20
21
  lockWorktree,
21
22
  mergeBase,
22
23
  parsePrReference,
@@ -54,12 +55,19 @@ function generateName(): string {
54
55
  }
55
56
 
56
57
  /** Offer once per session to ignore the managed worktree directory. */
57
- async function maybeIgnoreWorktreeDir(ctx: ExtensionContext, root: string, config: WorktreeConfig): Promise<void> {
58
+ async function maybeIgnoreWorktreeDir(
59
+ pi: ExtensionAPI,
60
+ ctx: ExtensionContext,
61
+ root: string,
62
+ config: WorktreeConfig,
63
+ ): Promise<void> {
58
64
  if (isGitignoreOffered()) return;
59
65
  if (!ctx.hasUI) return;
60
66
  markGitignoreOffered();
61
67
 
62
68
  const entry = `${config.dir.replace(/\/+$/, "")}/`;
69
+ if (await isIgnored(pi, root, entry)) return;
70
+
63
71
  const gitignorePath = join(root, ".gitignore");
64
72
  let content = "";
65
73
  try {
@@ -67,7 +75,6 @@ async function maybeIgnoreWorktreeDir(ctx: ExtensionContext, root: string, confi
67
75
  } catch {
68
76
  return;
69
77
  }
70
- if (content.split(/\r?\n/).some((line) => line.trim() === entry || line.trim() === config.dir)) return;
71
78
 
72
79
  const confirmed = await ctx.ui.confirm(
73
80
  "Add the worktree directory to .gitignore?",
@@ -225,7 +232,7 @@ export async function enterWorktree(
225
232
  let copiedIncludes: string[] = [];
226
233
  if (!existed) {
227
234
  copiedIncludes = await copyIncludes(pi, root, target, includePatterns(root, config.include), config.dir);
228
- await maybeIgnoreWorktreeDir(ctx, root, config);
235
+ await maybeIgnoreWorktreeDir(pi, ctx, root, config);
229
236
  }
230
237
 
231
238
  const state: WorktreeState = {