pi-web-ui 0.32.0 → 0.34.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.
@@ -1,71 +1,71 @@
1
- <?xml version="1.0" encoding="UTF-16"?>
2
- <!--
3
- pi-web-ui Task Scheduler task — Windows auto-start at logon.
4
-
5
- The easy way (no admin needed, generates everything for you):
6
- npm i -g pi-web-ui
7
- pi-web-ui server install --port 8787 --cwd C:\path\to\project
8
- pi-web-ui server status | restart | stop | uninstall
9
-
10
- Manual install with this template (edit the paths below first):
11
- schtasks /Create /TN "pi-web-ui" /XML pi-web-ui-task.xml /F
12
- schtasks /Run /TN "pi-web-ui"
13
-
14
- Notes:
15
- - The task runs the PowerShell launcher the CLI generates at
16
- %APPDATA%\pi-web-ui\pi-web-ui.ps1 with -WindowStyle Hidden, so the
17
- server runs with no black console window (nothing to accidentally
18
- close/kill). The ps1 sets PORT/PI_WEB_CWD, cd's to the workspace,
19
- launches node, and appends output to %USERPROFILE%\pi-web-ui.log.
20
- Preview both generated files with: pi-web-ui server install --print
21
- - Save this file as UTF-16 LE (schtasks requires it; the CLI does this
22
- automatically when it writes the task XML).
23
- - LogonTrigger = starts when you log in, same as a launchd user agent.
24
- For boot-start without login, use Docker instead (see README).
25
- -->
26
- <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
27
- <RegistrationInfo>
28
- <Description>pi-web-ui — web chat for the pi coding agent (auto-start at logon)</Description>
29
- </RegistrationInfo>
30
- <Triggers>
31
- <LogonTrigger>
32
- <Enabled>true</Enabled>
33
- </LogonTrigger>
34
- </Triggers>
35
- <Principals>
36
- <Principal id="Author">
37
- <LogonType>InteractiveToken</LogonType>
38
- <RunLevel>LeastPrivilege</RunLevel>
39
- </Principal>
40
- </Principals>
41
- <Settings>
42
- <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
43
- <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
44
- <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
45
- <AllowHardTerminate>true</AllowHardTerminate>
46
- <StartWhenAvailable>false</StartWhenAvailable>
47
- <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
48
- <IdleSettings>
49
- <StopOnIdleEnd>false</StopOnIdleEnd>
50
- <RestartOnIdle>false</RestartOnIdle>
51
- </IdleSettings>
52
- <AllowStartOnDemand>true</AllowStartOnDemand>
53
- <Enabled>true</Enabled>
54
- <Hidden>false</Hidden>
55
- <RunOnlyIfIdle>false</RunOnlyIfIdle>
56
- <WakeToRun>false</WakeToRun>
57
- <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
58
- <Priority>7</Priority>
59
- <RestartOnFailure>
60
- <Interval>PT1M</Interval>
61
- <Count>3</Count>
62
- </RestartOnFailure>
63
- </Settings>
64
- <Actions Context="Author">
65
- <Exec>
66
- <Command>C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe</Command>
67
- <Arguments>-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "%APPDATA%\pi-web-ui\pi-web-ui.ps1"</Arguments>
68
- <WorkingDirectory>%USERPROFILE%</WorkingDirectory>
69
- </Exec>
70
- </Actions>
71
- </Task>
1
+ <?xml version="1.0" encoding="UTF-16"?>
2
+ <!--
3
+ pi-web-ui Task Scheduler task — Windows auto-start at logon.
4
+
5
+ The easy way (no admin needed, generates everything for you):
6
+ npm i -g pi-web-ui
7
+ pi-web-ui server install --port 8787 --cwd C:\path\to\project
8
+ pi-web-ui server status | restart | stop | uninstall
9
+
10
+ Manual install with this template (edit the paths below first):
11
+ schtasks /Create /TN "pi-web-ui" /XML pi-web-ui-task.xml /F
12
+ schtasks /Run /TN "pi-web-ui"
13
+
14
+ Notes:
15
+ - The task runs the PowerShell launcher the CLI generates at
16
+ %APPDATA%\pi-web-ui\pi-web-ui.ps1 with -WindowStyle Hidden, so the
17
+ server runs with no black console window (nothing to accidentally
18
+ close/kill). The ps1 sets PORT/PI_WEB_CWD, cd's to the workspace,
19
+ launches node, and appends output to %USERPROFILE%\pi-web-ui.log.
20
+ Preview both generated files with: pi-web-ui server install --print
21
+ - Save this file as UTF-16 LE (schtasks requires it; the CLI does this
22
+ automatically when it writes the task XML).
23
+ - LogonTrigger = starts when you log in, same as a launchd user agent.
24
+ For boot-start without login, use Docker instead (see README).
25
+ -->
26
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
27
+ <RegistrationInfo>
28
+ <Description>pi-web-ui — web chat for the pi coding agent (auto-start at logon)</Description>
29
+ </RegistrationInfo>
30
+ <Triggers>
31
+ <LogonTrigger>
32
+ <Enabled>true</Enabled>
33
+ </LogonTrigger>
34
+ </Triggers>
35
+ <Principals>
36
+ <Principal id="Author">
37
+ <LogonType>InteractiveToken</LogonType>
38
+ <RunLevel>LeastPrivilege</RunLevel>
39
+ </Principal>
40
+ </Principals>
41
+ <Settings>
42
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
43
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
44
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
45
+ <AllowHardTerminate>true</AllowHardTerminate>
46
+ <StartWhenAvailable>false</StartWhenAvailable>
47
+ <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
48
+ <IdleSettings>
49
+ <StopOnIdleEnd>false</StopOnIdleEnd>
50
+ <RestartOnIdle>false</RestartOnIdle>
51
+ </IdleSettings>
52
+ <AllowStartOnDemand>true</AllowStartOnDemand>
53
+ <Enabled>true</Enabled>
54
+ <Hidden>false</Hidden>
55
+ <RunOnlyIfIdle>false</RunOnlyIfIdle>
56
+ <WakeToRun>false</WakeToRun>
57
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
58
+ <Priority>7</Priority>
59
+ <RestartOnFailure>
60
+ <Interval>PT1M</Interval>
61
+ <Count>3</Count>
62
+ </RestartOnFailure>
63
+ </Settings>
64
+ <Actions Context="Author">
65
+ <Exec>
66
+ <Command>C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe</Command>
67
+ <Arguments>-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "%APPDATA%\pi-web-ui\pi-web-ui.ps1"</Arguments>
68
+ <WorkingDirectory>%USERPROFILE%</WorkingDirectory>
69
+ </Exec>
70
+ </Actions>
71
+ </Task>
@@ -1,31 +1,31 @@
1
- # pi-web-ui systemd unit — Linux auto-start on boot.
2
- #
3
- # Install (adjust User/WorkingDirectory/Environment to taste):
4
- # sudo npm i -g pi-web-ui
5
- # sudo cp deploy/pi-web-ui.service /etc/systemd/system/
6
- # sudo systemctl daemon-reload
7
- # sudo systemctl enable --now pi-web-ui
8
- # sudo systemctl status pi-web-ui
9
- #
10
- # Logs: journalctl -u pi-web-ui -f
11
-
12
- [Unit]
13
- Description=pi-web-ui — web chat for the pi coding agent
14
- After=network.target
15
-
16
- [Service]
17
- Type=simple
18
- # Run as an unprivileged user (change to your user, e.g. yourname)
19
- User=YOUR_USER
20
- # The workspace the agent operates in (read/edit/bash/write)
21
- WorkingDirectory=/home/YOUR_USER
22
- Environment=PORT=8787
23
- # Point at your pi config dir if it's not the default ~/.pi/agent
24
- #Environment=PI_CODING_AGENT_DIR=/home/YOUR_USER/.pi/agent
25
- ExecStart=/usr/bin/pi-web-ui
26
- Restart=on-failure
27
- RestartSec=5
28
- # npm global bin may live elsewhere (nvm, etc.) — find with: which pi-web-ui
29
-
30
- [Install]
31
- WantedBy=multi-user.target
1
+ # pi-web-ui systemd unit — Linux auto-start on boot.
2
+ #
3
+ # Install (adjust User/WorkingDirectory/Environment to taste):
4
+ # sudo npm i -g pi-web-ui
5
+ # sudo cp deploy/pi-web-ui.service /etc/systemd/system/
6
+ # sudo systemctl daemon-reload
7
+ # sudo systemctl enable --now pi-web-ui
8
+ # sudo systemctl status pi-web-ui
9
+ #
10
+ # Logs: journalctl -u pi-web-ui -f
11
+
12
+ [Unit]
13
+ Description=pi-web-ui — web chat for the pi coding agent
14
+ After=network.target
15
+
16
+ [Service]
17
+ Type=simple
18
+ # Run as an unprivileged user (change to your user, e.g. yourname)
19
+ User=YOUR_USER
20
+ # The workspace the agent operates in (read/edit/bash/write)
21
+ WorkingDirectory=/home/YOUR_USER
22
+ Environment=PORT=8787
23
+ # Point at your pi config dir if it's not the default ~/.pi/agent
24
+ #Environment=PI_CODING_AGENT_DIR=/home/YOUR_USER/.pi/agent
25
+ ExecStart=/usr/bin/pi-web-ui
26
+ Restart=on-failure
27
+ RestartSec=5
28
+ # npm global bin may live elsewhere (nvm, etc.) — find with: which pi-web-ui
29
+
30
+ [Install]
31
+ WantedBy=multi-user.target
@@ -11,12 +11,13 @@
11
11
  * so reconnects just re-request a snapshot.
