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.
- package/CHANGELOG.md +124 -1
- package/bin/pi-web-ui.mjs +98 -8
- package/dist/server/agent-service.js +542 -16
- package/dist/server/client-state.js +99 -1
- package/dist/server/dsh/dsh-agent-service.js +239 -7
- package/dist/server/index.js +205 -3
- package/dist/server/managed.js +4 -0
- package/dist/server/plugin-catalog-sync.js +117 -0
- package/dist/server/plugin-catalog.js +67 -0
- package/dist/server/plugin-grants.js +281 -0
- package/dist/server/plugin-installer.js +238 -0
- package/dist/server/plugin-project.js +406 -0
- package/dist/server/plugins.js +390 -4
- package/dist/server/settings-service.js +34 -1
- package/dist/server/terminals.js +7 -2
- package/dist/server/update-check.js +100 -6
- package/dist/server/wait-subscription-scan.js +1 -1
- package/package.json +1 -1
- package/themes/dark-teal.css +209 -0
- package/web/dist/assets/TerminalPanel-BytY8dx7.js +6 -0
- package/web/dist/assets/index-CKoVyDkP.css +10 -0
- package/web/dist/assets/index-Dmwji4Cr.js +361 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/TerminalPanel-CNz4vxHW.js +0 -6
- package/web/dist/assets/index-BjODtvwn.css +0 -10
- package/web/dist/assets/index-DM-A59lk.js +0 -348
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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
|
-
/**
|
|
5522
|
-
* PluginManager.notifyCwd
|
|
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
|
-
|
|
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 触发:推送到所有客户端的全部会话。 */
|