pi-web-ui 0.85.0 → 0.86.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.
@@ -17,13 +17,13 @@ import "./patch-remote-catalog.js";
17
17
  import { spawn } from "node:child_process";
18
18
  import { randomUUID } from "node:crypto";
19
19
  import { createRequire } from "node:module";
20
- import { existsSync, readFileSync, rmSync, statSync, mkdirSync, watch } from "node:fs";
20
+ import { appendFileSync, existsSync, readFileSync, rmSync, statSync, mkdirSync, watch, writeFileSync } from "node:fs";
21
21
  import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
23
  import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashToolDefinition, createLocalBashOperations, getAgentDir, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
24
24
  import { Type } from "typebox";
25
25
  import { BgServerTracker } from "./bg-servers.js";
26
- import { checkAll as checkAllUpdates, collectTargets, compareVersions as compareSemver, } from "./update-check.js";
26
+ import { checkAll as checkAllUpdates, collectTargets, compareVersions as compareSemver, resolveNpmRegistry, } from "./update-check.js";
27
27
  import { hasActiveSubagentRun, hasPendingWaitSubscription, shouldRetainActive } from "./wait-subscription-scan.js";
28
28
  import { removeFirstOccurrence } from "./queue-utils.js";
29
29
  import { syncPluginToolsIntoSession } from "./plugins.js";
