@youweichen/pi-harness 1.0.0 → 1.1.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.
- package/LICENSE +1 -1
- package/dist/server/agent-service.js +87 -7
- package/dist/server/control-socket.js +1 -0
- package/dist/server/extensions-worker.js +3 -2
- package/dist/server/files-service.js +8 -16
- package/dist/server/index.js +7 -3
- package/dist/server/node-agent.js +262 -0
- package/dist/server/node-workbench.js +62 -1
- package/dist/server/plan/extension.js +4 -4
- package/dist/server/plan/state.js +11 -1
- package/dist/server/slash-commands.js +6 -0
- package/dist/server/wiki-routes.js +8 -1
- package/dist/server/wiki-service.js +13 -0
- package/package.json +1 -1
- package/web/dist/assets/NodeWorkbench-BiP2ktQP.js +3 -0
- package/web/dist/assets/{TerminalPanel-CWh6vw_N.js → TerminalPanel-CKaSUsN-.js} +1 -1
- package/web/dist/assets/{abnfDiagram-VCTEODGH-D5Ti0Kpa.js → abnfDiagram-VCTEODGH-CnwRJDRL.js} +1 -1
- package/web/dist/assets/{arc-DKJilLd6.js → arc-CG4Q7-W_.js} +1 -1
- package/web/dist/assets/{architectureDiagram-5GKGNRK7-Cf0w38g-.js → architectureDiagram-5GKGNRK7-BrGqX_Rg.js} +1 -1
- package/web/dist/assets/{blockDiagram-I7D4REHJ-BOTLWLIL.js → blockDiagram-I7D4REHJ-C2t0PjZT.js} +1 -1
- package/web/dist/assets/{c4Diagram-7LVT6UL2-CLCCoJs3.js → c4Diagram-7LVT6UL2-CadrcgGL.js} +1 -1
- package/web/dist/assets/channel-C27Jizb6.js +1 -0
- package/web/dist/assets/{chunk-2Q5K7J3B-BHjga2Ge.js → chunk-2Q5K7J3B-CC3V2ITZ.js} +1 -1
- package/web/dist/assets/{chunk-5VM5RSS4-CQJ5VfDZ.js → chunk-5VM5RSS4-DqKSbGmE.js} +1 -1
- package/web/dist/assets/{chunk-F27PBJKO-C67qoPJH.js → chunk-F27PBJKO-_CDr3JbY.js} +1 -1
- package/web/dist/assets/{chunk-IMKFNOWR-CgXXGwI8.js → chunk-IMKFNOWR-BIR4H4fX.js} +1 -1
- package/web/dist/assets/{chunk-JWPE2WC7-D0RPyByP.js → chunk-JWPE2WC7-CrsdBGXT.js} +1 -1
- package/web/dist/assets/{chunk-POPQ4Y6H-BeYC9s9x.js → chunk-POPQ4Y6H-C6q9kQmf.js} +1 -1
- package/web/dist/assets/{chunk-SVP7TREG-eQPl4UBQ.js → chunk-SVP7TREG-l2XXTr2v.js} +1 -1
- package/web/dist/assets/{chunk-TICWLB2K-DT73arPU.js → chunk-TICWLB2K-29I_mZgB.js} +1 -1
- package/web/dist/assets/{chunk-XXDRQBXY-B4FwQcP-.js → chunk-XXDRQBXY-htV9FBqi.js} +1 -1
- package/web/dist/assets/classDiagram-ZZMXUADV-DYTBrAEP.js +1 -0
- package/web/dist/assets/classDiagram-v2-VYDZK3BY-DYTBrAEP.js +1 -0
- package/web/dist/assets/{cose-bilkent-JH36ORCC-Bnn5nlQN.js → cose-bilkent-JH36ORCC-Cop-ihyx.js} +1 -1
- package/web/dist/assets/{cynefin-OW5HDTMX-CStWX9kE.js → cynefin-OW5HDTMX-IEk8qBRq.js} +1 -1
- package/web/dist/assets/{cynefinDiagram-5FMLGOSQ-BMVHC03f.js → cynefinDiagram-5FMLGOSQ-Bmzt6Qb5.js} +1 -1
- package/web/dist/assets/{dagre-GXQ25YYZ-ClSaO4ml.js → dagre-GXQ25YYZ-DiXTJYYU.js} +1 -1
- package/web/dist/assets/{diagram-S7CK7UJ4-C43LL08C.js → diagram-S7CK7UJ4-BT7CLuNk.js} +1 -1
- package/web/dist/assets/{diagram-UQ7AKVKN-BPVn_ySW.js → diagram-UQ7AKVKN-BZZ3nTCO.js} +1 -1
- package/web/dist/assets/{diagram-VSXAHHWV-C34FFhRi.js → diagram-VSXAHHWV-Dal8LeZh.js} +1 -1
- package/web/dist/assets/{diagram-VX7I27RA-BRk4bncf.js → diagram-VX7I27RA-D6V9FfNA.js} +1 -1
- package/web/dist/assets/{diagram-Z3DM3KII-O5hYPLEd.js → diagram-Z3DM3KII-KUAIcFiD.js} +1 -1
- package/web/dist/assets/{ebnfDiagram-PWID7BFC-DVGEBm99.js → ebnfDiagram-PWID7BFC-s0FEPZrV.js} +1 -1
- package/web/dist/assets/{erDiagram-RLTQ6QDP-M-BLpTEV.js → erDiagram-RLTQ6QDP-CiIQPFCD.js} +1 -1
- package/web/dist/assets/{flowDiagram-HODETNUW-BwdLopEx.js → flowDiagram-HODETNUW-CvG4MYWn.js} +1 -1
- package/web/dist/assets/{ganttDiagram-EL5Y4UJY-DYxx2ppA.js → ganttDiagram-EL5Y4UJY-CUf115ii.js} +1 -1
- package/web/dist/assets/{gitGraphDiagram-WWUBYQGX-DUU0wMYB.js → gitGraphDiagram-WWUBYQGX-o3K1r6fL.js} +1 -1
- package/web/dist/assets/index-C3vDFSEV.css +10 -0
- package/web/dist/assets/index-C5or_GAq.js +276 -0
- package/web/dist/assets/{infoDiagram-27XIBGKW-CFaW5C0C.js → infoDiagram-27XIBGKW-BxiBeQ8E.js} +1 -1
- package/web/dist/assets/{ishikawaDiagram-5VMMS53U--ENP1MTB.js → ishikawaDiagram-5VMMS53U-B1ONuCkH.js} +1 -1
- package/web/dist/assets/{journeyDiagram-3NMN7TZE-DgnAzSTj.js → journeyDiagram-3NMN7TZE-DwmuoXcX.js} +1 -1
- package/web/dist/assets/{kanban-definition-UXKFOSKX-_STZN1vU.js → kanban-definition-UXKFOSKX-pMKHubxH.js} +1 -1
- package/web/dist/assets/{layout-CkUClkXt.js → layout-CEwfjQR0.js} +1 -1
- package/web/dist/assets/{linear-sYHiUOxa.js → linear-D22Gdbt1.js} +1 -1
- package/web/dist/assets/{markdown-vNTCZJ6d.js → markdown-BhJ_LInD.js} +1 -1
- package/web/dist/assets/{mermaid.core-B8fKGWsc.js → mermaid.core-CO2nrD04.js} +5 -5
- package/web/dist/assets/{mindmap-definition-YA3MSWOX-DP0BYOEx.js → mindmap-definition-YA3MSWOX-9MQn9ZAp.js} +1 -1
- package/web/dist/assets/{pegDiagram-XKGWAZYB-DkWTmZre.js → pegDiagram-XKGWAZYB-Dy6fLjM-.js} +1 -1
- package/web/dist/assets/{pieDiagram-E7YTZNPT-DfZgMV4u.js → pieDiagram-E7YTZNPT-EDLez6nA.js} +1 -1
- package/web/dist/assets/{quadrantDiagram-AXDQQJYC-Djl4TT7q.js → quadrantDiagram-AXDQQJYC-Cgcd9mvy.js} +1 -1
- package/web/dist/assets/{railroadDiagram-O6MQD6OU-DbKF6zXn.js → railroadDiagram-O6MQD6OU-B4Td0tjj.js} +1 -1
- package/web/dist/assets/{react-BNj3n7jX.js → react-BddUxV-9.js} +1 -1
- package/web/dist/assets/{requirementDiagram-BXWQKSXE-idola5qb.js → requirementDiagram-BXWQKSXE-aF2QNZ78.js} +1 -1
- package/web/dist/assets/{sankeyDiagram-P5KCCOFB-KA0hQOle.js → sankeyDiagram-P5KCCOFB-DgTYwYMa.js} +1 -1
- package/web/dist/assets/{sequenceDiagram-WJ2MYXX4-BXSKvxzP.js → sequenceDiagram-WJ2MYXX4-EUcddWUc.js} +1 -1
- package/web/dist/assets/{sizeCapture-INFHLROL-C_Ewv3S5.js → sizeCapture-INFHLROL-CXHOv9Sx.js} +1 -1
- package/web/dist/assets/{stateDiagram-D77RDMKH-DIf5X8oo.js → stateDiagram-D77RDMKH-B-xlgfaK.js} +1 -1
- package/web/dist/assets/stateDiagram-v2-MP3YSRHH-kwAwAlWX.js +1 -0
- package/web/dist/assets/{swimlanes-42K2YHIH-Cx_Rc8M2.js → swimlanes-42K2YHIH-BtFBOCDh.js} +1 -1
- package/web/dist/assets/swimlanesDiagram-VR7AAH4N-D5j5Cj3S.js +8 -0
- package/web/dist/assets/{timeline-definition-24CTP7MA-BRrDhWVz.js → timeline-definition-24CTP7MA-CZlQY6pb.js} +1 -1
- package/web/dist/assets/{vennDiagram-4TSXK5OY-Ad6G0YwD.js → vennDiagram-4TSXK5OY-Cn61dJvL.js} +1 -1
- package/web/dist/assets/{wardleyDiagram-VM6X3IG4-X6-ENpSi.js → wardleyDiagram-VM6X3IG4-fiMNrWpr.js} +1 -1
- package/web/dist/assets/{xychartDiagram-S5SC5T6Z-DhegNS6d.js → xychartDiagram-S5SC5T6Z-CuzM_sof.js} +1 -1
- package/web/dist/index.html +4 -4
- package/web/dist/assets/NodeWorkbench-C4U1PldE.js +0 -3
- package/web/dist/assets/channel-D8Hd7T4P.js +0 -1
- package/web/dist/assets/classDiagram-ZZMXUADV-B_btgYRB.js +0 -1
- package/web/dist/assets/classDiagram-v2-VYDZK3BY-B_btgYRB.js +0 -1
- package/web/dist/assets/index-CI2KfVSr.css +0 -10
- package/web/dist/assets/index-DQcpggFU.js +0 -275
- package/web/dist/assets/stateDiagram-v2-MP3YSRHH-DYwe_2mB.js +0 -1
- package/web/dist/assets/swimlanesDiagram-VR7AAH4N-52Y8tgRV.js +0 -8
package/LICENSE
CHANGED
|
@@ -504,7 +504,7 @@ export class ClientSession {
|
|
|
504
504
|
* (the model choice is client-wide), so later conversations reuse the
|
|
505
505
|
* instance created with the first one.
|
|
506
506
|
*/
|
|
507
|
-
makeRuntimeFactory() {
|
|
507
|
+
makeRuntimeFactory(refreshResources = false, state) {
|
|
508
508
|
return async ({ cwd, sessionManager }) => {
|
|
509
509
|
const services = await createAgentSessionServices({
|
|
510
510
|
cwd,
|
|
@@ -513,7 +513,12 @@ export class ClientSession {
|
|
|
513
513
|
settingsManager: conversationSettings(cwd, this.agentDir),
|
|
514
514
|
resourceLoaderOptions: { extensionFactories: nativeToolExtensions() },
|
|
515
515
|
});
|
|
516
|
-
|
|
516
|
+
// A second native reload invalidates the process-wide extension factory cache.
|
|
517
|
+
if (refreshResources) {
|
|
518
|
+
await services.resourceLoader.reload();
|
|
519
|
+
refreshResources = false;
|
|
520
|
+
}
|
|
521
|
+
const created = await createAgentSessionFromServices({ services, sessionManager, model: state?.model, thinkingLevel: state?.thinkingLevel });
|
|
517
522
|
return { ...created, services, diagnostics: services.diagnostics };
|
|
518
523
|
};
|
|
519
524
|
}
|
|
@@ -1366,6 +1371,7 @@ export class ClientSession {
|
|
|
1366
1371
|
cwd: () => this.cwd,
|
|
1367
1372
|
getSession: () => this.session,
|
|
1368
1373
|
startNewSession: () => this.startNewSession(),
|
|
1374
|
+
restartSession: (conversationId) => this.restartSession(conversationId),
|
|
1369
1375
|
treeCommand: async (name, args, context) => {
|
|
1370
1376
|
const conv = this.conv;
|
|
1371
1377
|
if (conv.id !== context.conversationId)
|
|
@@ -1523,13 +1529,17 @@ export class ClientSession {
|
|
|
1523
1529
|
onAccepted?.(ok);
|
|
1524
1530
|
};
|
|
1525
1531
|
try {
|
|
1532
|
+
if (this.restartingSession)
|
|
1533
|
+
throw new Error("当前会话正在重启,请稍后重试。");
|
|
1526
1534
|
if (!["tree", "fork"].includes(parseSlash(text)?.name ?? ""))
|
|
1527
1535
|
await conv.tree?.waitForVerification();
|
|
1528
1536
|
const s = conv.session;
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1537
|
+
if (parseSlash(text)?.name !== "restart") {
|
|
1538
|
+
await flushPromptReload(s);
|
|
1539
|
+
const promptReloadError = promptReloadStatus(s).reloadError;
|
|
1540
|
+
if (promptReloadError)
|
|
1541
|
+
throw new Error(`Native prompt reload failed: ${promptReloadError}`);
|
|
1542
|
+
}
|
|
1533
1543
|
if (this.conv !== conv || conv.session !== s)
|
|
1534
1544
|
throw new Error("Conversation changed");
|
|
1535
1545
|
const extensionCommand = text.startsWith("/") && s.extensionRunner.getCommand(text.slice(1).split(" ", 1)[0]);
|
|
@@ -1841,8 +1851,59 @@ export class ClientSession {
|
|
|
1841
1851
|
});
|
|
1842
1852
|
}
|
|
1843
1853
|
}
|
|
1854
|
+
restartingSession = false;
|
|
1855
|
+
/** Recreate only this conversation's native runtime; retain its exact branch and PTYs. */
|
|
1856
|
+
async restartSession(conversationId) {
|
|
1857
|
+
const conv = this.conv;
|
|
1858
|
+
if (conv.id !== conversationId || this.switchingWorkspace || this.creatingConversation)
|
|
1859
|
+
throw new Error("对话正在切换或重启,请稍后重试。");
|
|
1860
|
+
if (this.quiesceBlocked())
|
|
1861
|
+
throw new Error("服务暂停接收新操作。");
|
|
1862
|
+
conv.tree?.assertWritable();
|
|
1863
|
+
if (!conv.session.isIdle || isRecovering(conv.recovery))
|
|
1864
|
+
throw new Error("请先停止当前任务,再重启会话。");
|
|
1865
|
+
this.restartingSession = true;
|
|
1866
|
+
let replacement;
|
|
1867
|
+
try {
|
|
1868
|
+
const previous = conv.runtime, model = conv.session.model, thinking = conv.session.thinkingLevel;
|
|
1869
|
+
replacement = await createAgentSessionRuntime(this.makeRuntimeFactory(true, { model, thinkingLevel: thinking }), {
|
|
1870
|
+
cwd: conv.cwd, agentDir: this.agentDir, sessionManager: conv.session.sessionManager,
|
|
1871
|
+
});
|
|
1872
|
+
if (this.disposed || this.conv !== conv)
|
|
1873
|
+
throw new Error("对话已切换。");
|
|
1874
|
+
await previous.dispose();
|
|
1875
|
+
conv.unsubscribe?.();
|
|
1876
|
+
conv.unsubscribe = undefined;
|
|
1877
|
+
conv.tree?.dispose();
|
|
1878
|
+
conv.tree = undefined;
|
|
1879
|
+
conv.runtime = replacement;
|
|
1880
|
+
replacement = undefined;
|
|
1881
|
+
conv.recovery = {};
|
|
1882
|
+
conv.treeProjectionRevision = undefined;
|
|
1883
|
+
conv.uiMessageCache.clear();
|
|
1884
|
+
conv.lastMessagesSig = "";
|
|
1885
|
+
await this.bindSession(conv);
|
|
1886
|
+
await this.pushSlashCommands();
|
|
1887
|
+
this.pushSettings();
|
|
1888
|
+
this.emitConversations();
|
|
1889
|
+
this.flushSnapshot(true);
|
|
1890
|
+
this.emit({ type: "notice", conversationId: conv.id, level: "info", text: "当前会话已重启,历史记录已保留。" });
|
|
1891
|
+
}
|
|
1892
|
+
finally {
|
|
1893
|
+
try {
|
|
1894
|
+
await replacement?.dispose();
|
|
1895
|
+
}
|
|
1896
|
+
finally {
|
|
1897
|
+
this.restartingSession = false;
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1844
1901
|
/** /new delegates session creation and history persistence to the SDK; the Web slot is reused. */
|
|
1845
1902
|
async startNewSession() {
|
|
1903
|
+
if (this.restartingSession) {
|
|
1904
|
+
this.emitNotice("warning", "当前会话正在重启,请稍后重试。");
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1846
1907
|
if (this.conv.tree?.busy) {
|
|
1847
1908
|
this.emitNotice("warning", "等待当前切换完成。");
|
|
1848
1909
|
return;
|
|
@@ -1876,6 +1937,10 @@ export class ClientSession {
|
|
|
1876
1937
|
void this.pushSlashCommands();
|
|
1877
1938
|
}
|
|
1878
1939
|
async newChat(fresh = false, wiki = false) {
|
|
1940
|
+
if (this.restartingSession) {
|
|
1941
|
+
this.emitNotice("warning", "当前会话正在重启,请稍后重试。");
|
|
1942
|
+
return false;
|
|
1943
|
+
}
|
|
1879
1944
|
if (this.conv.tree?.busy) {
|
|
1880
1945
|
this.emitNotice("warning", "等待当前切换完成。");
|
|
1881
1946
|
return false;
|
|
@@ -2058,6 +2123,10 @@ export class ClientSession {
|
|
|
2058
2123
|
}
|
|
2059
2124
|
/** Switch the ACTIVE conversation without interrupting any other chat. */
|
|
2060
2125
|
async switchConversation(id) {
|
|
2126
|
+
if (this.restartingSession) {
|
|
2127
|
+
this.emitNotice("warning", "当前会话正在重启,请稍后重试。");
|
|
2128
|
+
return;
|
|
2129
|
+
}
|
|
2061
2130
|
if (this.conv.tree?.busy) {
|
|
2062
2131
|
this.emitNotice("warning", "等待当前切换完成。");
|
|
2063
2132
|
return;
|
|
@@ -2286,6 +2355,10 @@ export class ClientSession {
|
|
|
2286
2355
|
* user opened history while it was streaming.
|
|
2287
2356
|
*/
|
|
2288
2357
|
async switchSession(path) {
|
|
2358
|
+
if (this.restartingSession) {
|
|
2359
|
+
this.emitNotice("warning", "当前会话正在重启,请稍后重试。");
|
|
2360
|
+
return;
|
|
2361
|
+
}
|
|
2289
2362
|
if (this.conv.tree?.busy) {
|
|
2290
2363
|
this.emitNotice("warning", "等待当前切换完成。");
|
|
2291
2364
|
return;
|
|
@@ -2553,8 +2626,15 @@ export class ClientSession {
|
|
|
2553
2626
|
}
|
|
2554
2627
|
cwdQueue = null;
|
|
2555
2628
|
cwdSwitchRunning = false;
|
|
2556
|
-
get switchingWorkspace() { return this.cwdSwitchRunning; }
|
|
2629
|
+
get switchingWorkspace() { return this.cwdSwitchRunning || this.restartingSession; }
|
|
2557
2630
|
async setCwd(path, id, source) {
|
|
2631
|
+
if (this.restartingSession) {
|
|
2632
|
+
if (id)
|
|
2633
|
+
this.emit({ type: "cwd_result", requestId: id, cwd: this.cwd, ok: false, error: "当前会话正在重启,请稍后重试。" });
|
|
2634
|
+
else
|
|
2635
|
+
this.emitNotice("warning", "当前会话正在重启,请稍后重试。");
|
|
2636
|
+
return;
|
|
2637
|
+
}
|
|
2558
2638
|
if (this.conv.tree?.busy) {
|
|
2559
2639
|
this.emitNotice("warning", "等待当前切换完成。");
|
|
2560
2640
|
return;
|
|
@@ -87,6 +87,7 @@ export function startControlServer(opts) {
|
|
|
87
87
|
switch (req.cmd) {
|
|
88
88
|
case "status":
|
|
89
89
|
resp = { ok: true, ...service.serviceStatus() };
|
|
90
|
+
resp.activeConversations = (resp.activeConversations ?? 0) + (opts.additionalActiveConversations?.() ?? 0);
|
|
90
91
|
break;
|
|
91
92
|
case "quiesce":
|
|
92
93
|
service.quiesce();
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { DefaultPackageManager, SettingsManager, ProjectTrustStore } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { writeFileSync, mkdirSync, renameSync } from "node:fs";
|
|
3
|
-
import {
|
|
3
|
+
import { join, relative } from "node:path";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { readJson, textField, resourceTypes, sourceOf, sourceInfo, packageDisabled, settingsVersion } from "./extensions-model.js";
|
|
6
|
+
import { extensionDisplay } from "./extension-display.js";
|
|
6
7
|
import { gitOutput, latestNpmVersion } from "./component-updates.js";
|
|
7
8
|
const blankResources = () => ({ extensions: [], skills: [], prompts: [], themes: [] });
|
|
8
9
|
export async function extensionWork(input, progress = () => { }) {
|
|
@@ -55,7 +56,7 @@ export async function extensionWork(input, progress = () => { }) {
|
|
|
55
56
|
for (const resource of resources.extensions) {
|
|
56
57
|
if (resource.metadata.origin !== "top-level" || resource.path.startsWith("builtin:") || resource.metadata.scope === "temporary")
|
|
57
58
|
continue;
|
|
58
|
-
result.push({ id: `file:${resource.metadata.scope}:${resource.path}`, source: resource.path,
|
|
59
|
+
result.push({ id: `file:${resource.metadata.scope}:${resource.path}`, source: resource.path, ...extensionDisplay(resource.path), scope: resource.metadata.scope, kind: "file", path: resource.path, enabled: resource.enabled, pinned: false, trusted: resource.metadata.scope === "user" || trusted, resources: blankResources() });
|
|
59
60
|
}
|
|
60
61
|
return { packages: result, version: settingsVersion(cwd, agentDir), trusted };
|
|
61
62
|
}
|
|
@@ -117,7 +117,7 @@ export function isRealPathInWorkspace(root, abs) {
|
|
|
117
117
|
* win32: stability and completeness first, preview second. ACL-protected
|
|
118
118
|
* system dirs (C:\$Recycle.Bin, Program Files internals, OneDrive placeholders)
|
|
119
119
|
* throw EPERM/EACCES on open — that must not kill the panel, so it degrades
|
|
120
|
-
* to an empty listing
|
|
120
|
+
* to an empty listing with a node-local error. Directory symlinks/junctions are
|
|
121
121
|
* followed so mklink /D folders stay navigable; broken links still show as
|
|
122
122
|
* files instead of vanishing. The cap is 4x posix and truncation is reported
|
|
123
123
|
* via `truncated` instead of happening silently.
|
|
@@ -132,11 +132,11 @@ async function readDirForUI(abs, rel) {
|
|
|
132
132
|
dirents = await fs.readdir(abs, { withFileTypes: true });
|
|
133
133
|
}
|
|
134
134
|
catch (err) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
135
|
+
const code = err.code;
|
|
136
|
+
return { entries: [], truncated: false, error: {
|
|
137
|
+
code: code === "ENOENT" ? "missing" : code === "ENOTDIR" ? "not_directory" : code === "EACCES" || code === "EPERM" ? "denied" : "unavailable",
|
|
138
|
+
message: err.message,
|
|
139
|
+
} };
|
|
140
140
|
}
|
|
141
141
|
const out = [];
|
|
142
142
|
for (const d of dirents) {
|
|
@@ -225,15 +225,6 @@ export class FilesService {
|
|
|
225
225
|
// always use "/", but relative() returns "\\" on Windows.
|
|
226
226
|
const rel = rawRel.split(sep).join("/");
|
|
227
227
|
const { entries, truncated, error } = await readDirForUI(target, rel);
|
|
228
|
-
if (error) {
|
|
229
|
-
// Windows-only: unreadable system dirs degrade to an empty list
|
|
230
|
-
// with a warning instead of a hard error — the panel stays usable.
|
|
231
|
-
this.host.emit({
|
|
232
|
-
type: "notice",
|
|
233
|
-
level: "warning",
|
|
234
|
-
text: `目录不可读:${error}`,
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
228
|
this.host.emit({
|
|
238
229
|
type: "files",
|
|
239
230
|
cwd,
|
|
@@ -245,6 +236,7 @@ export class FilesService {
|
|
|
245
236
|
: "",
|
|
246
237
|
entries,
|
|
247
238
|
truncated,
|
|
239
|
+
...(error ? { error } : {}),
|
|
248
240
|
});
|
|
249
241
|
// Watch the listed directory — deferred until AFTER the response is on
|
|
250
242
|
// the wire. On win32, fs.watch(root, {recursive:true}) registering over
|
|
@@ -253,7 +245,7 @@ export class FilesService {
|
|
|
253
245
|
// macOS's FSEvents); doing it inline before the emit made every
|
|
254
246
|
// project switch wait on that registration instead of just seeing the
|
|
255
247
|
// file list immediately and having the watcher arm a beat later.
|
|
256
|
-
if (cwd === this.host.getCwd())
|
|
248
|
+
if (!error && cwd === this.host.getCwd())
|
|
257
249
|
this.watchDir(target, rel);
|
|
258
250
|
}
|
|
259
251
|
/**
|
package/dist/server/index.js
CHANGED
|
@@ -615,7 +615,7 @@ wss.on("connection", (ws) => {
|
|
|
615
615
|
pending.push(msg);
|
|
616
616
|
return;
|
|
617
617
|
}
|
|
618
|
-
if (cs.switchingWorkspace && msg.type !== "set_cwd" && msg.type !== "get_state") {
|
|
618
|
+
if (cs.switchingWorkspace && msg.type !== "set_cwd" && msg.type !== "get_state" && msg.type !== "node_request") {
|
|
619
619
|
if (msg.type === "prompt" && msg.requestId)
|
|
620
620
|
send({ type: "prompt_result", requestId: msg.requestId, ok: false });
|
|
621
621
|
if (msg.type === "read_file" || msg.type === "write_file")
|
|
@@ -628,6 +628,10 @@ wss.on("connection", (ws) => {
|
|
|
628
628
|
}
|
|
629
629
|
switch (msg.type) {
|
|
630
630
|
case "node_request":
|
|
631
|
+
if (service.quiesceInfo().quiesced && ["agent_start", "agent_prompt", "agent_new", "agent_model"].includes(msg.action)) {
|
|
632
|
+
send({ type: "node_event", event: "failure", requestId: msg.requestId, nodeId: msg.nodeId, conversationId: msg.conversationId, data: { action: msg.action, message: "服务暂停接收新操作。" } });
|
|
633
|
+
break;
|
|
634
|
+
}
|
|
631
635
|
void nodeWorkbench.handle(clientId, msg);
|
|
632
636
|
break;
|
|
633
637
|
case "prompt":
|
|
@@ -754,7 +758,7 @@ wss.on("connection", (ws) => {
|
|
|
754
758
|
void cs.checkComponentUpdates(msg.requestId);
|
|
755
759
|
break;
|
|
756
760
|
case "update_component":
|
|
757
|
-
if (service.quiesceInfo().quiesced || service.activeConversations() || service.pendingMessages()) {
|
|
761
|
+
if (service.quiesceInfo().quiesced || service.activeConversations() || nodeWorkbench.activeAgents() || service.pendingMessages()) {
|
|
758
762
|
send({ type: "component_updates", requestId: msg.requestId, cwd: cs.cwd, phase: "error", items: [], error: "请等待所有任务结束后再更新扩展" });
|
|
759
763
|
}
|
|
760
764
|
else {
|
|
@@ -1009,7 +1013,7 @@ httpServer.listen(PORT, HOST, () => {
|
|
|
1009
1013
|
scheduleUploadCleanup();
|
|
1010
1014
|
// Local control socket (status / quiesce / unquiesce) — same data dir the
|
|
1011
1015
|
// CLI uses, so `pi-harness server status|quiesce|unquiesce` just works.
|
|
1012
|
-
const stopControl = startControlServer({ service, dataDir: DATA_DIR, port: PORT });
|
|
1016
|
+
const stopControl = startControlServer({ service, dataDir: DATA_DIR, port: PORT, additionalActiveConversations: () => nodeWorkbench.activeAgents() });
|
|
1013
1017
|
let shuttingDown = false;
|
|
1014
1018
|
async function shutdown() {
|
|
1015
1019
|
if (shuttingDown)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/** Native Pi RPC over one SSH exec channel. No local model or terminal injection. */
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { StringDecoder } from "node:string_decoder";
|
|
4
|
+
import { serializeMessage } from "./serialize.js";
|
|
5
|
+
const MAX_RECORD = 16 * 1024 * 1024;
|
|
6
|
+
const quote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
7
|
+
export function nodeAgentCommand(cwd) {
|
|
8
|
+
if (!cwd.startsWith("/") || /[\0\r\n]/.test(cwd) || cwd.length > 4096)
|
|
9
|
+
throw new Error("Invalid remote working directory");
|
|
10
|
+
return `sh -lc ${quote(`cd ${quote(cwd)} && exec pi --mode rpc`)}`;
|
|
11
|
+
}
|
|
12
|
+
export class NodeAgent {
|
|
13
|
+
stream;
|
|
14
|
+
changed;
|
|
15
|
+
id = randomUUID();
|
|
16
|
+
buffer = "";
|
|
17
|
+
decoder = new StringDecoder("utf8");
|
|
18
|
+
stderr = "";
|
|
19
|
+
seq = 0;
|
|
20
|
+
revision = 0;
|
|
21
|
+
dialogTimers = new Map();
|
|
22
|
+
timer;
|
|
23
|
+
pending = new Map();
|
|
24
|
+
state;
|
|
25
|
+
constructor(stream, cwd, changed) {
|
|
26
|
+
this.stream = stream;
|
|
27
|
+
this.changed = changed;
|
|
28
|
+
this.state = { id: this.id, cwd, phase: "starting", running: false, messages: [], tools: [], models: [], dialogs: [] };
|
|
29
|
+
stream.on("data", (data) => this.receive(this.decoder.write(Buffer.isBuffer(data) ? data : Buffer.from(data))));
|
|
30
|
+
stream.stderr.on("data", data => { this.stderr = (this.stderr + data.toString()).slice(-8000); });
|
|
31
|
+
stream.on("error", (error) => this.finish(error.message));
|
|
32
|
+
stream.on("close", () => this.finish(this.stderr || "Remote Pi exited"));
|
|
33
|
+
}
|
|
34
|
+
get busy() { return this.state.phase !== "closed" && (this.state.phase === "starting" || this.state.running || this.pending.size > 0 || this.state.dialogs.length > 0); }
|
|
35
|
+
snapshot() { return this.state; }
|
|
36
|
+
publish(immediate = false) {
|
|
37
|
+
if (this.timer) {
|
|
38
|
+
if (!immediate)
|
|
39
|
+
return;
|
|
40
|
+
clearTimeout(this.timer);
|
|
41
|
+
this.timer = undefined;
|
|
42
|
+
}
|
|
43
|
+
if (immediate)
|
|
44
|
+
this.changed(this.state);
|
|
45
|
+
else
|
|
46
|
+
this.timer = setTimeout(() => { this.timer = undefined; this.changed(this.state); }, 60);
|
|
47
|
+
}
|
|
48
|
+
set(patch, immediate = false) { this.state = { ...this.state, ...patch }; this.publish(immediate); }
|
|
49
|
+
finish(error) {
|
|
50
|
+
if (this.state.phase === "closed")
|
|
51
|
+
return;
|
|
52
|
+
for (const task of this.pending.values()) {
|
|
53
|
+
clearTimeout(task.timer);
|
|
54
|
+
task.reject(new Error(error || "Remote Pi closed"));
|
|
55
|
+
}
|
|
56
|
+
this.pending.clear();
|
|
57
|
+
for (const timer of this.dialogTimers.values())
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
this.dialogTimers.clear();
|
|
60
|
+
this.set({ phase: "closed", running: false, dialogs: [], error }, true);
|
|
61
|
+
}
|
|
62
|
+
close() {
|
|
63
|
+
this.finish();
|
|
64
|
+
try {
|
|
65
|
+
this.stream.signal("TERM");
|
|
66
|
+
}
|
|
67
|
+
catch { /* SSH may already be gone. */ }
|
|
68
|
+
this.stream.end();
|
|
69
|
+
this.stream.destroy();
|
|
70
|
+
}
|
|
71
|
+
receive(chunk) {
|
|
72
|
+
if (this.state.phase === "closed")
|
|
73
|
+
return;
|
|
74
|
+
this.buffer += chunk;
|
|
75
|
+
let index;
|
|
76
|
+
while ((index = this.buffer.indexOf("\n")) >= 0) {
|
|
77
|
+
const line = this.buffer.slice(0, index);
|
|
78
|
+
this.buffer = this.buffer.slice(index + 1);
|
|
79
|
+
if (line.length > MAX_RECORD) {
|
|
80
|
+
this.finish("Remote Pi response exceeds 16 MiB");
|
|
81
|
+
this.close();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!line.trim())
|
|
85
|
+
continue;
|
|
86
|
+
try {
|
|
87
|
+
this.event(JSON.parse(line));
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
this.finish(`Invalid remote Pi RPC response: ${error.message}`);
|
|
91
|
+
this.close();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (this.buffer.length > MAX_RECORD) {
|
|
96
|
+
this.finish("Remote Pi response exceeds 16 MiB");
|
|
97
|
+
this.close();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
request(type, fields = {}) {
|
|
101
|
+
if (this.state.phase === "closed")
|
|
102
|
+
return Promise.reject(new Error("Remote Pi is not running"));
|
|
103
|
+
if (this.pending.size >= 32)
|
|
104
|
+
return Promise.reject(new Error("Too many pending remote commands"));
|
|
105
|
+
const id = randomUUID();
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`Remote Pi ${type} timed out`)); }, 30000);
|
|
108
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
109
|
+
try {
|
|
110
|
+
this.stream.write(JSON.stringify({ ...fields, id, type }) + "\n");
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
this.pending.delete(id);
|
|
115
|
+
reject(error);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
async start() {
|
|
120
|
+
try {
|
|
121
|
+
await this.refresh();
|
|
122
|
+
const available = await this.request("get_available_models");
|
|
123
|
+
this.set({ phase: "ready", models: (available.models ?? []).map((m) => ({ id: String(m.id), provider: String(m.provider), name: String(m.name ?? m.id) })) }, true);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
this.finish(`${error.message}${this.stderr ? `\n${this.stderr}` : ""}`);
|
|
127
|
+
this.close();
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
retainMessages(messages) {
|
|
132
|
+
let bytes = 0, start = messages.length;
|
|
133
|
+
while (start > 0 && messages.length - start < 200) {
|
|
134
|
+
const size = Buffer.byteLength(JSON.stringify(messages[start - 1]));
|
|
135
|
+
if (start < messages.length && bytes + size > 4 * 1024 * 1024)
|
|
136
|
+
break;
|
|
137
|
+
bytes += size;
|
|
138
|
+
start--;
|
|
139
|
+
}
|
|
140
|
+
return messages.slice(start);
|
|
141
|
+
}
|
|
142
|
+
async refresh() {
|
|
143
|
+
const revision = this.revision;
|
|
144
|
+
const state = await this.request("get_state");
|
|
145
|
+
if (typeof state.sessionId !== "string" || typeof state.isStreaming !== "boolean")
|
|
146
|
+
throw new Error("Remote Pi must support the native 1.0.4 RPC protocol");
|
|
147
|
+
const history = await this.request("get_messages");
|
|
148
|
+
const messages = (history.messages ?? []).slice(-200).map((m, index) => serializeMessage(m, index)).filter((m) => !!m);
|
|
149
|
+
if (this.revision !== revision)
|
|
150
|
+
return;
|
|
151
|
+
this.seq = messages.length;
|
|
152
|
+
this.set({ sessionId: state.sessionId, sessionFile: state.sessionFile, model: state.model ? { id: String(state.model.id), provider: String(state.model.provider), name: String(state.model.name ?? state.model.id) } : undefined, thinkingLevel: state.thinkingLevel, running: state.isStreaming || state.isCompacting, messages: this.retainMessages(messages) });
|
|
153
|
+
}
|
|
154
|
+
async prompt(text, queue) {
|
|
155
|
+
if (this.state.phase !== "ready")
|
|
156
|
+
throw new Error("Remote Pi is not ready");
|
|
157
|
+
if (!text.trim() || text.length > 200000)
|
|
158
|
+
throw new Error("Invalid remote prompt");
|
|
159
|
+
this.set({ error: undefined });
|
|
160
|
+
await this.request("prompt", { message: text, ...(queue ? { streamingBehavior: queue } : {}) });
|
|
161
|
+
}
|
|
162
|
+
async abort() { await this.request("abort"); }
|
|
163
|
+
async newSession() {
|
|
164
|
+
if (this.state.running || this.state.dialogs.length)
|
|
165
|
+
throw new Error("Stop the remote task first");
|
|
166
|
+
const result = await this.request("new_session");
|
|
167
|
+
if (result.cancelled)
|
|
168
|
+
return;
|
|
169
|
+
this.set({ tools: [], streamingMessage: undefined });
|
|
170
|
+
await this.refresh();
|
|
171
|
+
}
|
|
172
|
+
async model(provider, modelId) {
|
|
173
|
+
if (this.state.running)
|
|
174
|
+
throw new Error("Stop the remote task first");
|
|
175
|
+
if (!this.state.models.some(m => m.id === modelId && m.provider === provider))
|
|
176
|
+
throw new Error("Unknown remote model");
|
|
177
|
+
await this.request("set_model", { provider, modelId });
|
|
178
|
+
await this.refresh();
|
|
179
|
+
}
|
|
180
|
+
dialog(id, response) {
|
|
181
|
+
const dialog = this.state.dialogs.find(d => d.id === id);
|
|
182
|
+
if (!dialog)
|
|
183
|
+
throw new Error("Remote dialog expired");
|
|
184
|
+
if (!response.cancelled && dialog.method === "select" && !dialog.options?.includes(response.value ?? ""))
|
|
185
|
+
throw new Error("Invalid remote selection");
|
|
186
|
+
if (response.value && response.value.length > 200000)
|
|
187
|
+
throw new Error("Remote response too large");
|
|
188
|
+
clearTimeout(this.dialogTimers.get(id));
|
|
189
|
+
this.dialogTimers.delete(id);
|
|
190
|
+
this.stream.write(JSON.stringify({ type: "extension_ui_response", id, ...response }) + "\n");
|
|
191
|
+
this.set({ dialogs: this.state.dialogs.filter(d => d.id !== id) });
|
|
192
|
+
}
|
|
193
|
+
event(event) {
|
|
194
|
+
if (!event || typeof event.type !== "string")
|
|
195
|
+
throw new Error("Missing RPC record type");
|
|
196
|
+
if (event.type === "response") {
|
|
197
|
+
const task = this.pending.get(event.id);
|
|
198
|
+
if (!task)
|
|
199
|
+
return;
|
|
200
|
+
clearTimeout(task.timer);
|
|
201
|
+
this.pending.delete(event.id);
|
|
202
|
+
if (event.success)
|
|
203
|
+
task.resolve(event.data ?? {});
|
|
204
|
+
else
|
|
205
|
+
task.reject(new Error(String(event.error ?? "Remote Pi command failed")));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (["agent_start", "compaction_start", "auto_retry_start"].includes(event.type)) {
|
|
209
|
+
this.revision++;
|
|
210
|
+
this.set({ running: true, error: undefined, tools: [] });
|
|
211
|
+
}
|
|
212
|
+
if (event.type === "agent_settled") {
|
|
213
|
+
this.set({ running: false });
|
|
214
|
+
void this.refresh().catch(error => this.set({ error: error.message }));
|
|
215
|
+
}
|
|
216
|
+
if (event.type === "message_start" && event.message?.role === "assistant")
|
|
217
|
+
this.set({ streamingMessage: serializeMessage(event.message, this.seq) ?? undefined });
|
|
218
|
+
if (event.type === "message_update") {
|
|
219
|
+
const delta = event.assistantMessageEvent, current = this.state.streamingMessage;
|
|
220
|
+
if (!current || !delta || !Number.isInteger(delta.contentIndex) || delta.contentIndex < 0 || delta.contentIndex > 4096)
|
|
221
|
+
return;
|
|
222
|
+
const content = [...current.content], index = delta.contentIndex, previous = content[index];
|
|
223
|
+
if (delta.type === "text_delta")
|
|
224
|
+
content[index] = { type: "text", text: (String(previous && "text" in previous ? previous.text ?? "" : "") + String(delta.delta ?? "")).slice(-200000) };
|
|
225
|
+
else if (delta.type === "thinking_delta")
|
|
226
|
+
content[index] = { type: "thinking", thinking: (String(previous && "thinking" in previous ? previous.thinking ?? "" : "") + String(delta.delta ?? "")).slice(-200000) };
|
|
227
|
+
else if (delta.type === "toolcall_start")
|
|
228
|
+
content[index] = { type: "toolCall", id: String(delta.id), name: String(delta.toolName), argumentsText: "" };
|
|
229
|
+
else if (delta.type === "toolcall_delta" && previous?.type === "toolCall")
|
|
230
|
+
content[index] = { ...previous, argumentsText: (String(previous.argumentsText ?? "") + String(delta.delta ?? "")).slice(0, 20000) };
|
|
231
|
+
else
|
|
232
|
+
return;
|
|
233
|
+
this.set({ streamingMessage: { ...current, content: content.filter(Boolean) } });
|
|
234
|
+
}
|
|
235
|
+
if (event.type === "message_end") {
|
|
236
|
+
this.revision++;
|
|
237
|
+
const message = serializeMessage(event.message, this.seq++);
|
|
238
|
+
if (message)
|
|
239
|
+
this.set({ messages: this.retainMessages([...this.state.messages, message]), ...(event.message.role === "assistant" ? { streamingMessage: undefined } : {}) });
|
|
240
|
+
}
|
|
241
|
+
if (event.type === "tool_execution_start" || event.type === "tool_execution_end") {
|
|
242
|
+
const tools = this.state.tools.filter(t => t.id !== event.toolCallId);
|
|
243
|
+
tools.push({ id: String(event.toolCallId), name: String(event.toolName), running: event.type === "tool_execution_start", isError: !!event.isError });
|
|
244
|
+
this.set({ tools: tools.slice(-256) });
|
|
245
|
+
}
|
|
246
|
+
if (event.type === "extension_ui_request") {
|
|
247
|
+
if (["select", "confirm", "input", "editor"].includes(event.method)) {
|
|
248
|
+
if (this.state.dialogs.length >= 32) {
|
|
249
|
+
this.stream.write(JSON.stringify({ type: "extension_ui_response", id: event.id, cancelled: true }) + "\n");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (typeof event.timeout === "number" && event.timeout >= 0 && event.timeout <= 2147483647)
|
|
253
|
+
this.dialogTimers.set(String(event.id), setTimeout(() => { this.dialogTimers.delete(String(event.id)); this.set({ dialogs: this.state.dialogs.filter(d => d.id !== event.id) }); }, event.timeout));
|
|
254
|
+
this.set({ dialogs: [...this.state.dialogs, { id: String(event.id), method: event.method, title: String(event.title ?? ""), message: String(event.message ?? ""), options: Array.isArray(event.options) ? event.options.map(String) : undefined, prefill: typeof event.prefill === "string" ? event.prefill : undefined }] }, true);
|
|
255
|
+
}
|
|
256
|
+
if (event.method === "notify")
|
|
257
|
+
this.set({ notice: String(event.message ?? "").slice(0, 8000) });
|
|
258
|
+
if (event.method === "set_editor_text")
|
|
259
|
+
this.set({ editorText: { id: String(event.id), text: String(event.text ?? "").slice(0, 200000) } });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Built-in SSH node workbench. All remote operations are scoped by node and client. */
|
|
2
|
+
import { NodeAgent, nodeAgentCommand } from "./node-agent.js";
|
|
2
3
|
import { execFile } from "node:child_process";
|
|
3
4
|
import { randomUUID, createHash } from "node:crypto";
|
|
4
5
|
import { readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
|
|
@@ -68,8 +69,9 @@ export class NodeWorkbench {
|
|
|
68
69
|
for (const sink of this.sinks.get(clientId) ?? [])
|
|
69
70
|
sink(msg);
|
|
70
71
|
}
|
|
72
|
+
activeAgents() { return [...this.connections.values()].filter(connection => connection.agentStarting || connection.agent?.busy).length; }
|
|
71
73
|
state(clientId) {
|
|
72
|
-
return { sources: this.sources, nodes: this.nodes.map((node) => ({ ...node, auth: node.localAuth ?? node.auth, unsupported: unsupportedOptions(node), hasSecret: this.secrets.has(`node:${node.id}:secret`) || this.transientSecrets.has(identity(clientId, node.id)), fingerprint: node.fingerprint })), connections: [...this.connections.values()].filter((c) => c.clientId === clientId).map((c) => ({ nodeId: c.nodeId, connectedAt: c.connectedAt, latencyMs: c.connectedAt ? c.connectedAt - c.startedAt : undefined, status: c.ready ? "connected" : "connecting", terminals: [...c.terminals.values()].map((t) => ({ id: t.id, conversationId: t.conversationId, busy: t.busy, output: t.buffer })) })) };
|
|
74
|
+
return { sources: this.sources, nodes: this.nodes.map((node) => ({ ...node, auth: node.localAuth ?? node.auth, unsupported: unsupportedOptions(node), hasSecret: this.secrets.has(`node:${node.id}:secret`) || this.transientSecrets.has(identity(clientId, node.id)), fingerprint: node.fingerprint })), connections: [...this.connections.values()].filter((c) => c.clientId === clientId).map((c) => ({ nodeId: c.nodeId, connectedAt: c.connectedAt, latencyMs: c.connectedAt ? c.connectedAt - c.startedAt : undefined, status: c.ready ? "connected" : "connecting", agent: c.agent?.snapshot(), terminals: [...c.terminals.values()].map((t) => ({ id: t.id, conversationId: t.conversationId, busy: t.busy, output: t.buffer })) })) };
|
|
73
75
|
}
|
|
74
76
|
attach(clientId, sink) {
|
|
75
77
|
let set = this.sinks.get(clientId);
|
|
@@ -93,6 +95,43 @@ export class NodeWorkbench {
|
|
|
93
95
|
throw new Error("终端身份已过期");
|
|
94
96
|
return t;
|
|
95
97
|
}
|
|
98
|
+
agent(clientId, req) {
|
|
99
|
+
const agent = this.connection(clientId, req.nodeId).agent;
|
|
100
|
+
if (!agent || agent.id !== req.conversationId)
|
|
101
|
+
throw new Error("远端 Agent 会话身份已过期");
|
|
102
|
+
return agent;
|
|
103
|
+
}
|
|
104
|
+
async startAgent(clientId, req) {
|
|
105
|
+
const connection = this.connection(clientId, req.nodeId);
|
|
106
|
+
if (connection.agentStarting)
|
|
107
|
+
throw new Error("远端 Agent 正在启动");
|
|
108
|
+
if (connection.agent && connection.agent.snapshot().phase !== "closed")
|
|
109
|
+
return connection.agent.snapshot();
|
|
110
|
+
const cwd = safePath(req.payload?.cwd ?? this.node(req.nodeId).defaultDir);
|
|
111
|
+
connection.agentStarting = true;
|
|
112
|
+
try {
|
|
113
|
+
const stream = await new Promise((resolve, reject) => connection.client.exec(nodeAgentCommand(cwd), (error, stream) => error ? reject(error) : resolve(stream)));
|
|
114
|
+
try {
|
|
115
|
+
this.ensureCurrent(connection);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
stream.signal("TERM");
|
|
119
|
+
stream.end();
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
const agent = new NodeAgent(stream, cwd, state => {
|
|
123
|
+
if (connection.agent?.id === state.id && this.connections.get(identity(clientId, connection.nodeId)) === connection)
|
|
124
|
+
this.emit(clientId, "agent_state", { agent: state }, { nodeId: connection.nodeId, conversationId: state.id });
|
|
125
|
+
});
|
|
126
|
+
connection.agent = agent;
|
|
127
|
+
this.broadcastState();
|
|
128
|
+
await agent.start();
|
|
129
|
+
return agent.snapshot();
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
connection.agentStarting = false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
96
135
|
async handle(clientId, req) {
|
|
97
136
|
try {
|
|
98
137
|
const p = req.payload ?? {};
|
|
@@ -308,6 +347,27 @@ export class NodeWorkbench {
|
|
|
308
347
|
case "disconnect":
|
|
309
348
|
this.disconnect(clientId, req.nodeId);
|
|
310
349
|
break;
|
|
350
|
+
case "agent_start":
|
|
351
|
+
result = { agent: await this.startAgent(clientId, req) };
|
|
352
|
+
break;
|
|
353
|
+
case "agent_prompt":
|
|
354
|
+
await this.agent(clientId, req).prompt(field(p.text, "消息", 200000), p.queue === "steer" || p.queue === "followUp" ? p.queue : undefined);
|
|
355
|
+
break;
|
|
356
|
+
case "agent_abort":
|
|
357
|
+
await this.agent(clientId, req).abort();
|
|
358
|
+
break;
|
|
359
|
+
case "agent_new":
|
|
360
|
+
await this.agent(clientId, req).newSession();
|
|
361
|
+
break;
|
|
362
|
+
case "agent_model":
|
|
363
|
+
await this.agent(clientId, req).model(field(p.provider, "服务商"), field(p.modelId, "模型"));
|
|
364
|
+
break;
|
|
365
|
+
case "agent_close":
|
|
366
|
+
this.agent(clientId, req).close();
|
|
367
|
+
break;
|
|
368
|
+
case "agent_dialog":
|
|
369
|
+
this.agent(clientId, req).dialog(field(p.id, "对话框"), { cancelled: p.cancelled === true, confirmed: p.confirmed === true, value: typeof p.value === "string" ? p.value : undefined });
|
|
370
|
+
break;
|
|
311
371
|
case "terminal_open":
|
|
312
372
|
result = await this.openTerminal(clientId, req);
|
|
313
373
|
break;
|
|
@@ -549,6 +609,7 @@ export class NodeWorkbench {
|
|
|
549
609
|
t.pending?.reject(new Error("SSH 已断开"));
|
|
550
610
|
this.emit(c.clientId, "terminal_exit", {}, { nodeId: c.nodeId, terminalId: t.id, conversationId: t.conversationId });
|
|
551
611
|
}
|
|
612
|
+
c.agent?.close();
|
|
552
613
|
c.terminals.clear();
|
|
553
614
|
c.client.end();
|
|
554
615
|
this.broadcastState();
|