pi-web-ui 0.47.0 → 0.48.1

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.
@@ -17,6 +17,7 @@ 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 { hasPendingWaitSubscription, shouldRetainActive } from "./wait-subscription-scan.js";
20
21
  import { syncPluginToolsIntoSession } from "./plugins.js";
21
22
  import { SettingsService } from "./settings-service.js";
22
23
  import { GoalService } from "./goal-service.js";
@@ -2124,22 +2125,26 @@ export class ClientSession {
2124
2125
  // An isolated reviewer can keep working while the main session is idle;
2125
2126
  // retain that conversation so its review is not disposed when the user
2126
2127
  // switches away without sending another prompt.
2127
- if (conv.goal.reviewing || conv.wizardRunning) {
2128
- conv.listed = true;
2129
- return null;
2130
- }
2131
- if (conv.session.isStreaming) {
2132
- conv.listed = true;
2133
- return null;
2134
- }
2135
- // Terminal state is a reason to keep an otherwise idle conversation alive:
2136
- // switching chats must not kill a PTY the user or agent may still need.
2137
- if (conv.terminals.list().length > 0) {
2128
+ // 同时检查磁盘上的未过期 wait-subscription 记录:后台子代理运行结束后
2129
+ // 仍欠本会话一次唤醒回合;此时释放运行时会杀死 pi-subagents 扩展宿主,
2130
+ // 唤醒永远无法送达(会话表现为无限期停摆)。保留是自限的:记录过期后
2131
+ // 不再阻止释放。
2132
+ // Also retain when a non-expired pi-subagents wait-subscription record
2133
+ // exists on disk for this session: a finished background subagent run
2134
+ // still owes this conversation a wake-up turn.
2135
+ const retained = shouldRetainActive({
2136
+ reviewing: conv.goal.reviewing,
2137
+ wizardRunning: conv.wizardRunning,
2138
+ streaming: conv.session.isStreaming,
2139
+ openTerminals: conv.terminals.list().length,
2140
+ listed: conv.listed,
2141
+ promptedSinceActive: conv.promptedSinceActive,
2142
+ hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
2143
+ });
2144
+ if (retained) {
2138
2145
  conv.listed = true;
2139
2146
  return null;
2140
2147
  }
2141
- if (conv.listed && conv.promptedSinceActive)
2142
- return null;
2143
2148
  return conv;
2144
2149
  }
2145
2150
  /** Remove a conversation from the running list and free its runtime. The
@@ -0,0 +1,183 @@
1
+ // ---------------------------------------------------------------------------
2
+ // 挂起 wake 订阅扫描(retain conversations with pending subagent wakes)
3
+ // ---------------------------------------------------------------------------
4
+ // 背景:pi-subagents 扩展在后台异步子代理运行结束后,通过持久化的
5
+ // wait-subscription 记录唤醒父会话(pi.sendMessage({ triggerTurn: true }))。
6
+ // 记录是独立的小 JSON 文件,存放在共享目录
7
+ // <tmp>/pi-subagents-<scope>/wait-subscriptions/<token>.json
8
+ // 其中 scope 与 pi-subagents/src/shared/types.ts 的 resolveTempScopeId() 一致:
9
+ // PI_SUBAGENTS_TEMP_ROOT 环境变量优先,否则 uid-N / user-<用户名> /
10
+ // home-<主目录> / shared(完整 fallback 链见 resolveTempScopeId 注释)。
11
+ // 记录的 sessionId 是会话 .jsonl 文件的绝对路径(AgentSession.sessionFile),
12
+ // 记录自带 expiresAt(毫秒时间戳),因此任何基于它的保留策略都是自限的:
13
+ // 记录过期后即不再触发保留,内存/运行时开销有硬上界。
14
+ //
15
+ // fail-open / fail-closed 决策:只有「能完整解析为合法 wait-subscription 记录、
16
+ // sessionId 匹配且未过期」的文件才算证据(→ fail-closed 保留运行时)。
17
+ // 损坏 JSON、格式不符(含非有限数值,见 parseRecord)、外部会话的记录一律
18
+ // 视为「无证据」(→ fail-open 允许释放):这些文件无法给出可信的过期时间,
19
+ // 若 fail-closed 会造成运行时永久泄漏;而误释放的最坏后果只是本 bug 已知的
20
+ // 行为(重开聊天时唤醒)。
21
+ // ---------------------------------------------------------------------------
22
+ // Scan for pending "wait subscription" records (pi-subagents). A pending record
23
+ // means the conversation's session is owed a wake-up turn by a finished
24
+ // background subagent run, so its runtime must be kept alive across project /
25
+ // conversation switches. See the Chinese block above for the persistence layout
26
+ // and the fail-open vs fail-closed rationale.
27
+ import { readdirSync, readFileSync } from "node:fs";
28
+ import { homedir, tmpdir, userInfo } from "node:os";
29
+ import path from "node:path";
30
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
31
+ /**
32
+ * 严格结构校验:任何字段缺失/类型不符(含非有限数值 —— 例如 JSON 里的 1e999
33
+ * 经 JSON.parse 变成 Infinity)都视为「外部格式」,不算证据(fail-open)。
34
+ * 非有限 expiresAt 会破坏「记录过期即自限」的不变量,必须挡在校验层。
35
+ */
36
+ function parseRecord(value) {
37
+ if (!value || typeof value !== "object" || Array.isArray(value))
38
+ return undefined;
39
+ const record = value;
40
+ if (record.version !== 1
41
+ || typeof record.token !== "string"
42
+ || !UUID_RE.test(record.token)
43
+ || typeof record.sessionId !== "string"
44
+ || (record.targetKind !== "async" && record.targetKind !== "foreground")
45
+ || typeof record.runId !== "string"
46
+ || typeof record.requestedId !== "string"
47
+ || typeof record.createdAt !== "number" || !Number.isFinite(record.createdAt)
48
+ || typeof record.expiresAt !== "number" || !Number.isFinite(record.expiresAt))
49
+ return undefined;
50
+ return record;
51
+ }
52
+ function sanitizeTempScopeSegment(value) {
53
+ const sanitized = value
54
+ .trim()
55
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
56
+ .replace(/^-+|-+$/g, "");
57
+ return sanitized || "unknown";
58
+ }
59
+ /**
60
+ * 复刻 pi-subagents 的临时目录 scope(resolveTempScopeId,types.ts:2054)。
61
+ * Fallback 链与上游逐层对齐:
62
+ * 1. getuid() 可用 → `uid-<n>`;
63
+ * 2. USERNAME / USER / LOGNAME 环境变量 → `user-<名>`;
64
+ * 3. os.userInfo().username(try/catch,抛错则落到下一层)→ `user-<名>`;
65
+ * 4. USERPROFILE / HOME 环境变量,再退 os.homedir()(非空字符串才用)→
66
+ * `home-<主目录>`;
67
+ * 5. 全部不可得 → "shared"。
68
+ * Mirror of pi-subagents' temp-scope resolution so both sides agree on the
69
+ * shared wait-subscriptions directory without importing pi-subagents.
70
+ * getuid / userInfo / homedir 可注入以便逐层测试(生产用 os 默认值)。
71
+ */
72
+ export function resolveTempScopeId(env = process.env, getuid = process.getuid?.bind(process), userInfoFn = userInfo, homedirFn = homedir) {
73
+ if (typeof getuid === "function")
74
+ return `uid-${getuid()}`;
75
+ for (const key of ["USERNAME", "USER", "LOGNAME"]) {
76
+ const value = env[key];
77
+ if (value)
78
+ return `user-${sanitizeTempScopeSegment(value)}`;
79
+ }
80
+ try {
81
+ const username = userInfoFn?.().username;
82
+ if (username)
83
+ return `user-${sanitizeTempScopeSegment(username)}`;
84
+ }
85
+ catch {
86
+ // Fall through to home-directory-based scoping.
87
+ }
88
+ const homedirEnv = env.USERPROFILE ?? env.HOME;
89
+ if (homedirEnv)
90
+ return `home-${sanitizeTempScopeSegment(homedirEnv)}`;
91
+ try {
92
+ const fallbackHomedir = homedirFn?.();
93
+ if (fallbackHomedir)
94
+ return `home-${sanitizeTempScopeSegment(fallbackHomedir)}`;
95
+ }
96
+ catch {
97
+ // Fall through to the last-resort shared scope.
98
+ }
99
+ return "shared";
100
+ }
101
+ /** wait-subscriptions 目录默认位置(与 pi-subagents 的推导一致)。 */
102
+ export function resolveSubscriptionsDir(env = process.env) {
103
+ const configured = env.PI_SUBAGENTS_TEMP_ROOT?.trim();
104
+ const root = configured
105
+ ? path.resolve(configured)
106
+ : path.join(tmpdir(), `pi-subagents-${resolveTempScopeId(env)}`);
107
+ return path.join(path.dirname(path.join(root, "async-subagent-runs")), "wait-subscriptions");
108
+ }
109
+ /**
110
+ * 磁盘扫描:该会话是否还有未过期的 wake 订阅记录。
111
+ * Does the session still have a non-expired wait-subscription record on disk?
112
+ * 任何 I/O / 解析错误都按「无证据」处理(fail-open,见文件头说明);
113
+ * 非 ENOENT 的 I/O 错误会 console.warn 一次(与 pi-subagents :142 对齐),
114
+ * ENOENT(目录/文件尚不存在)保持静默。
115
+ */
116
+ export function hasPendingWaitSubscription(options) {
117
+ const sessionId = options.sessionId;
118
+ if (!sessionId)
119
+ return false;
120
+ const now = options.now ?? Date.now;
121
+ const warn = options.warn ?? ((message, error) => console.warn(message, error));
122
+ const isNotFound = (error) => typeof error === "object" && error !== null && "code" in error
123
+ && error.code === "ENOENT";
124
+ const dir = options.subscriptionsDir ?? resolveSubscriptionsDir();
125
+ let files;
126
+ try {
127
+ files = readdirSync(dir).filter((file) => file.endsWith(".json"));
128
+ }
129
+ catch (error) {
130
+ if (!isNotFound(error))
131
+ warn(`Failed to scan wait subscriptions in '${dir}':`, error);
132
+ return false; // 目录缺失 / 不可读 → 无证据
133
+ }
134
+ for (const file of files) {
135
+ let value;
136
+ try {
137
+ value = JSON.parse(readFileSync(path.join(dir, file), "utf-8"));
138
+ }
139
+ catch (error) {
140
+ if (!isNotFound(error))
141
+ warn(`Failed to read wait subscription '${path.join(dir, file)}':`, error);
142
+ continue; // 损坏 JSON → 无证据(fail-open)
143
+ }
144
+ const record = parseRecord(value);
145
+ if (!record)
146
+ continue; // 外部格式 → 无证据
147
+ // 与 pi-subagents 的读取路径对齐(:154/:326):文件名必须是
148
+ // `<token>.json` —— 被重命名过的记录永远不会被上游消费,这里同样
149
+ // 视为无证据(fail-open),保证与宿主行为一致。
150
+ // Parity with pi-subagents: only files named `<token>.json` can ever
151
+ // fire, so renamed records count as no evidence.
152
+ if (path.basename(file) !== `${record.token}.json`)
153
+ continue;
154
+ if (record.sessionId !== sessionId)
155
+ continue; // 别的会话
156
+ if (record.expiresAt <= now())
157
+ continue; // 已过期 → 不再保留
158
+ return true;
159
+ }
160
+ return false;
161
+ }
162
+ /**
163
+ * 纯函数版置换决策:true = 保留(不得 dispose),false = 调用方可释放。
164
+ * Pure decision core of displaceActive(): true = retain, false = may dispose.
165
+ * 顺序与 displaceActive 保持一致:review/wizard → streaming → terminals →
166
+ * pending wake → listed+continued(「打开后继续过」的会话也保留)。
167
+ */
168
+ export function shouldRetainActive(input) {
169
+ if (input.reviewing || input.wizardRunning)
170
+ return true;
171
+ if (input.streaming)
172
+ return true;
173
+ if (input.openTerminals > 0)
174
+ return true;
175
+ const hasPendingWake = typeof input.hasPendingWake === "function"
176
+ ? input.hasPendingWake()
177
+ : input.hasPendingWake;
178
+ if (hasPendingWake)
179
+ return true;
180
+ if (input.listed && input.promptedSinceActive)
181
+ return true;
182
+ return false;
183
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.47.0",
3
+ "version": "0.48.1",
4
4
  "description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
5
5
  "license": "MIT",
6
6
  "author": {