@@ -771,6 +771,9 @@ export class ClientSession {
771
771
  * (server draining — new work rejected). Default false for direct use. */
772
772
  isQuiesced = () => false;
773
773
  cwd;
774
+ /** 当前项目的额外工作区根(宿主侧多根,见 protocol 的 set_workspace_roots)——
775
+ * 按 cwd 存在 client-state 里,这里只存一份内存缓存给快照热路径读。 */
776
+ roots = [];
774
777
  /** pi config dir (auth/models/skills). */
775
778
  agentDir;
776
779
  /** Persisted per-client UI state (last workspace + recent projects). */
@@ -1396,6 +1399,7 @@ export class ClientSession {
1396
1399
  this.cwd = cwd;
1397
1400
  this.agentDir = agentDir;
1398
1401
  this.stateStore = stateStore;
1402
+ this.roots = stateStore.getWorkspaceRoots(clientId, cwd);
1399
1403
  this.subagentTemplates = new SubagentTemplatesStore(join(stateStore.dataDir, "subagent-templates.json"));
1400
1404
  this.markerSvc = new MarkerService({
1401
1405
  clientId,
@@ -1485,7 +1489,7 @@ export class ClientSession {
1485
1489
  // the list is non-empty). unref: must not keep the process alive.
1486
1490
  this.bg.start();
1487
1491
  }
1488
- static async create(clientId, cwd, stateStore) {
1492
+ static async create(clientId, cwd, stateStore, opts) {
1489
1493
  const agentDir = process.env.PI_CODING_AGENT_DIR ?? getAgentDir();
1490
1494
  const cs = new ClientSession(clientId, cwd, agentDir, stateStore);
1491
1495
  const conversationId = cs.nextConversationId();
@@ -1496,7 +1500,8 @@ export class ClientSession {
1496
1500
  // Resume the most recent session for this project — the SDK default
1497
1501
  // per-project dir (<agentDir>/sessions/--<cwd>--/, shared with the
1498
1502
  // pi CLI/TUI) — or start a fresh one on first visit.
1499
- sessionManager: SessionManager.continueRecent(cwd),
1503
+ // issue #145: opts.blank = 跳过恢复(最近那条在别处跑着),直接空白新对话。
1504
+ sessionManager: opts?.blank ? SessionManager.create(cwd) : SessionManager.continueRecent(cwd),
1500
1505
  });
1501
1506
  // First conversation = the resumed session; it also seeds the shared
1502
1507
  // ModelRuntime that every later conversation reuses.
@@ -1514,6 +1519,15 @@ export class ClientSession {
1514
1519
  });
1515
1520
  }
1516
1521
  }
1522
+ // issue #145:因别处正在跑而跳过恢复 —— 首帧即被告之(pendingNotices 随 attachSink 下发)。
1523
+ if (opts?.blank && opts?.blankTitle) {
1524
+ cs.pendingNotices.push({
1525
+ type: "notice",
1526
+ level: "info",
1527
+ text: `该项目最近的对话「${opts.blankTitle}」正在另一处运行,为你停在了新对话 —— 直接打开会造出第二个写者。左栏「运行的对话」里能看到它(标着“另一处”),等它跑完再打开。`,
1528
+ textEn: `The most recent conversation ("${opts.blankTitle}") is running in another window, so you landed on a new chat instead — opening it here would create a second writer. It is listed under Running chats (tagged "Elsewhere"); open it after it finishes.`,
1529
+ });
1530
+ }
1517
1531
  await cs.bindSession();
1518
1532
  await cs.restoreProjectProviderKeysForCwd(cwd);
1519
1533
  await cs.restoreProjectModelForCwd(cwd);
@@ -2151,13 +2165,29 @@ export class ClientSession {
2151
2165
  // 手动 /compact 或阈值/溢出自动压缩开始——常驻进度条(快照 compaction
2152
2166
  // 字段),而不是一次性 toast(toast 几秒就消失,而摘要生成可能持续
2153
2167
  // 数十秒,用户会以为「没反应」)。立即 flush 让进度条第一时间出现。
2168
+ // 服务端重启会杀死压缩中的 LLM 调用且 compaction_end 永远不会到:
2169
+ // 把带时间戳的待处理标记写进会话文件(与 SDK 条目同格式、可追加),
2170
+ // 重启后打开该会话时检测到它即报「上次压缩被中断,可重试」,而不是
2171
+ // 静默丢失。标记在 compaction_end 到达时删除(正常完成不留痕)。
2154
2172
  case "compaction_start": {
2155
2173
  conv.compactionState = { reason: event.reason, startedAt: Date.now() };
2156
2174
  conv.lastCompactionTokens = null;
2175
+ this.markCompactionPending(conv);
2157
2176
  this.flushSnapshot();
2158
2177
  break;
2159
2178
  }
2160
2179
  case "compaction_end": {
2180
+ this.clearCompactionPending(conv, event.errorMessage
2181
+ ? { status: "failed", error: event.errorMessage }
2182
+ : event.aborted
2183
+ ? { status: "cancelled" }
2184
+ : event.result
2185
+ ? {
2186
+ status: "completed",
2187
+ tokensBefore: event.result.tokensBefore,
2188
+ tokensAfter: event.result.estimatedTokensAfter ?? event.result.tokensBefore,
2189
+ }
2190
+ : undefined);
2161
2191
  conv.compactionState = null;
2162
2192
  if (event.errorMessage) {
2163
2193
  this.emit({
@@ -2555,6 +2585,8 @@ export class ClientSession {
2555
2585
  return {
2556
2586
  clientId: this.clientId,
2557
2587
  cwd: this.cwd,
2588
+ // 判重:直接读缓存字段,不在快照热路径上重读 client-state。
2589
+ workspaceRoots: this.roots,
2558
2590
  sessionId: this.session.sessionId,
2559
2591
  sessionFile: this.session.sessionFile,
2560
2592
  conversationId: this.activeId,
@@ -2936,19 +2968,38 @@ export class ClientSession {
2936
2968
  }
2937
2969
  /** Set by index.ts: called when /pi-web-ui:quit is invoked. */
2938
2970
  onQuit = undefined;
2939
- /** 本客户端成功切换工作区(set_cwd)后触发,参数为新绝对路径。
2971
+ /** 本客户端成功切换工作区(set_cwd)后触发,参数为新绝对路径 + 该项目的额外
2972
+ * 工作区根(多根由用户/插件经 set_workspace_roots 设置,见 protocol)。
2940
2973
  * attach 时由 AgentService 接到全局 onClientCwdChanged —— 编辑器等
2941
2974
  * 工作区跟随型插件借此把根目录切到用户当前项目。 */
2942
2975
  onCwdChanged = undefined;
2976
+ /** issue #145 跨客户端同会话感知 —— attach 时由 AgentService 接线:
2977
+ * - findSessionOwner:别处是否已持有同一 session 文件(查重建第二个 writer 用);
2978
+ * - listProjectRunners:别处在同一 cwd 下正在跑的对话(同项目并行感知用);
2979
+ * - listExternalRunning:别处所有正在跑的对话(左栏「另一处正在运行」用);
2980
+ * - notifyExternalClients:向其他客户端广播一条 notice(并行打开时互相通告);
2981
+ * - onRunningChanged:本实例流式集合变化时触发,AgentService 借此让其他
2982
+ * 客户端重推 conversations(elsewhere 列表近实时)。 */
2983
+ findSessionOwner = undefined;
2984
+ /** issue #145:除本客户端外是否有人在跑(扫目录查重前置的无 I/O 判断)。 */
2985
+ hasStreamingElsewhere = undefined;
2986
+ listProjectRunners = undefined;
2987
+ listExternalRunning = undefined;
2988
+ notifyExternalClients = undefined;
2989
+ onRunningChanged = undefined;
2943
2990
  /** Ask the npm registry for the latest pi-web-ui version and report it. */
2944
2991
  async checkUpdate() {
2945
2992
  const current = ClientSession.currentAppVersion();
2946
2993
  try {
2994
+ // registry 遵从 <agentDir>/npm/.npmrc(与 `pi update` 经 npm 的行为一致,
2995
+ // issue #151);私有源的 token 也一并带上,否则直接 401。
2996
+ const { registry, authHeader } = resolveNpmRegistry(this.agentDir);
2947
2997
  // Fetch the full package doc (not /latest): it carries the per-version
2948
2998
  // publish timestamps so the UI can hint when a version was JUST
2949
2999
  // published and the registry/CDN caches may not have caught up yet.
2950
- const res = await fetch("https://registry.npmjs.org/pi-web-ui", {
3000
+ const res = await fetch(`${registry}/pi-web-ui`, {
2951
3001
  signal: AbortSignal.timeout(8_000),
3002
+ ...(authHeader ? { headers: { authorization: authHeader } } : {}),
2952
3003
  });
2953
3004
  if (!res.ok)
2954
3005
  throw new Error(`HTTP ${res.status}`);
@@ -2993,7 +3044,7 @@ export class ClientSession {
2993
3044
  }
2994
3045
  try {
2995
3046
  const targets = collectTargets(this.agentDir, ClientSession.currentAppVersion());
2996
- const items = await checkAllUpdates(targets, undefined, () => this.getLang());
3047
+ const items = await checkAllUpdates(targets, undefined, () => this.getLang(), resolveNpmRegistry(this.agentDir));
2997
3048
  this.updatesAllCache = { at: Date.now(), items };
2998
3049
  this.emit({ type: "update_status_all", items });
2999
3050
  }
@@ -3490,6 +3541,86 @@ export class ClientSession {
3490
3541
  n += c.queueFollowUp.length + c.queueSteering.length;
3491
3542
  return n;
3492
3543
  }
3544
+ /** issue #145:本实例连接的 socket 数(0 = 标签页全关了,ClientSession 残留)。 */
3545
+ sinkCount() {
3546
+ return this.sinks.size;
3547
+ }
3548
+ /** issue #145:按下 session 文件找本实例持有的对话(跨客户端查重的本机一半)。 */
3549
+ findConversationBySessionFile(targetPath) {
3550
+ for (const conv of this.convs.values()) {
3551
+ try {
3552
+ const sessionFile = conv.session.sessionFile;
3553
+ if (sessionFile && resolve(sessionFile) === targetPath)
3554
+ return conv;
3555
+ }
3556
+ catch {
3557
+ // session being replaced — skip
3558
+ }
3559
+ }
3560
+ return undefined;
3561
+ }
3562
+ /** issue #145:某对话是否正在流式运行(替换中按未跑处理,不误拦)。 */
3563
+ conversationStreaming(conv) {
3564
+ try {
3565
+ return conv.session.isStreaming;
3566
+ }
3567
+ catch {
3568
+ return false;
3569
+ }
3570
+ }
3571
+ /** issue #145:当前活动对话的 session 文件(resolved),无则 undefined。 */
3572
+ activeSessionFileResolved() {
3573
+ try {
3574
+ const conv = this.convs.get(this.activeId);
3575
+ const f = conv?.session.sessionFile;
3576
+ return f ? resolve(f) : undefined;
3577
+ }
3578
+ catch {
3579
+ return undefined;
3580
+ }
3581
+ }
3582
+ /** issue #145:本实例在某 cwd 下正在跑的对话摘要(同项目并行感知用)。 */
3583
+ streamingInCwd(cwd) {
3584
+ const out = [];
3585
+ for (const conv of this.convs.values()) {
3586
+ if (conv.cwd !== cwd || conv.isSubagent)
3587
+ continue;
3588
+ if (!this.conversationStreaming(conv))
3589
+ continue;
3590
+ let sessionFile;
3591
+ try {
3592
+ sessionFile = conv.session.sessionFile ?? undefined;
3593
+ }
3594
+ catch {
3595
+ sessionFile = undefined;
3596
+ }
3597
+ out.push({ convId: conv.id, title: conv.title, sessionFile });
3598
+ }
3599
+ return out;
3600
+ }
3601
+ /** issue #145:本实例所有正在跑的对话摘要(elsewhere 列表的本机一半)。 */
3602
+ streamingSummariesAll() {
3603
+ const out = [];
3604
+ for (const conv of this.convs.values()) {
3605
+ if (conv.isSubagent)
3606
+ continue;
3607
+ if (!this.conversationStreaming(conv))
3608
+ continue;
3609
+ out.push({ title: conv.title, cwd: conv.cwd, isStreaming: true });
3610
+ }
3611
+ return out;
3612
+ }
3613
+ /** issue #145:让其他客户端重推 conversations(elsewhere 刷新用;
3614
+ * 流式集合签名驱动,外层循环安全)。 */
3615
+ refreshExternalRunning() {
3616
+ if (this.disposed)
3617
+ return;
3618
+ this.emitConversations();
3619
+ }
3620
+ /** issue #145:AgentService 代其他客户端向本客户端广播 notice(并行通告用)。 */
3621
+ sendNotice(msg) {
3622
+ this.emit(msg);
3623
+ }
3493
3624
  async prompt(text, attachments,
3494
3625
  /**
3495
3626
  * true = followUp: while streaming, queue the prompt and deliver it only
@@ -3518,6 +3649,73 @@ export class ClientSession {
3518
3649
  // is refused until admission reopens.
3519
3650
  if (this.quiesceBlocked())
3520
3651
  return;
3652
+ // issue #145:发之前再查一次同文件持有者 —— 拦住「打开时空闲、发送时在跑」的竞态。
3653
+ // 没有第二个 writer,就不可能有看不见的第二个 agent。
3654
+ const activeFile = this.activeSessionFileResolved();
3655
+ if (activeFile) {
3656
+ const owner = this.findSessionOwner?.(activeFile);
3657
+ if (owner && owner.isStreaming) {
3658
+ this.emit({
3659
+ type: "notice",
3660
+ level: "warning",
3661
+ text: `发送已拦截:该对话正在另一处运行中(「${owner.title}」)。请等它结束后再发,或回到原窗口继续 —— 否则两个 agent 会同时写同一份记录,其中一支事后不可见。`,
3662
+ textEn: `Prompt blocked: this conversation is running in another window ("${owner.title}"). Wait for it to finish or continue there — two writers on one transcript would leave one run permanently invisible.`,
3663
+ });
3664
+ this.flushSnapshot();
3665
+ return;
3666
+ }
3667
+ }
3668
+ // issue #145:同项目并行感知 —— 同一 cwd 下别处(或其他对话)正在跑时,
3669
+ // 允许并行(可以同时改不同部分),但用户与 AI 都必须知道。只在新一轮启动时
3670
+ // 通告一次(steer/排队等流式中发送不重复打扰)。
3671
+ if (!conv.isSubagent && !s.isStreaming) {
3672
+ const localRunners = [...this.convs.values()]
3673
+ .filter((c) => c.id !== conv.id && !c.isSubagent && c.cwd === conv.cwd && this.conversationStreaming(c))
3674
+ .map((c) => ({ title: c.title }));
3675
+ const externalRunners = (this.listProjectRunners?.(conv.cwd) ?? []).filter((r) => r.sessionFile === undefined || (activeFile !== undefined && resolve(r.sessionFile) !== activeFile));
3676
+ const runnerTitles = [
3677
+ ...localRunners.map((r) => `本窗口「${r.title}」`),
3678
+ ...externalRunners.map((r) => `另一处「${r.title}」`),
3679
+ ];
3680
+ if (runnerTitles.length > 0) {
3681
+ const shown = runnerTitles.slice(0, 3).join("、");
3682
+ const more = runnerTitles.length > 3 ? `等 ${runnerTitles.length} 处` : "";
3683
+ this.emit({
3684
+ type: "notice",
3685
+ level: "info",
3686
+ text: `同项目并行提醒:${shown}${more}正在同一项目运行。你可以继续(适合改不同文件),改动同一文件前请先确认;拿不准就等它跑完。`,
3687
+ textEn: `Parallel-work notice: ${shown}${more ? " and more" : ""} running in the same project. You may continue (fine for different files); confirm before touching the same files, or wait for it to finish when unsure.`,
3688
+ });
3689
+ // 给 AI 的上下文:评估冲突概率,拿不准就 ask_user_question 让用户选
3690
+ // (并行 / 等它跑完 / 只读围观)。display:false —— 用户界面只看上面的 notice。
3691
+ const aiReminder = `(System reminder: ${runnerTitles.length} other run(s) [${runnerTitles.join("; ").slice(0, 600)}] ` +
3692
+ `are currently running in the same project directory. You may work in parallel on different files, ` +
3693
+ `but before reading/writing files or running commands, assess the conflict probability with the other run(s) ` +
3694
+ `(same files? same commands? migrations?). If a conflict is likely or you are unsure, ` +
3695
+ `use ask_user_question to let the user choose: continue in parallel / wait / watch read-only.)\n` +
3696
+ `(系统提醒:同一项目另有 ${runnerTitles.length} 处运行(${shown}${more})。改不同文件可并行;` +
3697
+ `读写文件或跑命令前先评估冲突概率,拿不准就用 ask_user_question 让用户选择:并行 / 等它跑完 / 只读围观。)`;
3698
+ try {
3699
+ await s.sendCustomMessage({
3700
+ customType: "parallel-work-reminder",
3701
+ content: [{ type: "text", text: aiReminder }],
3702
+ display: false,
3703
+ }, { deliverAs: "nextTurn" });
3704
+ }
3705
+ catch {
3706
+ // best effort —— 注入失败不影响发送本身
3707
+ }
3708
+ // 让对端也知道:有人在同项目开了并行工作(只通知其他客户端,不打扰自己)。
3709
+ if (externalRunners.length > 0) {
3710
+ this.notifyExternalClients?.({
3711
+ type: "notice",
3712
+ level: "info",
3713
+ text: `同项目并行提醒:另一处在「${conv.cwd}」开始了对话(「${conv.title}」),可能与你正在跑的任务并行改动同一项目。`,
3714
+ textEn: `Parallel-work notice: another window started a conversation ("${conv.title}") in "${conv.cwd}", possibly editing the same project in parallel with your running task.`,
3715
+ });
3716
+ }
3717
+ }
3718
+ }
3521
3719
  // 轨迹用:暂存本轮任务文本,下一轮 agent_start 消费(steer/内部续跑
3522
3720
  // 不经此处,届时 task 缺省,插件回退为「继续执行」)。
3523
3721
  conv.pendingTask = text.trim() ? truncRun(text.trim(), RUN_TASK_CAP) : undefined;
@@ -4036,6 +4234,108 @@ export class ClientSession {
4036
4234
  * caller must drop it (returns it so removal happens only after the
4037
4235
  * active conversation has been switched away).
4038
4236
  */
4237
+ /** Write a pending-compaction marker into the session file. The SDK treats
4238
+ * unknown custom entries as inert data, so a crash/restart-safe "we were
4239
+ * compacting" record survives in the transcript itself — no sidecar file
4240
+ * to orphan or clean up. Removed on compaction_end. */
4241
+ markCompactionPending(conv) {
4242
+ try {
4243
+ const file = conv.session.sessionFile;
4244
+ if (!file)
4245
+ return;
4246
+ // parentId joins the live chain: a null-parent marker would become a
4247
+ // second root and hijack the leaf, corrupting the transcript.
4248
+ const marker = {
4249
+ type: "custom",
4250
+ id: "pi-web-ui-compaction-pending",
4251
+ parentId: conv.session.sessionManager.getLeafId(),
4252
+ timestamp: new Date().toISOString(),
4253
+ customType: "pi-web-ui/compaction-pending",
4254
+ data: {
4255
+ reason: conv.compactionState?.reason ?? "manual",
4256
+ startedAt: conv.compactionState?.startedAt ?? Date.now(),
4257
+ },
4258
+ };
4259
+ appendFileSync(file, `${JSON.stringify(marker)}\n`);
4260
+ }
4261
+ catch {
4262
+ // Marker is best-effort: compaction still runs without it, only the
4263
+ // restart-detection below is lost.
4264
+ }
4265
+ }
4266
+ /** Close out the pending-compaction marker (called on compaction_end). The
4267
+ * session file is append-only through the SDK, so rewrite the file with the
4268
+ * pending marker replaced by a completion marker carrying the outcome
4269
+ * (completed / failed / cancelled + token counts). The transcript then holds
4270
+ * a durable started→finished record instead of a silent gap. No-op when the
4271
+ * file or marker is absent. */
4272
+ clearCompactionPending(conv, outcome) {
4273
+ try {
4274
+ const file = conv.session.sessionFile;
4275
+ if (!file || !existsSync(file))
4276
+ return;
4277
+ const id = "pi-web-ui-compaction-pending";
4278
+ const raw = readFileSync(file, "utf8");
4279
+ const lines = raw.split("\n");
4280
+ const idx = lines.findIndex((line) => line.includes(`"${id}"`));
4281
+ if (idx < 0)
4282
+ return;
4283
+ // ponytail: full-file rewrite on compaction end — compactions are rare
4284
+ // (seconds apart at most), session files are KBs; no streaming needed.
4285
+ if (!outcome) {
4286
+ lines.splice(idx, 1);
4287
+ }
4288
+ else {
4289
+ let parentId = null;
4290
+ try {
4291
+ parentId = conv.session.sessionManager.getLeafId();
4292
+ }
4293
+ catch {
4294
+ // fall through with null parent
4295
+ }
4296
+ const done = {
4297
+ type: "custom",
4298
+ id: "pi-web-ui-compaction-done",
4299
+ parentId,
4300
+ timestamp: new Date().toISOString(),
4301
+ customType: "pi-web-ui/compaction-done",
4302
+ data: { reason: conv.compactionState?.reason ?? "manual", ...outcome },
4303
+ };
4304
+ lines[idx] = JSON.stringify(done);
4305
+ }
4306
+ writeFileSync(file, lines.join("\n"));
4307
+ }
4308
+ catch {
4309
+ // Best-effort, same as the write path.
4310
+ }
4311
+ }
4312
+ /** Check a freshly opened session for a leftover compaction-pending marker.
4313
+ * A marker with no matching compaction_end means the server died mid-compaction
4314
+ * (restart/crash): the in-flight summary is gone, but the session is intact.
4315
+ * Surface a warning notice with a one-click retry (/compact) instead of
4316
+ * silently dropping it. Consumes the marker either way. */
4317
+ noticeInterruptedCompaction(conv) {
4318
+ try {
4319
+ const file = conv.session.sessionFile;
4320
+ if (!file || !existsSync(file))
4321
+ return;
4322
+ // Match on the stable customType (conversation ids change every restart).
4323
+ const raw = readFileSync(file, "utf8");
4324
+ if (!raw.includes('"pi-web-ui/compaction-pending"'))
4325
+ return;
4326
+ const kept = raw.split("\n").filter((line) => !line.includes("pi-web-ui/compaction-pending"));
4327
+ writeFileSync(file, kept.join("\n"));
4328
+ this.emit({
4329
+ type: "notice",
4330
+ level: "warning",
4331
+ text: "上次压缩上下文被服务端重启打断,未完成。可发送 /compact 重试。",
4332
+ textEn: "The last context compaction was interrupted by a server restart and did not finish. Send /compact to retry.",
4333
+ });
4334
+ }
4335
+ catch {
4336
+ // Best-effort, same as the write path.
4337
+ }
4338
+ }
4039
4339
  displaceActive() {
4040
4340
  const conv = this.conv;
4041
4341
  // 子代理不受切换关闭影响(见上)。
@@ -4068,6 +4368,7 @@ export class ClientSession {
4068
4368
  reviewing: conv.goal.reviewing,
4069
4369
  wizardRunning: conv.wizardRunning,
4070
4370
  streaming: conv.session.isStreaming,
4371
+ compacting: conv.session.isCompacting,
4071
4372
  openTerminals: conv.terminals.countLive(),
4072
4373
  listed: conv.listed,
4073
4374
  promptedSinceActive: conv.promptedSinceActive,
@@ -4118,12 +4419,13 @@ export class ClientSession {
4118
4419
  void this.pushSlashCommands();
4119
4420
  if (cwdChanged) {
4120
4421
  this.cwd = newCwd;
4422
+ this.roots = this.stateStore.getWorkspaceRoots(this.clientId, newCwd);
4121
4423
  await this.restoreProjectProviderKeysForCwd(newCwd);
4122
4424
  await this.restoreProjectModelForCwd(newCwd);
4123
4425
  // Mirror set_cwd's project-switch side-effects so the whole UI follows
4124
4426
  // the new workspace, not just the chat pane.
4125
4427
  try {
4126
- this.onCwdChanged?.(newCwd);
4428
+ this.onCwdChanged?.(newCwd, this.roots);
4127
4429
  }
4128
4430
  catch {
4129
4431
  /* hook failure must not break the switch */
@@ -4204,13 +4506,34 @@ export class ClientSession {
4204
4506
  parentId: conv.parentId,
4205
4507
  });
4206
4508
  }
4509
+ // issue #145:流式集合签名变化 → 通知其他客户端重推(左栏「另一处正在运行」近实时)
4510
+ try {
4511
+ const sig = JSON.stringify([...this.convs.values()]
4512
+ .filter((c) => this.conversationStreaming(c))
4513
+ .map((c) => c.id)
4514
+ .sort());
4515
+ if (sig !== this.lastRunningSig) {
4516
+ this.lastRunningSig = sig;
4517
+ this.onRunningChanged?.();
4518
+ }
4519
+ }
4520
+ catch {
4521
+ // 会话替换中——跳过本轮签名比较
4522
+ }
4523
+ const elsewhere = this.listExternalRunning?.() ?? [];
4207
4524
  this.emit({
4208
4525
  type: "conversations",
4209
4526
  conversations,
4210
4527
  activeId: this.activeId,
4528
+ // 为空时缺省(老快照字节一致)
4529
+ ...(elsewhere.length > 0 ? { elsewhere } : {}),
4211
4530
  });
4212
4531
  }
4213
4532
  /** List persisted sessions for this client, newest first. */
4533
+ /** issue #145:上次 emit 时本实例流式对话 id 集合签名。变化时经
4534
+ * onRunningChanged 让其他客户端重推 conversations(elsewhere 近实时);
4535
+ * 签名相等即停,天然防 ping-pong 循环。 */
4536
+ lastRunningSig = "";
4214
4537
  /** The client asked for the session list at least once (lazy loading) —
4215
4538
  * background refreshes only re-push when this is true, so a mobile
4216
4539
  * client that never opened the panel never pays the disk scan. */
@@ -4885,6 +5208,32 @@ export class ClientSession {
4885
5208
  return;
4886
5209
  }
4887
5210
  }
5211
+ // issue #145:同一文件在别处已有持有者 —— 绝不建第二个 writer。
5212
+ // 正在跑:直接拒绝(否则两支 run 并发写同一份 JSONL,事后只有一支可读);
5213
+ // 空闲:放行打开(只剩一处能发送时不会分叉),但提醒用户别处也开着,
5214
+ // 发消息前的 prompt() 守卫会再查一次(开时空闲、发时在跑的竞态也拦得住)。
5215
+ const owner = this.findSessionOwner?.(targetPath);
5216
+ if (owner && owner.isStreaming) {
5217
+ this.emit({
5218
+ type: "notice",
5219
+ level: "warning",
5220
+ text: `该对话正在另一处运行中(「${owner.title}」),为避免两个 agent 同时写同一份记录,已停止打开。请等它结束后再试,或回到原窗口继续。`,
5221
+ textEn: `This conversation is running in another window ("${owner.title}"). Opening it here would create a second writer for the same transcript, so it was blocked. Wait for it to finish, or continue in the original window.`,
5222
+ });
5223
+ this.flushSnapshot();
5224
+ return;
5225
+ }
5226
+ if (owner) {
5227
+ // 对端已断开(标签页关了)只剩残留会话 —— 不打扰,直接开。
5228
+ if (owner.connected) {
5229
+ this.emit({
5230
+ type: "notice",
5231
+ level: "info",
5232
+ text: `提醒:该对话在另一处也开着(「${owner.title}」,当前空闲)。请只留一处发送消息,否则两边轮流发送会让历史分叉、其中一支事后不可见。`,
5233
+ textEn: `Note: this conversation is also open in another window ("${owner.title}", currently idle). Send new messages from only one place — alternating between two writers forks the history and hides one branch.`,
5234
+ });
5235
+ }
5236
+ }
4888
5237
  const sessionManager = SessionManager.open(targetPath);
4889
5238
  const targetCwd = sessionManager.getCwd();
4890
5239
  const conversationId = this.nextConversationId();
@@ -4922,6 +5271,7 @@ export class ClientSession {
4922
5271
  // Deliberately resumed — must not be dismissed when the user later
4923
5272
  // switches away without sending a new message.
4924
5273
  conv.promptedSinceActive = true;
5274
+ this.noticeInterruptedCompaction(conv);
4925
5275
  this.convs.set(conv.id, conv);
4926
5276
  this.activeId = conv.id;
4927
5277
  openedRuntime = null;
@@ -5186,6 +5536,38 @@ export class ClientSession {
5186
5536
  async completePath(input) {
5187
5537
  return this.files.completePath(input);
5188
5538
  }
5539
+ /** 当前项目的额外工作区根(空数组 = 单根)。 */
5540
+ get workspaceRoots() {
5541
+ return this.roots;
5542
+ }
5543
+ /**
5544
+ * 设置当前项目的额外工作区根(宿主侧多根,见 protocol 的 set_workspace_roots)。
5545
+ *
5546
+ * 语义:AI 仍只在主 cwd 里干活(pi SDK 是单 cwd 模型),多根只影响「哪些路径算
5547
+ * 工作区内」—— 右栏文件树可跨根浏览,插件的 host.fs / host.project.create 不必
5548
+ * 再走授权就能读这些根(所以它是用户/宿主侧动作,不是插件能静默做的)。
5549
+ *
5550
+ * 归一化交给 ClientStateStore(只收绝对路径 / 去重 / 上限 8)。刻意**不**校验
5551
+ * 目录是否存在:根可能是暂时断开的盘或挂载点,不该把用户设过的根静默清掉。
5552
+ */
5553
+ async setWorkspaceRoots(roots) {
5554
+ const before = this.stateStore.getWorkspaceRoots(this.clientId, this.cwd);
5555
+ this.stateStore.saveWorkspaceRoots(this.clientId, this.cwd, roots ?? []);
5556
+ const saved = this.stateStore.getWorkspaceRoots(this.clientId, this.cwd);
5557
+ if (saved.length === before.length && saved.every((p, i) => p === before[i])) {
5558
+ // 没变化(重复点 / 重放的旧命令):不打扰插件、不推快照。
5559
+ return;
5560
+ }
5561
+ this.roots = saved;
5562
+ try {
5563
+ // 同一个钩子:插件宿主要跟着把「工作区内的路径」重新算一遍。
5564
+ this.onCwdChanged?.(this.cwd, this.roots);
5565
+ }
5566
+ catch {
5567
+ /* 钩子异常不影响主流程 */
5568
+ }
5569
+ this.flushSnapshot();
5570
+ }
5189
5571
  async setCwd(newCwd) {
5190
5572
  try {
5191
5573
  const { resolve, sep } = await import("node:path");
@@ -5240,13 +5622,34 @@ export class ClientSession {
5240
5622
  this.removeConversation(displaced.id);
5241
5623
  }
5242
5624
  else {
5243
- // First visit to this project: resume its most recent session.
5625
+ // First visit to this project: resume its most recent session
5626
+ // unless that transcript is streaming on another client (#145):
5627
+ // default-opening it would strand the tab on a conversation it
5628
+ // cannot use (the prompt guard refuses) with a stale leaf that
5629
+ // forks history once the owner finishes. Land blank instead.
5630
+ let sessionManager = SessionManager.continueRecent(abs);
5631
+ let resumeSkipped = null;
5632
+ // 无人在跑时不扫目录(首访切项目的常见情形零开销)。
5633
+ if (this.hasStreamingElsewhere?.() ?? false) {
5634
+ try {
5635
+ const infos = await SessionManager.list(abs, piSessionsRoot());
5636
+ const recent = infos[0]?.path ? resolve(infos[0].path) : undefined;
5637
+ const owner = recent ? this.findSessionOwner?.(recent) : null;
5638
+ if (owner?.isStreaming) {
5639
+ sessionManager = SessionManager.create(abs);
5640
+ resumeSkipped = owner;
5641
+ }
5642
+ }
5643
+ catch {
5644
+ // 列表失败不挡正常恢复
5645
+ }
5646
+ }
5244
5647
  const conversationId = this.nextConversationId();
5245
5648
  const terminals = this.makeTerminalManager(conversationId, abs);
5246
5649
  const newRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, undefined, conversationId), {
5247
5650
  cwd: abs,
5248
5651
  agentDir: this.agentDir,
5249
- sessionManager: SessionManager.continueRecent(abs),
5652
+ sessionManager,
5250
5653
  });
5251
5654
  const conv = this.makeConversation(newRuntime, conversationId, terminals);
5252
5655
  this.convs.set(conv.id, conv);
@@ -5259,16 +5662,25 @@ export class ClientSession {
5259
5662
  }
5260
5663
  }
5261
5664
  await this.bindSession();
5665
+ if (resumeSkipped) {
5666
+ this.emit({
5667
+ type: "notice",
5668
+ level: "info",
5669
+ text: `该项目最近的对话「${resumeSkipped.title}」正在另一处运行,为你停在了新对话 —— 直接打开会造出第二个写者。左栏「运行的对话」里能看到它(标着“另一处”),等它跑完再打开。`,
5670
+ textEn: `The most recent conversation ("${resumeSkipped.title}") is running in another window, so you landed on a new chat instead — opening it here would create a second writer. It is listed under Running chats (tagged "Elsewhere"); open it after it finishes.`,
5671
+ });
5672
+ }
5262
5673
  }
5263
5674
  this.pushTerminals();
5264
5675
  this.conv.promptedSinceActive = false;
5265
5676
  this.conv.lastActiveAt = Date.now();
5266
5677
  this.cwd = abs;
5678
+ this.roots = this.stateStore.getWorkspaceRoots(this.clientId, abs);
5267
5679
  await this.restoreProjectProviderKeysForCwd(abs);
5268
5680
  await this.restoreProjectModelForCwd(abs);
5269
5681
  // 工作区跟随型插件(编辑器文件树等)同步切根。
5270
5682
  try {
5271
- this.onCwdChanged?.(abs);
5683
+ this.onCwdChanged?.(abs, this.roots);
5272
5684
  }
5273
5685
  catch {
5274
5686
  /* 钩子异常不影响主流程 */
@@ -5518,8 +5930,9 @@ export class AgentService {
5518
5930
  stateStore;
5519
5931
  /** Set by index.ts: called when /pi-web-ui:quit is invoked. */
5520
5932
  onQuit = undefined;
5521
- /** 任意客户端成功切换工作区后触发(新绝对路径)。index.ts 接到
5522
- * PluginManager.notifyCwd,让插件宿主的 host.cwd 实时跟随当前项目。 */
5933
+ /** 任意客户端成功切换工作区后触发(新绝对路径 + 该项目的额外工作区根)。
5934
+ * index.ts 接到 PluginManager.notifyCwd / notifyWorkspaceRoots,让插件宿主的
5935
+ * host.cwd 实时跟随当前项目、受支持路径范围跟着多根变。 */
5523
5936
  onClientCwdChanged = undefined;
5524
5937
  constructor(cwd, stateFile) {
5525
5938
  this.cwd = cwd;
@@ -5544,6 +5957,89 @@ export class AgentService {
5544
5957
  quiesceInfo() {
5545
5958
  return this.quiesced ? { quiesced: true, quiescedSince: this.quiescedAt } : { quiesced: false };
5546
5959
  }
5960
+ /** issue #145:除请求方外是否有客户端正在跑(扫目录查重前置的无 I/O 判断)。 */
5961
+ hasStreamingElsewhere(excludeClientId) {
5962
+ for (const [clientId, cs] of this.clients) {
5963
+ if (clientId === excludeClientId)
5964
+ continue;
5965
+ try {
5966
+ if (cs.activeConversations() > 0)
5967
+ return true;
5968
+ }
5969
+ catch {
5970
+ // 单客户端坏了不影响判断
5971
+ }
5972
+ }
5973
+ return false;
5974
+ }
5975
+ /** issue #145:跨客户端同会话查重 —— 找持有某 session 文件的别处对话。
5976
+ * 调用方在 SessionManager.open() 之前问这一句,就造不出第二个 writer。 */
5977
+ findSessionOwner(targetPath, excludeClientId) {
5978
+ for (const [clientId, cs] of this.clients) {
5979
+ if (clientId === excludeClientId)
5980
+ continue;
5981
+ const conv = cs.findConversationBySessionFile(targetPath);
5982
+ if (conv) {
5983
+ return {
5984
+ clientId,
5985
+ title: conv.title,
5986
+ cwd: conv.cwd,
5987
+ isStreaming: cs.conversationStreaming(conv),
5988
+ connected: cs.sinkCount() > 0,
5989
+ };
5990
+ }
5991
+ }
5992
+ return null;
5993
+ }
5994
+ /** issue #145:别处在某 cwd 下正在跑的对话(同项目并行感知用,不含请求方)。 */
5995
+ listProjectRunners(cwd, excludeClientId) {
5996
+ const out = [];
5997
+ for (const [clientId, cs] of this.clients) {
5998
+ if (clientId === excludeClientId)
5999
+ continue;
6000
+ for (const r of cs.streamingInCwd(cwd))
6001
+ out.push({ clientId, title: r.title, sessionFile: r.sessionFile });
6002
+ }
6003
+ return out;
6004
+ }
6005
+ /** issue #145:别处所有正在跑的对话(左栏 elsewhere 只读感知用)。 */
6006
+ listExternalRunning(excludeClientId) {
6007
+ const out = [];
6008
+ for (const [clientId, cs] of this.clients) {
6009
+ if (clientId === excludeClientId)
6010
+ continue;
6011
+ for (const r of cs.streamingSummariesAll())
6012
+ out.push(r);
6013
+ }
6014
+ return out;
6015
+ }
6016
+ /** issue #145:某客户端流式集合变化 → 其他客户端重推 conversations。 */
6017
+ pokeExternalRunning(excludeClientId) {
6018
+ for (const [clientId, cs] of this.clients) {
6019
+ if (clientId === excludeClientId)
6020
+ continue;
6021
+ try {
6022
+ cs.refreshExternalRunning();
6023
+ }
6024
+ catch {
6025
+ // 单客户端坏了不影响其他
6026
+ }
6027
+ }
6028
+ }
6029
+ /** issue #145:向除请求方外的所有客户端发一条 notice(并行通告用)。 */
6030
+ notifyClientsExcept(excludeClientId, msg) {
6031
+ for (const [clientId, cs] of this.clients) {
6032
+ if (clientId === excludeClientId)
6033
+ continue;
6034
+ try {
6035
+ // 经 ClientSession.emit 才能进该客户端的 sink 组播;用公开发送面。
6036
+ cs.sendNotice(msg);
6037
+ }
6038
+ catch {
6039
+ // 单客户端坏了不影响其他
6040
+ }
6041
+ }
6042
+ }
5547
6043
  /** Aggregate across every client session: conversations with in-flight runs. */
5548
6044
  activeConversations() {
5549
6045
  let n = 0;
@@ -5644,12 +6140,35 @@ export class AgentService {
5644
6140
  }
5645
6141
  }
5646
6142
  // Sessions use the SDK default per-project dir — no per-client dir.
5647
- const creating = ClientSession.create(clientId, cwd, this.stateStore).finally(() => {
6143
+ // issue #145:新标签页默认恢复项目最近的会话 —— 若那条在别处跑着,
6144
+ // 建之前就决定空白(第二个 writer 根本不会被打开,也无需事后拆 runtime)。
6145
+ // 先做无 I/O 的便宜判断,无人在跑时不扫目录。
6146
+ let createOpts;
6147
+ if (this.hasStreamingElsewhere(clientId)) {
6148
+ try {
6149
+ const infos = await SessionManager.list(cwd, piSessionsRoot());
6150
+ const recent = infos[0]?.path ? resolve(infos[0].path) : undefined;
6151
+ const owner = recent ? this.findSessionOwner(recent, clientId) : null;
6152
+ if (owner?.isStreaming)
6153
+ createOpts = { blank: true, blankTitle: owner.title };
6154
+ }
6155
+ catch {
6156
+ // 列表失败不挡正常恢复
6157
+ }
6158
+ }
6159
+ const creating = ClientSession.create(clientId, cwd, this.stateStore, createOpts).finally(() => {
5648
6160
  this.pending.delete(clientId);
5649
6161
  });
5650
6162
  this.pending.set(clientId, creating);
5651
6163
  cs = await creating;
5652
6164
  this.clients.set(clientId, cs);
6165
+ // issue #145 接线提前:首帧 elsewhere 依赖它。
6166
+ cs.findSessionOwner = (targetPath) => this.findSessionOwner(targetPath, clientId);
6167
+ cs.hasStreamingElsewhere = () => this.hasStreamingElsewhere(clientId);
6168
+ cs.listProjectRunners = (cwd) => this.listProjectRunners(cwd, clientId);
6169
+ cs.listExternalRunning = () => this.listExternalRunning(clientId);
6170
+ cs.notifyExternalClients = (msg) => this.notifyClientsExcept(clientId, msg);
6171
+ cs.onRunningChanged = () => this.pokeExternalRunning(clientId);
5653
6172
  // Make sure the restored/default workspace appears in the project list.
5654
6173
  this.stateStore.remember(clientId, cwd);
5655
6174
  if (cwd !== this.cwd) {
@@ -5677,10 +6196,17 @@ export class AgentService {
5677
6196
  cs.pluginBgTasksProvider = this.pluginBgTasksProvider;
5678
6197
  cs.pluginStopBgTask = this.pluginStopBgTask;
5679
6198
  cs.isQuiesced = () => this.quiesced;
6199
+ // issue #145 跨客户端感知接线(同会话查重 / 同项目并行 / elsewhere 列表)。
6200
+ cs.findSessionOwner = (targetPath) => this.findSessionOwner(targetPath, clientId);
6201
+ cs.hasStreamingElsewhere = () => this.hasStreamingElsewhere(clientId);
6202
+ cs.listProjectRunners = (cwd) => this.listProjectRunners(cwd, clientId);
6203
+ cs.listExternalRunning = () => this.listExternalRunning(clientId);
6204
+ cs.notifyExternalClients = (msg) => this.notifyClientsExcept(clientId, msg);
6205
+ cs.onRunningChanged = () => this.pokeExternalRunning(clientId);
5680
6206
  // 插件宿主工作区跟随:初次接入也同步一次(恢复的 lastCwd 可能≠服务启动目录),
5681
6207
  // notifyCwd 幂等去重;此后 set_cwd 成功时由 cs.onCwdChanged 继续驱动。
5682
- cs.onCwdChanged = (abs) => this.onClientCwdChanged?.(abs);
5683
- this.onClientCwdChanged?.(cs.cwd);
6208
+ cs.onCwdChanged = (abs, roots) => this.onClientCwdChanged?.(abs, roots);
6209
+ this.onClientCwdChanged?.(cs.cwd, cs.workspaceRoots);
5684
6210
  return cs;
5685
6211
  }
5686
6212
  /** 插件 AI 工具集合变化(注册/注销)时由 index.ts 触发:推送到所有客户端的全部会话。 */