12
12
  */
13
13
  import { spawn } from "node:child_process";
14
- import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync, watch, } from "node:fs";
14
+ import { existsSync, readFileSync, rmSync, statSync, writeFileSync, mkdirSync, watch, } from "node:fs";
15
15
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashTool, createLocalBashOperations, defineTool, getAgentDir, ModelRuntime, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
18
18
  import { Type } from "typebox";
19
19
  import { BgServerTracker } from "./bg-servers.js";
20
+ import { syncPluginToolsIntoSession } from "./plugins.js";
20
21
  import { SettingsService } from "./settings-service.js";
21
22
  import { GoalService } from "./goal-service.js";
22
23
  import { SlashCommandsService, parseSlash } from "./slash-commands.js";
@@ -24,7 +25,7 @@ import { ModelAdminService } from "./model-admin.js";
24
25
  import { FilesService, workspacePath } from "./files-service.js";
25
26
  import { isExtensionDisabled, ClientStateStore, } from "./client-state.js";
26
27
  import { saveUpload } from "./uploads.js";
27
- import { makePersistentTerminalTools, TERMINAL_TOOLS_GUIDANCE, TERMINAL_TOOL_NAMES, } from "./terminals.js";
28
+ import { makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, TERMINAL_TOOL_NAMES, } from "./terminals.js";
28
29
  import { WebUIContext } from "./webui-context.js";
