@sukeai/pi-logfwd 0.1.1 → 0.2.2

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 CHANGED
@@ -2,18 +2,20 @@
2
2
 
3
3
  实时命令日志转发:当 pi 运行命令时,把输出**实时流式**转发回来(而不是内置 bash 工具那样攒到最后一次性返回 + 截断)。
4
4
 
5
- - **bash_logged 工具**:pi 扩展,经 Go 二进制 `pi-logfwd` 在伪终端(PTY)中运行命令,JSONL 事件流逐块推送,可选追加日志文件。
5
+ - **bash 工具(覆盖内置)**:pi 扩展注册名为 `bash`,同名覆盖 pi 内置的缓冲式 bash——模型每次调用 bash 都固定经 Go 二进制 `pi-logfwd` 在伪终端(PTY)中运行命令,JSONL 事件流逐块推送,可选追加日志文件。
6
6
  - **pi-logfwd 二进制**:跨平台预编译二进制,作为 npm 平台包随主包一起分发(见下方「平台支持」)。
7
7
 
8
8
  动机:pi 内置 bash/process 工具是缓冲式的——输出攒到最后一次性返回,且截断为末尾 2000 行 / 50KB;没有 TTY、没有交互输入通道。pi-logfwd 补上:实时流式输出、PTY 支持、日志落盘。
9
9
 
10
+ > **为什么不叫 bash_logged**:早期版本注册成独立的 `bash_logged` 工具,与内置 `bash` 并列——模型每次执行命令都在两个工具间“随缘二选一”,导致 pi-logfwd 有时生效有时不生效(实测 23 个会话中仅 4 个用到,shell 调用占比约 3%)。0.2.0 起直接注册名为 `bash` 覆盖内置工具,触发从此 100% 确定:要么不用这个包,要用就全走实时转发。
11
+
10
12
  > 密码提示与 GUI 授权弹窗**不支持**(PTY 只能渲染提示、无人应答;弹窗无法程序化操作)——此时告诉用户手动执行。
11
13
 
12
14
  ## 架构
13
15
 
14
16
  ```
15
17
  pi (agent)
16
- bash_logged 工具(@sukeai/pi-logfwd 扩展)
18
+ bash 工具(@sukeai/pi-logfwd 扩展,覆盖内置 bash)
17
19
 
18
20
  pi-logfwd run -- "shell script" ← 接收 shell 脚本 / 任意命令
19
21
  │ 内部:PTY 分配(默认)或管道(--no-pty)
@@ -40,7 +42,15 @@ pi install /path/to/pi-log-forwarder
40
42
  pi -e npm:@sukeai/pi-logfwd
41
43
  ```
42
44
 
43
- 装完在 pi 里 `/reload`,即可调用 `bash_logged` 工具(参数 `command` / `timeout` / `cwd` / `logFile` / `noPty`)。
45
+ 装完在 pi 里 `/reload`,内置 `bash` 即被替换——所有 shell 命令自动走实时转发(参数 `command` / `timeout` / `cwd` / `logFile` / `noPty` 可用)。想还原内置 bash:`pi remove npm:@sukeai/pi-logfwd` 后 `/reload`。
46
+
47
+ ## 工具结果标记
48
+
49
+ 每次调用的结果末尾会附加一行状态标记,命令失败**不会静默**:
50
+
51
+ - 退出码非零 → `(exit code: N)`(真退出码取自 Go 端 JSONL `exit` 事件,非 pi-logfwd 进程自身退出码)
52
+ - `timeout` 杀进程 → `(timed out after Ns, exit code 124)`(判据是 exit 事件的 `message: "killed by --timeout"`,命令自己 `exit 124` 不会被误判为超时)
53
+ - 正常退出(0)→ 无标记
44
54
 
45
55
  ## 平台支持
46
56
 
@@ -56,7 +66,7 @@ pi -e npm:@sukeai/pi-logfwd
56
66
 
57
67
  **Windows 为什么不支持**:`pi-logfwd` 的 PTY 层用 creack/pty,它在 Windows 上直接返回 `ErrUnsupported`(`--no-pty` 管道模式理论上可行,但需额外改 shell 默认值/信号处理,成本高收益低),因此不发布 win32 平台包。
58
68
 
