@gavin-hu/my-pi-agent 0.13.1 → 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 (42) hide show
  1. package/README.md +14 -11
  2. package/README.zh-CN.md +12 -12
  3. package/extensions/bytefly/README.md +140 -58
  4. package/extensions/bytefly/doctor/config.ts +4 -1
  5. package/extensions/bytefly/footer/price/config.ts +3 -2
  6. package/extensions/bytefly/header/component.ts +30 -8
  7. package/extensions/bytefly/header/config.ts +22 -0
  8. package/extensions/bytefly/header/register.ts +2 -1
  9. package/extensions/bytefly/index.ts +3 -3
  10. package/extensions/bytefly/notify/config.ts +1 -1
  11. package/extensions/bytefly/panel/commands.ts +1 -1
  12. package/extensions/bytefly/panel/format.ts +12 -19
  13. package/extensions/bytefly/panel/settings-runtime.ts +62 -18
  14. package/extensions/bytefly/panel/settings.ts +239 -43
  15. package/extensions/bytefly/panel/tui/settings-view.ts +49 -3
  16. package/extensions/bytefly/panel/tui.ts +87 -19
  17. package/extensions/bytefly/separator/component.ts +7 -6
  18. package/extensions/bytefly/separator/config.ts +15 -0
  19. package/extensions/bytefly/separator/format.ts +60 -6
  20. package/extensions/bytefly/separator/register.ts +68 -9
  21. package/extensions/bytefly/separator/types.ts +8 -0
  22. package/extensions/bytefly/working/config.ts +4 -5
  23. package/extensions/bytefly/working/format.ts +24 -7
  24. package/extensions/bytefly/working/register.ts +34 -3
  25. package/extensions/memory/README.md +2 -0
  26. package/extensions/plan/README.md +12 -0
  27. package/extensions/subagent/README.md +60 -5
  28. package/extensions/subagent/config.ts +58 -0
  29. package/extensions/subagent/index.ts +27 -1
  30. package/extensions/subagent/orchestrate.ts +16 -3
  31. package/extensions/subagent/render.ts +12 -0
  32. package/extensions/subagent/run.ts +100 -37
  33. package/extensions/subagent/transcripts.ts +202 -0
  34. package/extensions/subagent/types.ts +12 -0
  35. package/extensions/watch/README.md +2 -0
  36. package/extensions/wechat/README.md +2 -0
  37. package/lib/README.md +1 -1
  38. package/lib/config.ts +33 -9
  39. package/lib/format.ts +6 -0
  40. package/package.json +2 -1
  41. package/themes/bytefly-dark.json +2 -3
  42. package/themes/bytefly-light.json +2 -3
@@ -15,7 +15,7 @@
15
15
  * Load with: pi --extension ./extensions/subagent
16
16
  */
17
17
 
18
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
19
19
  import { isExtensionEnabled, resolveEffectiveCwd } from "../../lib/env.ts";