29
30
  import { buildAttachmentMessages, parseModelSpec, } from "./attachments.js";
30
31
  import { serializeMessage, serializeStreamingMessage, } from "./serialize.js";
@@ -79,14 +80,14 @@ export class QuiesceRejectedError extends Error {
79
80
  * programs wait for input that never comes. Legacy Chinese files are often
80
81
  * GBK/GB2312 — read them with the right encoding, never paste mojibake into
81
82
  * reasoning/answers. */
82
- const WINDOWS_PERSONA = `You are a coding agent running on Windows. The bash tool runs Git Bash (bash.exe), not PowerShell. Follow these rules to avoid hanging the session:
83
-
84
-
85
-
86
- - ALWAYS pass a timeout parameter to the bash tool (in seconds). There is NO default timeout — a command that never finishes (servers, watchers, infinite loops, slow downloads/installs) will hang the entire conversation indefinitely. Pick a generous timeout for long-running work, but never omit it.
87
- - NEVER run interactive or foreground long-running commands through the bash tool (vi, less, top, python -, node -, npm run dev, sleep 10000). For servers/daemons use background execution with output redirected to a log file, then poll the log; stop them when done.
88
- - In the interactive terminal (TTY) — which is Git Bash too, not PowerShell — NEVER use heredocs (<<'EOF' ... EOF) or here-strings, and NEVER start interactive programs (vi, less, python -, node -, npm init): they wait for keyboard input that never arrives and hang the terminal forever. Prefer writing a temp script file (e.g. .pi-tmp.sh) and running it non-interactively. ALWAYS pass a timeout to long-running commands (e.g. \`timeout 120 npm run dev\`).
89
-
83
+ const WINDOWS_PERSONA = `You are a coding agent running on Windows. The bash tool runs Git Bash (bash.exe), not PowerShell. Follow these rules to avoid hanging the session:
84
+
85
+
86
+
87
+ - ALWAYS pass a timeout parameter to the bash tool (in seconds). There is NO default timeout — a command that never finishes (servers, watchers, infinite loops, slow downloads/installs) will hang the entire conversation indefinitely. Pick a generous timeout for long-running work, but never omit it.
88
+ - NEVER run interactive or foreground long-running commands through the bash tool (vi, less, top, python -, node -, npm run dev, sleep 10000). For servers/daemons use background execution with output redirected to a log file, then poll the log; stop them when done.
89
+ - In the interactive terminal (TTY) — which is Git Bash too, not PowerShell — NEVER use heredocs (<<'EOF' ... EOF) or here-strings, and NEVER start interactive programs (vi, less, python -, node -, npm init): they wait for keyboard input that never arrives and hang the terminal forever. Prefer writing a temp script file (e.g. .pi-tmp.sh) and running it non-interactively. ALWAYS pass a timeout to long-running commands (e.g. \`timeout 120 npm run dev\`).
90
+
90
91
  Many legacy Chinese text files (.html/.txt/.md/.log, exported documents) are GBK/GB2312 encoded: the read tool decodes UTF-8 only and will show mojibake (乱码) for them. If a file's content looks garbled, read it through the terminal instead: in Git Bash use \`cat file | iconv -f GBK -t UTF-8\` (or \`iconv -f GBK -t UTF-8 file\`); in cmd use \`chcp 65001 && type file\`; in PowerShell use \`Get-Content -Encoding Default file\`. Never paste mojibake into your reasoning or answer — describe the decoded content instead.`;
91
92
  /**
92
93
  * Killable bash tool: wraps the SDK bash tool with operations that register
@@ -126,6 +127,48 @@ function makeKillableBashTool(cwd, kills) {
126
127
  execute: (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params, signal, onUpdate),
127
128
  };
128
129
  }
130
+ /**
131
+ * 动态分流 bash:调用时按设置决定走哪套实现——「终端接管 bash」开关因此
132
+ * 即时生效(customTools 在 runtime 创建时固定,不能在创建时二选一)。
133
+ */
134
+ function makeAdaptiveBashTool(killable, terminalBacked, useTerminal) {
135
+ return {
136
+ ...killable,
137
+ execute: (id, params, signal, onUpdate, ctx) => (useTerminal() ? terminalBacked : killable).execute(id, params, signal, onUpdate, ctx),
138
+ };
139
+ }
140
+ /**
141
+ * 插件结构化工具 → SDK ToolDefinition。
142
+ * execute 返回值宽容处理:{content,details} 原样收编;字符串/对象包成文本块。
143
+ */
144
+ function pluginToolToDefinition(tool) {
145
+ const normalize = (result) => {
146
+ if (result &&
147
+ typeof result === "object" &&
148
+ Array.isArray(result.content)) {
149
+ return result;
150
+ }
151
+ const text = typeof result === "string" ? result : JSON.stringify(result ?? null, null, 2);
152
+ return { content: [{ type: "text", text }] };
153
+ };
154
+ return {
155
+ name: tool.name,
156
+ label: tool.label ?? tool.name,
157
+ description: tool.description,
158
+ promptSnippet: tool.promptSnippet,
159
+ promptGuidelines: tool.promptGuidelines,
160
+ parameters: (tool.parameters ?? {
161
+ type: "object",
162
+ properties: {},
163
+ }),
164
+ execute: async (toolCallId, params, signal, onUpdate) => {
165
+ const raw = await tool.execute(toolCallId, params, signal, onUpdate
166
+ ? (partial) => onUpdate(normalize(partial))
167
+ : undefined);
168
+ return normalize(raw);
169
+ },
170
+ };
171
+ }
129
172
  /**
130
173
  * Cheap per-message discriminator for the serialization cache key. Persisted
131
174
  * message content never changes, so this is stable across snapshots, while
@@ -272,6 +315,13 @@ export class ClientSession {
272
315
  flushSnapshot: () => this.flushSnapshot(),
273
316
  isDisposed: () => this.disposed,
274
317
  });
318
+ /** index.ts 注入(经 AgentService 拷贝到每个新会话):把 SDK 工具执行事件转发给
319
+ * 插件(PluginManager.emitToolEvent)。未设置时不做任何事。 */
320
+ onToolEvent = undefined;
321
+ /** index.ts 注入:读取插件当前注册的 AI 工具(attach 时拷贝到每个新会话)。 */
322
+ pluginToolsProvider = undefined;
323
+ /** 上一轮注入会话的插件工具名集合(用于检测注销/移除)。 */
324
+ appliedPluginToolNames = new Set();
275
325
  /** The active conversation (all session operations target it). */
276
326
  get conv() {
277
327
  const conv = this.convs.get(this.activeId);
@@ -299,7 +349,68 @@ export class ClientSession {
299
349
  return (conversationId ? this.convs.get(conversationId) : this.conv)?.cwd ?? this.cwd;
300
350
  }
301
351
  makeTerminalManager(conversationId, cwd) {
302
- return new TerminalManager((msg) => this.emitTerminal(conversationId, msg), cwd);
352
+ const mgr = new TerminalManager((msg) => this.emitTerminal(conversationId, msg), cwd);
353
+ // 终端活力检测:AI 触碰过的终端静默 ≥ 阈值(PI_WEB_TERMINAL_IDLE_MS,
354
+ // 默认 15s)且该对话正在运行时,注入一条 steer 消息唤醒 AI 去检查。
355
+ mgr.onAgentIdle = (terminalId, idleMs, title) => this.notifyTerminalIdle(conversationId, terminalId, idleMs, title);
356
+ return mgr;
357
+ }
358
+ /** 终端活力提醒:仅在该对话正在流式运行时注入(sendUserMessage 在流式中
359
+ * 即 steer 语义——当前回合结算后送达,agent 立即响应);空闲时不打扰。
360
+ * 一次性语义由 TerminalManager 保证(触发后解除武装,agent 再次触碰才
361
+ * 重新计时),不会反复刷屏。 */
362
+ notifyTerminalIdle(conversationId, terminalId, idleMs, title) {
363
+ const conv = this.convs.get(conversationId);
364
+ if (!conv || this.disposed)
365
+ return;
366
+ if (!conv.runtime.session.isStreaming)
367
+ return;
368
+ const seconds = Math.max(1, Math.round(idleMs / 1000));
369
+ void conv.runtime.session
370
+ .sendUserMessage(`(系统自动提醒:你启动的终端「${title}」已连续 ${seconds} 秒没有任何新输出。` +
371
+ `进程可能在等待输入、卡住或已挂起。请用 terminal_read 查看它的当前状态;` +
372
+ `若在等交互就用 terminal_input / terminal_key 回应;确认不再需要就 terminal_close 关掉它。)`)
373
+ .catch(() => {
374
+ // best effort —— 注入失败不影响终端本身
375
+ });
376
+ }
377
+ /**
378
+ * 终端接管的 bash 静默转后台后的完成通知:命令真正结束时主动告诉 AI。
379
+ * 流式中 → sendUserMessage(steer,立即唤醒处理);空闲时 → sendCustomMessage
380
+ * nextTurn 排队(不唤醒 agent、不耗 token,下次对话自动带上)。
381
+ */
382
+ notifyTerminalBashDone(terminals, info) {
383
+ const conv = [...this.convs.values()].find((c) => c.terminals === terminals);
384
+ if (!conv || this.disposed)
385
+ return;
386
+ let tail = "";
387
+ try {
388
+ const end = terminals.endCursor(info.terminalId);
389
+ if (end !== null) {
390
+ tail = terminals.read(info.terminalId, Math.max(0, end - 4000))?.data ?? "";
391
+ }
392
+ }
393
+ catch {
394
+ // 终端可能已被关闭
395
+ }
396
+ const exitText = info.exitCode === null ? "终端已关闭" : `退出码 ${info.exitCode}`;
397
+ const cmdShort = info.command.length > 120 ? `${info.command.slice(0, 120)}…` : info.command;
398
+ const text = `(系统:你之前在终端 ${info.terminalId} 后台运行的命令已结束(${exitText}):${cmdShort}\n` +
399
+ `最后输出:\n${stripAnsi(tail).trim() || "(无输出)"})`;
400
+ const session = conv.runtime.session;
401
+ if (session.isStreaming) {
402
+ void session.sendUserMessage(text).catch(() => { });
403
+ }
404
+ else {
405
+ // 空闲时不唤醒 agent——排队为 nextTurn 上下文,下次对话自动可见。
406
+ void session
407
+ .sendCustomMessage({
408
+ customType: "terminal-bash-done",
409
+ content: [{ type: "text", text }],
410
+ display: true,
411
+ })
412
+ .catch(() => { });
413
+ }
303
414
  }
304
415
  emitTerminal(conversationId, msg) {
305
416
  // Background conversations keep collecting output in their own PTY buffer.
@@ -356,6 +467,20 @@ export class ClientSession {
356
467
  }
357
468
  return "";
358
469
  }
470
+ /** The FULL system prompt actually in effect right now (AgentSession getter,
471
+ * includes the append/replace override + auto-appended sections like
472
+ * project context, skills and tool guidance). Read-only view source for
473
+ * the settings panel. */
474
+ effectiveSystemPrompt() {
475
+ try {
476
+ const sp = this.session.systemPrompt;
477
+ return typeof sp === "string" ? sp : "";
478
+ }
479
+ catch {
480
+ // Session not ready yet.
481
+ return "";
482
+ }
483
+ }
359
484
  /** Web-facing extension UI context (widgets, notifications). */
360
485
  webUi = new WebUIContext((msg) => this.emit(msg));
361
486
  widgetsTimer = null;
@@ -375,6 +500,11 @@ export class ClientSession {
375
500
  /** Messages array as of the last emitted snapshot/delta — identity-walked
376
501
  * against the current array to detect append-only growth. */
377
502
  emittedMessages = null;
503
+ /** Conversation whose messages emittedMessages belongs to. A conversation
504
+ * switch (set_cwd / new_chat / switch_*) must fall back to a FULL snapshot:
505
+ * two empty conversations have identical (empty) arrays, so the identity
506
+ * walk alone would misread the switch as "nothing changed" → delta. */
507
+ emittedConvId = null;
378
508
  /** snapRev value at which emittedMessages was captured. */
379
509
  emittedRev = 0;
380
510
  /**
@@ -420,6 +550,7 @@ export class ClientSession {
420
550
  await this.pushSlashCommands();
421
551
  },
422
552
  effectiveDefaultSystemPrompt: () => this.effectiveDefaultSystemPrompt(),
553
+ effectiveSystemPrompt: () => this.effectiveSystemPrompt(),
423
554
  });
424
555
  this.goalSvc = new GoalService({
425
556
  clientId,
@@ -550,8 +681,21 @@ export class ClientSession {
550
681
  // 覆盖),执行时把自己的 AbortController 注册进客户端集合——
551
682
  // abortBash() 只杀这些命令,agent run 与对话继续。
552
683
  customTools: [
553
- makeKillableBashTool(effectiveCwd, this.bashKills),
684
+ // bash 双实现动态分流:「终端接管」开启时命令跑进持久可见终端
685
+ // (保留 shell 状态、静默自动转后台),关闭时是原生 killable bash。
686
+ makeAdaptiveBashTool(makeKillableBashTool(effectiveCwd, this.bashKills), makeTerminalBashTool(terminals, {
687
+ cwd: effectiveCwd,
688
+ idleMs: () => this.settingsSvc.current.terminalBash
689
+ ? Math.max(0, Math.floor(this.settingsSvc.current.terminalBashIdleMs) ||
690
+ 0)
691
+ : 0,
692
+ kills: this.bashKills,
693
+ notifyBackgroundDone: (info) => this.notifyTerminalBashDone(terminals, info),
694
+ }), () => this.settingsSvc.current.terminalBash),
554
695
  ...makePersistentTerminalTools(terminals, effectiveCwd),
696
+ // 插件注册的 AI 工具(创建时刻的实时快照;后续注册经
697
+ // refreshPluginTools 动态补入已有会话)。
698
+ ...(this.pluginToolsProvider?.() ?? []).map(pluginToolToDefinition),
555
699
  ],
556
700
  });
557
701
  // 终端工具开关从创建起就生效(工具始终注册进注册表,只调活跃集)。
@@ -797,6 +941,8 @@ export class ClientSession {
797
941
  this.bg.snapshotBefore();
798
942
  }
799
943
  this.armToolWatchdog(conv, event.toolCallId);
944
+ // 插件扩展点:工具开始执行(异常由 emitToolEvent 隔离)。
945
+ this.onToolEvent?.({ phase: "start", toolName: event.toolName, conversationId: conv.id });
800
946
  break;
801
947
  }
802
948
  case "tool_execution_end": {
@@ -808,6 +954,14 @@ export class ClientSession {
808
954
  if (event.toolName === "bash")
809
955
  void this.bg.trackAfterBash();
810
956
  const durationMs = startedAt !== undefined ? Date.now() - startedAt : undefined;
957
+ // 插件扩展点:工具结束执行(带耗时与错误标志)。
958
+ this.onToolEvent?.({
959
+ phase: "end",
960
+ toolName: event.toolName,
961
+ conversationId: conv.id,
962
+ ...(durationMs !== undefined ? { durationMs } : {}),
963
+ isError: event.isError,
964
+ });
811
965
  // The bash tool does not put its exit code in result.details — on
812
966
  // failure it throws "Command exited with code N" and the agent
813
967
  // wraps that into the error result text. Try details first (future
@@ -1107,7 +1261,10 @@ export class ClientSession {
1107
1261
  return;
1108
1262
  const cur = this.currentMessages();
1109
1263
  const prev = this.emittedMessages;
1110
- let incremental = !forceFull && prev !== null && prev.length <= cur.length;
1264
+ let incremental = !forceFull &&
1265
+ prev !== null &&
1266
+ this.emittedConvId === this.activeId &&
1267
+ prev.length <= cur.length;
1111
1268
  if (incremental && prev) {
1112
1269
  for (let i = 0; i < prev.length; i++) {
1113
1270
  if (prev[i] !== cur[i]) {
@@ -1120,6 +1277,7 @@ export class ClientSession {
1120
1277
  if (incremental && prev) {
1121
1278
  const baseRev = this.emittedRev;
1122
1279
  this.emittedMessages = cur;
1280
+ this.emittedConvId = this.activeId;
1123
1281
  this.emittedRev = rev;
1124
1282
  this.emit({
1125
1283
  type: "snapshot_delta",
@@ -1132,6 +1290,7 @@ export class ClientSession {
1132
1290
  }
1133
1291
  else {
1134
1292
  this.emittedMessages = cur;
1293
+ this.emittedConvId = this.activeId;
1135
1294
  this.emittedRev = rev;
1136
1295
  this.emit({
1137
1296
  type: "snapshot",
@@ -1444,6 +1603,24 @@ export class ClientSession {
1444
1603
  // Session 未就绪——下次创建/reload 会再应用。
1445
1604
  }
1446
1605
  }
1606
+ /** 把插件 AI 工具同步进一个已存在的会话(新增/更新/移除)。
1607
+ * 实际 diff 逻辑在 plugins.ts 的 syncPluginToolsIntoSession(可单测)。 */
1608
+ syncPluginTools(session) {
1609
+ try {
1610
+ const defs = (this.pluginToolsProvider?.() ?? []).map(pluginToolToDefinition);
1611
+ const next = syncPluginToolsIntoSession(session, defs, this.appliedPluginToolNames);
1612
+ if (next)
1613
+ this.appliedPluginToolNames = new Set(next);
1614
+ }
1615
+ catch (err) {
1616
+ console.error("[plugins] sync tools to session failed:", err);
1617
+ }
1618
+ }
1619
+ /** index.ts 经 pluginMgr.onAgentToolsChanged 触发:把插件 AI 工具推入全部会话。 */
1620
+ refreshPluginTools() {
1621
+ for (const conv of this.convs.values())
1622
+ this.syncPluginTools(conv.session);
1623
+ }
1447
1624
  async applySettingsReload() {
1448
1625
  // 兼容旧入口:reload + 刷目录在宿主回调里完成
1449
1626
  return this.settingsSvc.applyRuntime();
@@ -1933,6 +2110,48 @@ export class ClientSession {
1933
2110
  this.emit({ type: "sessions", sessions: [] });
1934
2111
  }
1935
2112
  }
2113
+ /** Remove an entry from the client's recent-project list (UI state only). */
2114
+ async removeProject(path) {
2115
+ this.stateStore.removeProject(this.clientId, path);
2116
+ await this.pushProjects();
2117
+ }
2118
+ /** Permanently delete a persisted session transcript file (history list ✕). */
2119
+ async deleteSession(path) {
2120
+ try {
2121
+ const abs = resolve(path);
2122
+ // Guardrail: only transcripts under the shared sessions root
2123
+ // (<agentDir>/sessions/) may be deleted — never arbitrary files.
2124
+ const sessionsRoot = resolve(this.agentDir, "sessions");
2125
+ if (!abs.startsWith(sessionsRoot + sep)) {
2126
+ this.emit({
2127
+ type: "notice",
2128
+ level: "error",
2129
+ text: "只能删除会话目录中的对话记录",
2130
+ });
2131
+ return;
2132
+ }
2133
+ // Refuse to pull the file out from under a live conversation.
2134
+ for (const conv of this.convs.values()) {
2135
+ if (conv.session.sessionFile === abs) {
2136
+ this.emit({
2137
+ type: "notice",
2138
+ level: "warning",
2139
+ text: "该对话正在使用中,请先切换到其他对话再删除",
2140
+ });
2141
+ return;
2142
+ }
2143
+ }
2144
+ rmSync(abs, { force: true });
2145
+ await this.refreshSessions();
2146
+ }
2147
+ catch (err) {
2148
+ this.emit({
2149
+ type: "notice",
2150
+ level: "error",
2151
+ text: `删除会话失败:${err.message}`,
2152
+ });
2153
+ }
2154
+ }
1936
2155
  /** Switch the active session to a persisted one (from listSessions). */
1937
2156
  async switchSession(path) {
1938
2157
  if (this.quiesceBlocked())
@@ -2053,6 +2272,7 @@ export class ClientSession {
2053
2272
  async pushProjects() {
2054
2273
  try {
2055
2274
  const saved = this.stateStore.get(this.clientId);
2275
+ const removedProjects = new Set(this.stateStore.getRemovedProjects(this.clientId));
2056
2276
  const map = new Map();
2057
2277
  for (const p of saved.projects)
2058
2278
  map.set(p.path, p.lastUsed);
@@ -2066,9 +2286,10 @@ export class ClientSession {
2066
2286
  }
2067
2287
  }
2068
2288
  // Only keep directories that still exist — a deleted/unmounted workspace
2069
- // is useless in the picker.
2289
+ // is useless in the picker. Tombstoned entries (explicitly removed by
2290
+ // the user) stay hidden even though session files still mention them.
2070
2291
  const projects = [...map.entries()]
2071
- .filter(([path]) => existsSync(path))
2292
+ .filter(([path]) => !removedProjects.has(path) && existsSync(path))
2072
2293
  .map(([path, lastUsed]) => ({ path, lastUsed }))
2073
2294
  .sort((a, b) => b.lastUsed - a.lastUsed)
2074
2295
  .slice(0, 20);
@@ -2366,6 +2587,10 @@ export class ClientSession {
2366
2587
  }
2367
2588
  export class AgentService {
2368
2589
  cwd;
2590
+ /** index.ts 注入:SDK 工具执行事件的插件转发钩子,attach 时拷贝到每个新会话。 */
2591
+ onToolEvent = undefined;
2592
+ /** index.ts 注入:读取插件当前注册的 AI 工具(attach 时拷贝到每个新会话)。 */
2593
+ pluginToolsProvider = undefined;
2369
2594
  clients = new Map();
2370
2595
  /** Quiesce (draining) state — the service refuses NEW work (prompts, forks,
2371
2596
  * session resumes, new clients) so a deploy/upgrade/backup can stop cleanly
@@ -2492,9 +2717,16 @@ export class AgentService {
2492
2717
  cs.attachSink(send);
2493
2718
  // Forward hooks (set once by index.ts) to every session.
2494
2719
  cs.onQuit = this.onQuit;
2720
+ cs.onToolEvent = this.onToolEvent;
2721
+ cs.pluginToolsProvider = this.pluginToolsProvider;
2495
2722
  cs.isQuiesced = () => this.quiesced;
2496
2723
  return cs;
2497
2724
  }
2725
+ /** 插件 AI 工具集合变化(注册/注销)时由 index.ts 触发:推送到所有客户端的全部会话。 */
2726
+ applyPluginAgentTools() {
2727
+ for (const cs of this.clients.values())
2728
+ cs.refreshPluginTools();
2729
+ }
2498
2730
  /** Remove a socket from a client's broadcast set (called on socket close). */
2499
2731
  detach(clientId, send) {
2500
2732
  this.clients.get(clientId)?.detachSink(send);
@@ -397,9 +397,9 @@ export async function buildAttachmentMessages(ctx, attachments) {
397
397
  content: [
398
398
  {
399
399
  type: "text",
400
- text: `
401
- <vision-bridge>
402
- ${transcript}
400
+ text: `
401
+ <vision-bridge>
402
+ ${transcript}
403
403
  </vision-bridge>`,
404
404
  },
405
405
  ...(pathImg