59
- **不支持的平台如何提醒**:全部平台包被 npm 跳过 → 二进制缺失 → `bash_logged` 不会静默报 ENOENT,而是返回明确说明:
69
+ **不支持的平台如何提醒**:全部平台包被 npm 跳过 → 二进制缺失 → `bash`(pi-logfwd)不会静默报 ENOENT,而是返回明确说明:
60
70
 
61
71
  - **win32**:提示「Windows 不受支持(creack/pty ErrUnsupported),建议在 WSL/容器中运行 pi」;自行编译仅管道版可设 `PI_LOG_FWD_BIN` 绕过。
62
72
  - **其他缺二进制**:给出三种装法(`pi install npm:@sukeai/pi-logfwd` / `go build` / 放入 PATH 或 `~/.pi/agent/bin`)。
@@ -97,7 +107,7 @@ pi-logfwd run --timeout 30s 'npm run build'
97
107
 
98
108
  ```
99
109
  cmd/pi-logfwd/ Go 源码(main.go / runner.go)
100
- extension/extension.ts pi 扩展:注册 bash_logged 工具(平台解析 + 提醒逻辑)
110
+ extension/extension.ts pi 扩展:注册 bash 工具覆盖内置(平台解析 + 提醒逻辑)
101
111
  package.json 主包(pi manifest + optionalDependencies 平台包列表)
102
112
  scripts/release.sh 交叉编译 + 发布(Go build → 平台包 → npm publish)
103
113
  scripts/set-version.js 主包/平台包版本同步
@@ -1,10 +1,19 @@
1
1
  /**
2
- * pi-logfwd - pi package extension registering a "bash_logged" tool.
2
+ * pi-logfwd - pi package extension registering a "bash" tool that REPLACES
3
+ * pi's built-in (buffered) bash.
4
+ *
5
+ * 为什么注册名用 bash:pi 的工具注册表里与内置工具同名的扩展工具会覆盖内置定义
6
+ * (_refreshToolRegistry:先放 built-in,再按工具名 set 扩展工具)。注册成 bash
7
+ * 后,模型每次调用 bash 都固定走 pi-logfwd(PTY + 实时流式转发 + 可选日志文件),
8
+ * 不再存在 "bash / bash_logged 由模型随缘二选一" 的触发不规律问题。
3
9
  *
4
10
  * Runs commands through the Go binary `pi-logfwd` (PTY, real-time log
5
11
  * forwarding, optional log file). Streaming chunks are pushed into the
6
12
  * conversation via onUpdate, so output appears as it happens instead of
7
- * being buffered and truncated by the built-in bash tool.
13
+ * being buffered until the process exits. Output longer than MAX_CHARS is
14
+ * mirrored to a temp file (reported at the end) instead of silently losing
15
+ * its head - the safety net pi's built-in bash used to provide via
16
+ * temp files.
8
17
  *
9
18
  * Binary resolution order (checked on every execute):
10
19
  * 1. env PI_LOG_FWD_BIN (explicit override)
@@ -23,15 +32,19 @@
23
32
  * Install:
24
33
  * pi install npm:@sukeai/pi-logfwd # 推荐:随包自动装当前平台二进制
25
34
  *
35
+ * 装好后 /reload 即覆盖内置 bash(所有 shell 命令经 pi-logfwd 实时转发);
36
+ * 想还原内置 bash:pi remove npm:@sukeai/pi-logfwd 后 /reload。
37
+ *
26
38
  * Dev / manual overrides:
27
39
  * PI_LOG_FWD_BIN=/path/to/pi-logfwd # 指向自建/自编译二进制
28
40
  * cd cmd/pi-logfwd && go build -o ~/.pi/agent/bin/pi-logfwd ./cmd/pi-logfwd
29
41
  */
30
42
  import { spawn } from "node:child_process";
31
43
  import { createInterface } from "node:readline";
32
- import { existsSync } from "node:fs";
44
+ import { createWriteStream, existsSync } from "node:fs";
45
+ import { rm } from "node:fs/promises";
33
46
  import { createRequire } from "node:module";
34
- import { homedir } from "node:os";
47
+ import { homedir, tmpdir } from "node:os";
35
48
  import { dirname, join } from "node:path";
36
49
  import { fileURLToPath } from "node:url";
37
50
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -39,7 +52,7 @@ import { Text } from "@earendil-works/pi-tui";
39
52
  import { Type } from "typebox";
40
53
 
41
54
  const require = createRequire(import.meta.url);
42
- const MAX_CHARS = 60_000;
55
+ const MAX_CHARS = 60_000; // 会话文本保留的尾部上限;超出部分镜像到临时文件并报告路径
43
56
  const STREAM_INTERVAL_MS = 150;
44
57
 
45
58
  /** Prebuilt binary platform packages published alongside this package. */
@@ -77,7 +90,7 @@ function resolveBin(): Resolution {
77
90
  bin: null,
78
91
  notice:
79
92
  "Windows 不受支持: pi-logfwd 的 PTY 依赖 (creack/pty) 在 Windows 上返回 ErrUnsupported,未发布 win32 预编译包。\n" +
80
- "建议: 在 WSL / 容器中运行 pi 以使用 bash_logged。\n" +
93
+ "建议: 在 WSL / 容器中运行 pi 以使用本 bash 工具(实时转发)。\n" +
81
94
  "自行交叉编译仅管道版本 (--no-pty,无 PTY) 后,可设 PI_LOG_FWD_BIN 指向该二进制以绕过此提示。",
82
95
  };
83
96
  }
@@ -118,22 +131,28 @@ function resolveBin(): Resolution {
118
131
 
119
132
  export default function (pi: ExtensionAPI) {
120
133
  pi.registerTool({
121
- name: "bash_logged",
122
- label: "Bash (streamed log forward)",
134
+ name: "bash",
135
+ label: "bash",
123
136
  description:
124
- "Run a shell command or script through pi-logfwd and stream its logs back in real time. " +
125
- "Prefer bash_logged over bash when you need: (a) real-time output streaming instead of " +
126
- "end-buffered/truncated output, (b) a command that requires a pseudo-terminal (PTY), or " +
127
- "(c) a persistent log file. " +
128
- "NOT supported by any pi tool (including this one): interactive secret prompts " +
129
- "(sudo/ssh password) and GUI authorization dialogs - PTY can render a prompt but nothing " +
130
- "can answer it. For those, tell the user to run the command manually.",
131
- promptSnippet: "Run a shell command with real-time log forwarding (PTY support)",
132
- // 自定义 renderCall:TUI 里完整显示命令,并追加一行 log-fwd 标签标识控制来源
137
+ "Run a bash command in the current working directory through pi-logfwd (PTY, real-time " +
138
+ "log forwarding). Output is streamed back in real time instead of being buffered until " +
139
+ "the process exits; the conversation keeps the last 60000 chars and longer output is " +
140
+ "mirrored to a temp file whose path is reported at the end. The real exit status is never " +
141
+ "silent: a non-zero exit appends `(exit code: N)` and a tool-timeout kill appends " +
142
+ "`(timed out after Ns, exit code 124)` at the end of the result. This tool replaces " +
143
+ "pi's built-in buffered bash. NOT supported: interactive secret prompts (sudo/ssh " +
144
+ "password) and GUI authorization dialogs - PTY can render a prompt but nothing can " +
145
+ "answer it, so tell the user to run those manually. Optionally provide a timeout " +
146
+ "(seconds), cwd, logFile to append to, or noPty.",
147
+ promptSnippet: "Execute bash commands (ls, grep, find, etc.) with real-time streamed output (PTY)",
148
+ promptGuidelines: [
149
+ "You can inspect PI_* environment variables for current model and session details.",
150
+ ],
151
+ // 自定义 renderCall:TUI 里完整显示命令,并追加一行 log-fwd 标签标识「经 pi-logfwd 实时转发」
133
152
  renderCall(args, theme, _context) {
134
153
  const command = typeof args.command === "string" ? args.command : "";
135
154
  const commandDisplay = command || theme.fg("toolOutput", "...");
136
- let text = theme.fg("toolTitle", theme.bold(`bash_logged ${commandDisplay}`));
155
+ let text = theme.fg("toolTitle", theme.bold(commandDisplay));
137
156
  if (typeof args.timeout === "number") {
138
157
  text += theme.fg("muted", ` (timeout ${args.timeout}s)`);
139
158
  }
@@ -156,7 +175,7 @@ export default function (pi: ExtensionAPI) {
156
175
  const res = resolveBin();
157
176
  if (!res.ok) {
158
177
  return {
159
- content: [{ type: "text", text: `bash_logged 不可用:\n${res.notice}` }],
178
+ content: [{ type: "text", text: `bash (pi-logfwd) 不可用:\n${res.notice}` }],
160
179
  details: { exitCode: null, forwarded: false, reason: "binary-unavailable" },
161
180
  };
162
181
  }
@@ -170,39 +189,64 @@ export default function (pi: ExtensionAPI) {
170
189
 
171
190
  const child = spawn(res.bin, args, { cwd: ctx.cwd, env: process.env });
172
191
 
173
- let accumulated = "";
192
+ // 全量镜像到临时文件(原内置 bash 的“完整输出”兜底):输出 <= MAX_CHARS 时删除
193
+ // 临时文件;超长时在结果末尾报告文件路径。会话文本始终只保留尾部 MAX_CHARS。
194
+ let totalChars = 0;
195
+ let tail = "";
174
196
  let lastStream = 0;
197
+ let logPath: string | null = null;
198
+ let logStream: import("node:fs").WriteStream | null = null;
175
199
  const push = (chunk: string) => {
176
- accumulated += chunk;
177
- if (accumulated.length > MAX_CHARS * 2) accumulated = accumulated.slice(-MAX_CHARS);
200
+ totalChars += chunk.length;
201
+ if (!logStream) {
202
+ logPath = join(tmpdir(), `pi-logfwd-${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}.log`);
203
+ logStream = createWriteStream(logPath, { flags: "a" });
204
+ }
205
+ logStream.write(chunk);
206
+ tail = (tail + chunk).slice(-MAX_CHARS);
178
207
  const now = Date.now();
179
208
  if (now - lastStream > STREAM_INTERVAL_MS) {
180
209
  lastStream = now;
181
- onUpdate?.({ content: [{ type: "text", text: accumulated.slice(-MAX_CHARS) }] });
210
+ onUpdate?.({ content: [{ type: "text", text: tail }] });
182
211
  }
183
212
  };
184
213
 
214
+ // 消费 JSONL:output 事件进流式文本;exit 事件携带「真正」的命令退出码
215
+ // (超时被杀为 124 + message "killed by --timeout")。child(Go 进程) 的 close
216
+ // code 虽经 os.Exit 透传命令退出码,但 Go 自身报错/被杀时即偏离、且丢失
217
+ // timeout 的 message 与 durationMs;故一律以 exit 事件为准,close code 仅在
218
+ // 事件缺失时兜底。
219
+ type ExitInfo = { code: number | null; durationMs: number; message: string };
220
+ let exitEv: ExitInfo | null = null;
185
221
  const rl = createInterface({ input: child.stdout });
186
222
  rl.on("line", (line) => {
187
223
  try {
188
224
  const ev = JSON.parse(line);
189
- if (ev.event === "output") push(ev.data);
225
+ if (ev.event === "output") {
226
+ push(ev.data);
227
+ } else if (ev.event === "exit") {
228
+ exitEv = {
229
+ code: typeof ev.code === "number" ? ev.code : null,
230
+ durationMs: typeof ev.durationMs === "number" ? ev.durationMs : 0,
231
+ message: typeof ev.message === "string" ? ev.message : "",
232
+ };
233
+ }
190
234
  } catch {
191
235
  /* ignore malformed lines */
192
236
  }
193
237
  });
194
238
 
195
- let exitCode: number | null = null;
239
+ let goExitCode: number | null = null; // child(Go 进程) 自身退出码,仅作 exit 事件缺失时的兜底
196
240
  let errText = "";
197
241
  child.stderr.on("data", (d: Buffer) => (errText += d.toString()));
198
242
  child.on("error", (e: Error) => {
199
243
  if (e && (e as NodeJS.ErrnoException).code === "ENOENT") {
200
- errText += `\nbash_logged 不可用: ${res.bin} 不存在或不可执行。\n${res.notice}`;
244
+ errText += `\nbash (pi-logfwd) 不可用: ${res.bin} 不存在或不可执行。\n${res.notice}`;
201
245
  } else {
202
246
  errText += `pi-logfwd: ${e.message}\n`;
203
247
  }
204
248
  });
205
- child.on("close", (c) => (exitCode = c));
249
+ child.on("close", (c) => (goExitCode = c));
206
250
  signal?.addEventListener("abort", () => child.kill("SIGTERM"), { once: true });
207
251
 
208
252
  await new Promise<void>((resolve) => {
@@ -216,11 +260,49 @@ export default function (pi: ExtensionAPI) {
216
260
  child.on("error", finish);
217
261
  });
218
262
 
219
- const text = (accumulated || "(no output)").slice(-MAX_CHARS);
220
- const resultText = errText ? `${text}\n${errText.trimEnd()}` : text;
263
+ // 关闭日志流;只在文本被截断(输出超长)时保留临时文件
264
+ if (logStream) {
265
+ await new Promise<void>((r) => logStream!.end(() => r()));
266
+ if (totalChars <= MAX_CHARS && logPath) {
267
+ try {
268
+ await rm(logPath, { force: true });
269
+ } catch {
270
+ /* best-effort cleanup */
271
+ }
272
+ logPath = null;
273
+ }
274
+ }
275
+
276
+ // 真退出码优先(exit 事件);只有 Go 进程异常退出(未发 exit 事件)时才退回其进程退出码。
277
+ const exitCode = exitEv ? exitEv.code : goExitCode;
278
+ // 超时判据只看 exit 事件 message:Go 端仅在 timeout 杀死时写 "killed by --timeout";
279
+ // 命令自己 exit 124(如 gnu timeout 惯例)时 message 为空,不应误判为超时。
280
+ const timedOut = /timeout/i.test(exitEv?.message ?? "");
281
+
282
+ const text = tail || "(no output)";
283
+ const notes: string[] = [];
284
+ if (timedOut) {
285
+ const t =
286
+ typeof params.timeout === "number"
287
+ ? `${params.timeout}s`
288
+ : `${((exitEv?.durationMs ?? 0) / 1000).toFixed(1)}s`;
289
+ notes.push(`(timed out after ${t}, exit code 124)`);
290
+ } else if (exitCode !== null && exitCode !== 0) {
291
+ notes.push(`(exit code: ${exitCode})`);
292
+ }
293
+
294
+ let resultText = errText ? `${text}\n${errText.trimEnd()}` : text;
295
+ if (notes.length) resultText += `\n${notes.join("\n")}`;
296
+ if (logPath) resultText += `\n(输出超长,完整日志已存 ${logPath})`;
221
297
  return {
222
298
  content: [{ type: "text", text: resultText }],
223
- details: { exitCode, forwarded: true, logFile: params.logFile ?? null },
299
+ details: {
300
+ exitCode,
301
+ forwarded: true,
302
+ timedOut,
303
+ durationMs: exitEv?.durationMs ?? null,
304
+ logFile: params.logFile ?? null,
305
+ },
224
306
  };
225
307
  },
226
308
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sukeai/pi-logfwd",
3
- "version": "0.1.1",
4
- "description": "pi package: bash_logged tool that streams command logs in real time through the pi-logfwd Go binary (PTY, JSONL events, optional log file) / pi 实时命令日志转发扩展",
3
+ "version": "0.2.2",
4
+ "description": "pi package: replaces the built-in bash tool with real-time log forwarding via the pi-logfwd Go binary (PTY, JSONL events, optional log file) / pi 实时命令日志转发扩展(bash 覆盖内置)",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -26,10 +26,10 @@
26
26
  "typebox": "*"
27
27
  },
28
28
  "optionalDependencies": {
29
- "@sukeai/pi-logfwd-darwin-arm64": "0.1.1",
30
- "@sukeai/pi-logfwd-darwin-amd64": "0.1.1",
31
- "@sukeai/pi-logfwd-linux-arm64": "0.1.1",
32
- "@sukeai/pi-logfwd-linux-amd64": "0.1.1"
29
+ "@sukeai/pi-logfwd-darwin-arm64": "0.2.2",
30
+ "@sukeai/pi-logfwd-darwin-amd64": "0.2.2",
31
+ "@sukeai/pi-logfwd-linux-arm64": "0.2.2",
32
+ "@sukeai/pi-logfwd-linux-amd64": "0.2.2"
33
33
  },
34
34
  "pi": {
35
35
  "extensions": [