20
20
  import {
21
21
  type AgentConfig,
@@ -25,8 +25,10 @@ import {
25
25
  formatAgentList,
26
26
  formatAgentNames,
27
27
  } from "./agents.ts";
28
+ import { loadSubagentConfig, type SubagentConfig } from "./config.ts";
28
29
  import { runChainMode, runParallelMode, runSingleMode, type ModeContext } from "./orchestrate.ts";
29
30
  import { renderSubagentCall, renderSubagentResult } from "./render.ts";
31
+ import { pruneTranscripts, transcriptsDirFor } from "./transcripts.ts";
30
32
  import { SUBAGENT_FINISHED_CHANNEL } from "../../lib/events.ts";
31
33
  import { finishedEvent } from "./accounting.ts";
32
34
  import { runSingleAgent, type RunOptions } from "./run.ts";
@@ -68,6 +70,21 @@ function requestedAgentNames(args: SubagentArgs): string[] {
68
70
  export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): void {
69
71
  if (!isExtensionEnabled("subagent")) return;
70
72
  const runner = deps.run ?? runSingleAgent;
73
+ // Read once per session; a call that arrives before `session_start` reads the
74
+ // same files lazily, so the two paths cannot disagree.
75
+ let settings: SubagentConfig | undefined;
76
+
77
+ const startSession = async (ctx: ExtensionContext): Promise<void> => {
78
+ settings = loadSubagentConfig(ctx.cwd);
79
+ if (settings.transcripts === "off") return;
80
+ try {
81
+ pruneTranscripts(await transcriptsDirFor(pi, ctx.cwd), settings);
82
+ } catch {
83
+ // Retention is housekeeping: an unreadable store must not fail a session.
84
+ }
85
+ };
86
+
87
+ pi.on("session_start", (_event, ctx) => startSession(ctx));
71
88
 
72
89
  /**
73
90
  * Account for each finished child, then hand the result on unchanged.
@@ -127,6 +144,13 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
127
144
  const discovery = discoverAgents(root, agentScope);
128
145
  const agents = discovery.agents;
129
146
  const requestedTotal = args.chain?.length ?? args.tasks?.length ?? 1;
147
+ const config = settings ?? loadSubagentConfig(ctx.cwd);
148
+ // Resolved against the session cwd, not the active root: a run's record outlives
149
+ // the worktree it ran in, the way the `job` registry does.
150
+ const transcripts =
151
+ config.transcripts === "off"
152
+ ? undefined
153
+ : { dir: await transcriptsDirFor(pi, ctx.cwd), mode: config.transcripts };
130
154
  const makeDetails = (results: SingleResult[]): SubagentDetails => ({
131
155
  mode,
132
156
  results,
@@ -180,6 +204,8 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
180
204
  onUpdate,
181
205
  makeDetails,
182
206
  agents,
207
+ timeoutMs: config.timeoutMs,
208
+ transcripts,
183
209
  };
184
210
 
185
211
  if (mode === "chain" && args.chain) return runChainMode(context);
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
8
8
  import type { AgentConfig } from "./agents.ts";
9
+ import type { TranscriptMode } from "./config.ts";
9
10
  import type { RunOptions } from "./run.ts";
10
11
  import { MAX_CONCURRENCY, PER_TASK_OUTPUT_CAP, type SubagentArgs } from "./schema.ts";
11
12
  import {
@@ -30,11 +31,15 @@ export interface ModeContext {
30
31
  onUpdate?: OnUpdateCallback;
31
32
  /** Builds the details payload for this mode from the current results. */
32
33
  makeDetails: (results: SingleResult[]) => SubagentDetails;
34
+ /** Per-run wall-clock limit in ms; `0` or absent disables it. */
35
+ timeoutMs?: number;
36
+ /** Where and when a finished run writes a transcript. */
37
+ transcripts?: { dir: string; mode: Exclude<TranscriptMode, "off"> };
33
38
  }
34
39
 
35
40
  /** Sequential chain: each step's `{previous}` is the prior step's final output. */
36
41
  export async function runChainMode(ctx: ModeContext): Promise<AgentToolResult<SubagentDetails>> {
37
- const { run, args, defaults, defaultCwd, signal, onUpdate, makeDetails, agents } = ctx;
42
+ const { run, args, defaults, defaultCwd, signal, onUpdate, makeDetails, agents, timeoutMs, transcripts } = ctx;
38
43
  const chain = args.chain ?? [];
39
44
  const results: SingleResult[] = [];
40
45
  let previous = "";
@@ -62,6 +67,8 @@ export async function runChainMode(ctx: ModeContext): Promise<AgentToolResult<Su
62
67
  signal,
63
68
  onUpdate: chainUpdate,
64
69
  makeDetails,
70
+ timeoutMs,
71
+ transcripts,
65
72
  });
66
73
  results.push(result);
67
74
 
@@ -89,7 +96,7 @@ export async function runChainMode(ctx: ModeContext): Promise<AgentToolResult<Su
89
96
 
