@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.
- package/README.md +14 -11
- package/README.zh-CN.md +12 -12
- package/extensions/bytefly/README.md +163 -61
- package/extensions/bytefly/doctor/config.ts +4 -1
- package/extensions/bytefly/doctor/runtime.ts +2 -0
- package/extensions/bytefly/footer/budget.ts +64 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +22 -4
- package/extensions/bytefly/footer/price/config.ts +18 -1
- package/extensions/bytefly/footer/price/types.ts +5 -0
- package/extensions/bytefly/footer/register.ts +34 -3
- package/extensions/bytefly/header/component.ts +30 -8
- package/extensions/bytefly/header/config.ts +22 -0
- package/extensions/bytefly/header/register.ts +2 -1
- package/extensions/bytefly/index.ts +7 -5
- package/extensions/bytefly/notify/config.ts +1 -1
- package/extensions/bytefly/notify/register.ts +15 -1
- package/extensions/bytefly/panel/commands.ts +1 -1
- package/extensions/bytefly/panel/format.ts +12 -19
- package/extensions/bytefly/panel/settings-runtime.ts +62 -18
- package/extensions/bytefly/panel/settings.ts +269 -42
- package/extensions/bytefly/panel/tui/settings-view.ts +49 -3
- package/extensions/bytefly/panel/tui.ts +87 -19
- package/extensions/bytefly/separator/component.ts +7 -6
- package/extensions/bytefly/separator/config.ts +15 -0
- package/extensions/bytefly/separator/format.ts +60 -6
- package/extensions/bytefly/separator/register.ts +68 -9
- package/extensions/bytefly/separator/types.ts +8 -0
- package/extensions/bytefly/working/config.ts +4 -5
- package/extensions/bytefly/working/format.ts +24 -7
- package/extensions/bytefly/working/register.ts +34 -3
- package/extensions/job/format.ts +4 -7
- package/extensions/memory/README.md +2 -0
- package/extensions/plan/README.md +12 -0
- package/extensions/subagent/README.md +60 -5
- package/extensions/subagent/config.ts +58 -0
- package/extensions/subagent/index.ts +27 -1
- package/extensions/subagent/orchestrate.ts +16 -3
- package/extensions/subagent/render.ts +12 -0
- package/extensions/subagent/run.ts +100 -37
- package/extensions/subagent/transcripts.ts +202 -0
- package/extensions/subagent/types.ts +12 -0
- package/extensions/todo/nudge.ts +5 -1
- package/extensions/watch/README.md +13 -5
- package/extensions/watch/commands.ts +4 -3
- package/extensions/watch/config.ts +1 -1
- package/extensions/watch/format.ts +4 -7
- package/extensions/watch/runtime.ts +83 -12
- package/extensions/wechat/README.md +2 -0
- package/extensions/worktree/README.md +7 -1
- package/extensions/worktree/enter.ts +10 -3
- package/extensions/worktree/exit.ts +25 -5
- package/extensions/worktree/git.ts +28 -0
- package/extensions/worktree/index.ts +2 -0
- package/extensions/worktree/root-tools.ts +1 -1
- package/extensions/worktree/runtime.ts +5 -0
- package/lib/README.md +5 -3
- package/lib/config.ts +33 -9
- package/lib/format.ts +6 -0
- package/lib/requirements.ts +80 -1
- package/lib/tui/columns.ts +17 -0
- package/lib/tui/tool.ts +6 -2
- package/package.json +2 -1
- package/themes/bytefly-dark.json +2 -3
- 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. */
|
package/extensions/todo/nudge.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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,
|
|
37
|
-
command's process tree
|
|
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` |
|
|
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` |
|
|
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
|
|
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
|
-
},
|
|
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
|
-
/**
|
|
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
|
|
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 =
|
|
177
|
+
const preview = labelLine(source);
|
|
181
178
|
const pattern = typeof args.pattern === "string" ? args.pattern.trim() : "";
|
|
182
|
-
return pattern ? `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
|
|
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, "
|
|
250
|
-
else w.child?.kill(
|
|
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
|
-
|
|
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
|
-
|
|
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())
|
|
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
|
-
|
|
488
|
-
|
|
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
|
-
|
|
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(
|
|
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 = {
|