90
97
  /** Independent tasks with bounded concurrency, reporting running/done progress. */
91
98
  export async function runParallelMode(ctx: ModeContext): Promise<AgentToolResult<SubagentDetails>> {
92
- const { run, args, defaults, defaultCwd, signal, onUpdate, makeDetails, agents } = ctx;
99
+ const { run, args, defaults, defaultCwd, signal, onUpdate, makeDetails, agents, timeoutMs, transcripts } = ctx;
93
100
  const tasks = args.tasks ?? [];
94
101
  const allResults: SingleResult[] = tasks.map((task) => createResult(task.agent, task.task));
95
102
 
@@ -113,6 +120,10 @@ export async function runParallelMode(ctx: ModeContext): Promise<AgentToolResult
113
120
  cwd: task.cwd,
114
121
  readOnly: args.readOnly,
115
122
  signal,
123
+ // Elapsed time is per run, so a deadline is too: one timer around the mode
124
+ // would bound the batch rather than each child.
125
+ timeoutMs,
126
+ transcripts,
116
127
  onUpdate: (partial) => {
117
128
  const current = partial.details?.results[0];
118
129
  if (!current) return;
@@ -148,7 +159,7 @@ export async function runParallelMode(ctx: ModeContext): Promise<AgentToolResult
148
159
 
149
160
  /** One agent + task. */
150
161
  export async function runSingleMode(ctx: ModeContext): Promise<AgentToolResult<SubagentDetails>> {
151
- const { run, args, defaults, defaultCwd, signal, onUpdate, makeDetails, agents } = ctx;
162
+ const { run, args, defaults, defaultCwd, signal, onUpdate, makeDetails, agents, timeoutMs, transcripts } = ctx;
152
163
  const result = await run({
153
164
  defaultCwd,
154
165
  defaults,
@@ -160,6 +171,8 @@ export async function runSingleMode(ctx: ModeContext): Promise<AgentToolResult<S
160
171
  signal,
161
172
  onUpdate,
162
173
  makeDetails,
174
+ timeoutMs,
175
+ transcripts,
163
176
  });
164
177
 
165
178
  if (isFailedResult(result)) {
@@ -296,6 +296,16 @@ function callText(args: StreamedSubagentArgs, theme: Theme, context?: { cwd?: st
296
296
  return text;
297
297
  }
298
298
 
299
+ /** The transcript this run kept, or why it could not be written. */
300
+ function transcriptLine(result: SingleResult, theme: Theme): string | undefined {
301
+ if (result.artifactError) {
302
+ return paint(theme, "warn", `transcript failed: ${sanitizeLine(result.artifactError)}`);
303
+ }
304
+ if (!result.artifactPath) return undefined;
305
+ const cut = result.artifactTruncated ? paint(theme, "warn", " (truncated)") : "";
306
+ return paint(theme, "faint", `transcript: ${sanitizeLine(result.artifactPath)}${cut}`);
307
+ }
308
+
299
309
  /** Append one result's expanded block: header, error, task, output, usage. */
300
310
  function addExpandedResult(
301
311
  container: Container,
@@ -353,6 +363,8 @@ function addExpandedResult(
353
363
  container.addChild(new Spacer(1));
354
364
  container.addChild(new Text(paint(theme, "faint", usage), 0, 0));
355
365
  }
366
+ const transcript = transcriptLine(result, theme);
367
+ if (transcript) container.addChild(new Text(transcript, 0, 0));
356
368
  }
357
369
 
358
370
  /** The collapsed single result: header, a word, body, expand hint, and usage. */
@@ -2,8 +2,9 @@
2
2
  * Running one subagent subprocess.
3
3
  *
4
4
  * Spawns `pi --mode json`, folds the JSON event stream into a `SingleResult`,
5
- * streams partial updates, and cleans up the temporary system-prompt file. The
6
- * process factory is injectable so tests can drive a scripted child.
5
+ * enforces the run's deadline, optionally records the raw stream, streams partial
6
+ * updates, and cleans up the temporary system-prompt file. The process factory and
7
+ * the transcript store are injectable so tests can drive a scripted child.
7
8
  */
8
9
 
9
10
  import { spawn as nodeSpawn } from "node:child_process";
@@ -11,9 +12,12 @@ import * as fs from "node:fs";
11
12
  import * as os from "node:os";
12
13
  import * as path from "node:path";
13
14
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
15
+ import { formatDuration } from "../../lib/format.ts";
14
16
  import { BUILTIN_AGENTS, formatAgentList, type AgentConfig } from "./agents.ts";
17
+ import type { TranscriptMode } from "./config.ts";
15
18
  import { buildAgentArgs, getPiInvocation, READ_ONLY_NOTE } from "./invocation.ts";
16
- import { applyEvent, createResult, parseJsonLine } from "./stream.ts";
19
+ import { applyEvent, createResult, isFailedResult, parseJsonLine } from "./stream.ts";
20
+ import { createTranscript, type Transcript } from "./transcripts.ts";
17
21
  import type { DispatchDefaults, OnUpdateCallback, SingleResult, SpawnFn, SubagentDetails } from "./types.ts";
18
22
 
19
23
  export interface RunOptions {
@@ -38,6 +42,10 @@ export interface RunOptions {
38
42
  makeDetails: (results: SingleResult[]) => SubagentDetails;
39
43
  /** Process factory; defaults to `node:child_process.spawn`. */
40
44
  spawn?: SpawnFn;
45
+ /** Per-run wall-clock limit in ms; `0` or absent disables it. */
46
+ timeoutMs?: number;
47
+ /** Where and when a finished run writes a transcript. */
48
+ transcripts?: { dir: string; mode: Exclude<TranscriptMode, "off"> };
41
49
  }
42
50
 
43
51
  async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
@@ -53,46 +61,79 @@ async function writePromptToTempFile(agentName: string, prompt: string): Promise
53
61
  interface SpawnOutcome {
54
62
  exitCode: number;
55
63
  aborted: boolean;
64
+ timedOut: boolean;
65
+ }
66
+
67
+ /** How long a killed child has to exit before it is killed again, hard. */
68
+ const KILL_ESCALATION_MS = 5000;
69
+
70
+ /** Why a child is being killed; a timeout fails the run, an abort rejects the call. */
71
+ type KillReason = "abort" | "timeout";
72
+
73
+ interface SpawnContext {
74
+ command: string;
75
+ args: string[];
76
+ cwd: string;
77
+ /** Per-run wall-clock limit in ms; `0` disables it. */
78
+ timeoutMs: number;
79
+ signal?: AbortSignal;
80
+ result: SingleResult;
81
+ onEvent: () => void;
82
+ transcript?: Transcript;
83
+ spawn: SpawnFn;
56
84
  }
57
85
 
58
86
  /** Spawn the child, parse stdout line-by-line, and resolve when it closes. */
59
- function spawnAndCollect(
60
- command: string,
61
- args: string[],
62
- cwd: string,
63
- signal: AbortSignal | undefined,
64
- result: SingleResult,
65
- onEvent: () => void,
66
- spawn: SpawnFn,
67
- ): Promise<SpawnOutcome> {
87
+ function spawnAndCollect(context: SpawnContext): Promise<SpawnOutcome> {
88
+ const { command, args, cwd, timeoutMs, signal, result, onEvent, transcript, spawn } = context;
68
89
  return new Promise<SpawnOutcome>((resolve) => {
69
90
  const proc = spawn(command, args, { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"] });
70
91
  let buffer = "";
71
92
  let settled = false;
72
- let aborted = false;
93
+ let abortRequested = false;
94
+ let timedOut = false;
73
95
 
74
- let kill: (() => void) | undefined;
75
96
  let killTimer: ReturnType<typeof setTimeout> | undefined;
97
+ let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
98
+
99
+ const kill = (reason: KillReason) => {
100
+ if (settled) return;
101
+ if (reason === "abort") abortRequested = true;
102
+ else {
103
+ timedOut = true;
104
+ result.timedOut = true;
105
+ }
106
+ proc.kill("SIGTERM");
107
+ killTimer = setTimeout(() => {
108
+ if (!proc.killed) proc.kill("SIGKILL");
109
+ }, KILL_ESCALATION_MS);
110
+ killTimer.unref();
111
+ };
112
+ const onAbort = () => kill("abort");
76
113
 
77
114
  const finish = (exitCode: number) => {
78
115
  if (settled) return;
79
116
  settled = true;
80
- if (kill && signal) signal.removeEventListener("abort", kill);
117
+ if (signal) signal.removeEventListener("abort", onAbort);
81
118
  if (killTimer) clearTimeout(killTimer);
119
+ if (timeoutTimer) clearTimeout(timeoutTimer);
82
120
  if (buffer.trim()) {
121
+ transcript?.line(buffer);
83
122
  const event = parseJsonLine(buffer);
84
123
  if (event && applyEvent(result, event)) onEvent();
85
124
  }
86
- resolve({ exitCode, aborted });
125
+ resolve({ exitCode, aborted: abortRequested, timedOut });
87
126
  };
88
127
 
89
128
  const processLine = (line: string) => {
129
+ transcript?.line(line);
90
130
  const event = parseJsonLine(line);
91
131
  if (event && applyEvent(result, event)) onEvent();
92
132
  };
93
133
 
94
134
  proc.stdout?.on("data", (chunk) => {
95
- buffer += chunk.toString();
135
+ const text = chunk.toString();
136
+ buffer += text;
96
137
  // Keep the final partial line in the buffer for the close handler.
97
138
  let newline = buffer.indexOf("\n");
98
139
  while (newline !== -1) {
@@ -103,7 +144,9 @@ function spawnAndCollect(
103
144
  });
104
145
 
105
146
  proc.stderr?.on("data", (chunk) => {
106
- result.stderr += chunk.toString();
147
+ const text = chunk.toString();
148
+ result.stderr += text;
149
+ transcript?.stderr(text);
107
150
  });
108
151
 
109
152
  proc.on("close", (code, signal) => {
@@ -122,16 +165,12 @@ function spawnAndCollect(
122
165
  });
123
166
 
124
167
  if (signal) {
125
- kill = () => {
126
- aborted = true;
127
- proc.kill("SIGTERM");
128
- killTimer = setTimeout(() => {
129
- if (!proc.killed) proc.kill("SIGKILL");
130
- }, 5000);
131
- killTimer.unref();
132
- };
133
- if (signal.aborted) kill();
134
- else signal.addEventListener("abort", kill, { once: true });
168
+ if (signal.aborted) onAbort();
169
+ else signal.addEventListener("abort", onAbort, { once: true });
170
+ }
171
+ if (timeoutMs > 0) {
172
+ timeoutTimer = setTimeout(() => kill("timeout"), timeoutMs);
173
+ timeoutTimer.unref();
135
174
  }
136
175
  });
137
176
  }
@@ -163,6 +202,12 @@ export async function runSingleAgent(options: RunOptions): Promise<SingleResult>
163
202
 
164
203
  const emit = () => options.onUpdate?.({ content: [], details: options.makeDetails([result]) });
165
204
 
205
+ const timeoutMs = options.timeoutMs ?? 0;
206
+ if (timeoutMs > 0) result.timeoutMs = timeoutMs;
207
+ const transcripts = options.transcripts;
208
+ const transcript = transcripts ? createTranscript() : undefined;
209
+ let runCwd = options.defaultCwd;
210
+
166
211
  let tempDir: string | null = null;
167
212
  let tempPath: string | null = null;
168
213
 
@@ -178,22 +223,40 @@ export async function runSingleAgent(options: RunOptions): Promise<SingleResult>
178
223
 
179
224
  const args = buildAgentArgs(agent, options.task, options.defaults, tempPath, options.readOnly === true);
180
225
  const invocation = getPiInvocation(args);
181
- const runCwd = options.cwd ? path.resolve(options.defaultCwd, options.cwd) : options.defaultCwd;
182
- const outcome = await spawnAndCollect(
183
- invocation.command,
184
- invocation.args,
185
- runCwd,
186
- options.signal,
226
+ runCwd = options.cwd ? path.resolve(options.defaultCwd, options.cwd) : options.defaultCwd;
227
+ const outcome = await spawnAndCollect({
228
+ command: invocation.command,
229
+ args: invocation.args,
230
+ cwd: runCwd,
231
+ timeoutMs,
232
+ signal: options.signal,
187
233
  result,
188
- emit,
234
+ onEvent: emit,
235
+ transcript,
189
236
  spawn,
190
- );
237
+ });
191
238
 
192
- result.exitCode = outcome.exitCode;
239
+ result.exitCode = outcome.timedOut ? 1 : outcome.exitCode;
193
240
  result.finishedAt = Date.now();
241
+ // A deadline fails the run rather than rejecting it: a chain then stops at
242
+ // this step and reports why, instead of discarding the steps already done.
243
+ if (outcome.timedOut) result.errorMessage = `Timed out after ${formatDuration(timeoutMs)}`;
194
244
  if (outcome.aborted) throw new Error("Subagent was aborted");
195
245
  return result;
196
246
  } finally {
247
+ // Flushed here, not after the call: the abort path throws past every caller.
248
+ if (transcript && transcripts && (transcripts.mode === "all" || isFailedResult(result))) {
249
+ const written = transcript.write(transcripts.dir, result, {
250
+ cwd: runCwd,
251
+ readOnly: options.readOnly === true,
252
+ });
253
+ if (written.path) {
254
+ result.artifactPath = written.path;
255
+ result.artifactBytes = written.bytes;
256
+ if (written.truncated) result.artifactTruncated = true;
257
+ }
258
+ if (written.error) result.artifactError = written.error;
259
+ }
197
260
  if (tempPath) {
198
261
  try {
199
262
  fs.unlinkSync(tempPath);
@@ -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. */
@@ -103,6 +103,8 @@ render the tail of the sanitized lines, with the hidden count noted above and
103
103
 
104
104
  ### Command: /watch
105
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
+
106
108
  A bare `/watch` opens a dock screen listing watchers. `j`/`k` (and the wheel)
107
109
  move, `Enter`/`l` opens a watcher's matches, `x`/`K` stops a running watcher
108
110
  behind a `y/N` confirm, `d` clears stopped ones behind a `y/N` confirm, `Esc`
@@ -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
package/lib/README.md CHANGED
@@ -18,7 +18,7 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
18
18
  | Module | Responsibility |
19
19
  |---|---|
20
20
  | [`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. |
21
- | [`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`. |
21
+ | [`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` / `unsetConfigValueAt` (the last three edit one leaf, flat or nested, preserving siblings and metadata; the last removes it and prunes what it emptied). Includes `clampInteger` / `clampNumber` / `cleanString` / `asRecord` / `readJson` / `readJsonResult`. |
22
22
  | [`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. |
23
23
  | [`escape.ts`](./escape.ts) | `escapeHtml`: the single HTML text/attribute escape used by the browse page renderers and the Markdown and syntax highlighters. |
24
24
  | [`events.ts`](./events.ts) | Inter-extension `pi.events` channels and payloads: `JOB_FINISHED_CHANNEL` / `JobFinishedEvent` (published by `job`, consumed by bytefly's notify capability), `SUBAGENT_FINISHED_CHANNEL` / `SubagentFinishedEvent` (published by `subagent`, consumed by bytefly's footer roll-up), and `WEB_SEARCH_PROVIDER_CHANNEL` / `WebSearchProviderEvent` (published by `web-access` at session start, consumed by bytefly's doctor so it can report the resolved provider without importing the extension — the key itself is never in the payload). |