dsh-ccgui 0.0.0-stage → 1.0.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 +21 -0
- package/README.md +327 -2
- package/THIRD_PARTY_NOTICES.md +64 -0
- package/assets/logo.png +0 -0
- package/cordis.patch.yml +32 -0
- package/lib/abortable.d.ts +12 -0
- package/lib/abortable.js +27 -0
- package/lib/adapter.d.ts +39 -0
- package/lib/adapter.js +64 -0
- package/lib/async-queue.d.ts +26 -0
- package/lib/async-queue.js +55 -0
- package/lib/cli-session.d.ts +99 -0
- package/lib/cli-session.js +384 -0
- package/lib/cli-tool.d.ts +18 -0
- package/lib/cli-tool.js +101 -0
- package/lib/client.js +4039 -0
- package/lib/constants.d.ts +22 -0
- package/lib/constants.js +22 -0
- package/lib/git/git-read.d.ts +65 -0
- package/lib/git/git-read.js +139 -0
- package/lib/git/git-requests.d.ts +44 -0
- package/lib/git/git-requests.js +153 -0
- package/lib/git/git-routes.d.ts +38 -0
- package/lib/git/git-routes.js +78 -0
- package/lib/git/git-run.d.ts +58 -0
- package/lib/git/git-run.js +97 -0
- package/lib/git/git-status.d.ts +51 -0
- package/lib/git/git-status.js +122 -0
- package/lib/git/git-write.d.ts +75 -0
- package/lib/git/git-write.js +134 -0
- package/lib/index.d.ts +78 -0
- package/lib/index.js +229 -0
- package/lib/model-cache.d.ts +33 -0
- package/lib/model-cache.js +101 -0
- package/lib/model-catalog.d.ts +63 -0
- package/lib/model-catalog.js +107 -0
- package/lib/model-options.d.ts +26 -0
- package/lib/model-options.js +46 -0
- package/lib/model-probe.d.ts +16 -0
- package/lib/model-probe.js +91 -0
- package/lib/npx-cache.d.ts +39 -0
- package/lib/npx-cache.js +97 -0
- package/lib/permission.d.ts +17 -0
- package/lib/permission.js +34 -0
- package/lib/prompt-images.d.ts +44 -0
- package/lib/prompt-images.js +46 -0
- package/lib/request-input.d.ts +42 -0
- package/lib/request-input.js +113 -0
- package/lib/session/connect.d.ts +41 -0
- package/lib/session/connect.js +68 -0
- package/lib/session/drive-tool.d.ts +39 -0
- package/lib/session/drive-tool.js +37 -0
- package/lib/session/model-state.d.ts +40 -0
- package/lib/session/model-state.js +68 -0
- package/lib/session/outcomes.d.ts +31 -0
- package/lib/session/outcomes.js +67 -0
- package/lib/session/permission-queue.d.ts +24 -0
- package/lib/session/permission-queue.js +33 -0
- package/lib/session/read-step.d.ts +31 -0
- package/lib/session/read-step.js +53 -0
- package/lib/session/tool-mirror.d.ts +65 -0
- package/lib/session/tool-mirror.js +131 -0
- package/lib/session/turn-queue.d.ts +76 -0
- package/lib/session/turn-queue.js +102 -0
- package/lib/session/types.d.ts +58 -0
- package/lib/session/types.js +6 -0
- package/lib/session-registry.d.ts +124 -0
- package/lib/session-registry.js +189 -0
- package/lib/stdio-transport.d.ts +28 -0
- package/lib/stdio-transport.js +164 -0
- package/lib/stream-blocks.d.ts +52 -0
- package/lib/stream-blocks.js +95 -0
- package/lib/tool-record.d.ts +100 -0
- package/lib/tool-record.js +167 -0
- package/lib/transport.d.ts +35 -0
- package/lib/transport.js +5 -0
- package/package.json +136 -4
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-reader async queue. CLI output arrives on notification callbacks while
|
|
3
|
+
* the harness pulls it from an async generator, and a step can stop reading with
|
|
4
|
+
* events still buffered for the next step.
|
|
5
|
+
* @module dsh-ccgui/async-queue
|
|
6
|
+
*/
|
|
7
|
+
/** FIFO buffer with one abortable waiting reader. */
|
|
8
|
+
export class AsyncQueue {
|
|
9
|
+
items = [];
|
|
10
|
+
waiter;
|
|
11
|
+
/** Number of buffered items. */
|
|
12
|
+
get size() {
|
|
13
|
+
return this.items.length;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Hand an item to the waiting reader, or buffer it.
|
|
17
|
+
* @param item - the item to deliver.
|
|
18
|
+
*/
|
|
19
|
+
push(item) {
|
|
20
|
+
const waiter = this.waiter;
|
|
21
|
+
if (waiter === undefined) {
|
|
22
|
+
this.items = [...this.items, item];
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
this.waiter = undefined;
|
|
26
|
+
waiter.dispose();
|
|
27
|
+
waiter.resolve(item);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Read the next item, waiting if necessary. Aborting leaves buffered items in place.
|
|
31
|
+
* @param signal - optional cancellation for this read.
|
|
32
|
+
* @returns the next item.
|
|
33
|
+
*/
|
|
34
|
+
next(signal) {
|
|
35
|
+
if (signal?.aborted)
|
|
36
|
+
return Promise.reject(signal.reason);
|
|
37
|
+
const [first, ...rest] = this.items;
|
|
38
|
+
if (this.items.length > 0) {
|
|
39
|
+
this.items = rest;
|
|
40
|
+
return Promise.resolve(first);
|
|
41
|
+
}
|
|
42
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
43
|
+
const onAbort = () => {
|
|
44
|
+
this.waiter = undefined;
|
|
45
|
+
reject(signal?.reason);
|
|
46
|
+
};
|
|
47
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
48
|
+
this.waiter = { resolve, dispose: () => signal?.removeEventListener('abort', onAbort) };
|
|
49
|
+
return promise;
|
|
50
|
+
}
|
|
51
|
+
/** Drop all buffered items. */
|
|
52
|
+
clear() {
|
|
53
|
+
this.items = [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One harness session's live CLI session. The harness thinks in steps, each one
|
|
3
|
+
* model request plus its tools; a CLI thinks in turns that run many tools before
|
|
4
|
+
* `session/prompt` returns. This class holds a CLI turn open across harness steps:
|
|
5
|
+
* a step ends at every tool call, the mirror tool waits for the CLI's result, and
|
|
6
|
+
* the next step resumes reading the same turn.
|
|
7
|
+
* @module dsh-ccgui/cli-session
|
|
8
|
+
*/
|
|
9
|
+
import type { StreamChunk } from '@deepseek-ai/dsh-llm';
|
|
10
|
+
import type { AskPermission } from './session/drive-tool.ts';
|
|
11
|
+
import type { CliSessionOptions, StreamRequest } from './session/types.ts';
|
|
12
|
+
import type { ToolOutcome } from './tool-record.ts';
|
|
13
|
+
export type { AskPermission, PermissionAsk } from './session/drive-tool.ts';
|
|
14
|
+
export type { CliSessionOptions, StreamRequest } from './session/types.ts';
|
|
15
|
+
/** Live bridge between one harness session and one CLI session. */
|
|
16
|
+
export declare class CliSession {
|
|
17
|
+
private readonly options;
|
|
18
|
+
private readonly events;
|
|
19
|
+
private readonly label;
|
|
20
|
+
private readonly startTimeoutMs;
|
|
21
|
+
private readonly tools;
|
|
22
|
+
private readonly permissions;
|
|
23
|
+
private readonly model;
|
|
24
|
+
private readonly turns;
|
|
25
|
+
private seen;
|
|
26
|
+
private transport;
|
|
27
|
+
private started;
|
|
28
|
+
private acpSessionId;
|
|
29
|
+
/** Whether the CLI declared image support when it connected. */
|
|
30
|
+
private acceptsImages;
|
|
31
|
+
/** The CLI cannot see this conversation, and the user has not been told yet. */
|
|
32
|
+
private owesFreshNotice;
|
|
33
|
+
private failure;
|
|
34
|
+
private disposeRequested;
|
|
35
|
+
/** This dead session holds user input it can no longer deliver. */
|
|
36
|
+
private owesInput;
|
|
37
|
+
constructor(options: CliSessionOptions);
|
|
38
|
+
/** Whether this session can no longer run turns. */
|
|
39
|
+
get dead(): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Whether a dead session can be replaced. While its failure is unread it stays,
|
|
42
|
+
* so the next step reports why the CLI died instead of quietly starting another.
|
|
43
|
+
*/
|
|
44
|
+
get replaceable(): boolean;
|
|
45
|
+
/** Ids of the user messages this session or its predecessors sent to a CLI. */
|
|
46
|
+
get sentMessageIds(): ReadonlySet<string>;
|
|
47
|
+
/**
|
|
48
|
+
* Run one harness step: send any new user input, then read until the next tool
|
|
49
|
+
* call or the end of the CLI turn.
|
|
50
|
+
* @param request - the derived history and cancellation for this step.
|
|
51
|
+
* @yields the step's stream chunks, ending with `finish`.
|
|
52
|
+
*/
|
|
53
|
+
stream(request: StreamRequest): AsyncGenerator<StreamChunk>;
|
|
54
|
+
/**
|
|
55
|
+
* Wait for the CLI's result of a published call. This is the body of the mirror
|
|
56
|
+
* tool. It answers every open permission request, not only its own: a parent call
|
|
57
|
+
* can be waiting on a child whose mirror cannot start until this one returns.
|
|
58
|
+
* @param callId - the call id from the harness log.
|
|
59
|
+
* @param ask - asks the harness about a blocked call.
|
|
60
|
+
* @param signal - cancellation; aborting abandons the CLI turn.
|
|
61
|
+
* @returns the finished call's outcome.
|
|
62
|
+
*/
|
|
63
|
+
waitForTool(callId: string, ask: AskPermission, signal?: AbortSignal): Promise<ToolOutcome>;
|
|
64
|
+
/**
|
|
65
|
+
* Stop whatever the CLI is doing for a harness turn that is over. The harness can
|
|
66
|
+
* end a turn between steps, when no stream or mirror is there to notice, so this
|
|
67
|
+
* is driven by the end of the harness turn. It does nothing on an idle session.
|
|
68
|
+
*/
|
|
69
|
+
abandonTurn(): void;
|
|
70
|
+
/**
|
|
71
|
+
* Stop the CLI process and fail anything still waiting on it.
|
|
72
|
+
* @param force - skip the wait for the CLI to exit by itself.
|
|
73
|
+
* @param reason - why it is being stopped, shown to a turn it cuts off.
|
|
74
|
+
*/
|
|
75
|
+
dispose(force?: boolean, reason?: string): Promise<void>;
|
|
76
|
+
private prepareStep;
|
|
77
|
+
private promptBlocks;
|
|
78
|
+
private applyModel;
|
|
79
|
+
/** Steering still in the queue is not sent yet, but must not be queued a second time. */
|
|
80
|
+
private unsentInput;
|
|
81
|
+
private ensureStarted;
|
|
82
|
+
private connectInTime;
|
|
83
|
+
private connect;
|
|
84
|
+
private pushFreshSessionNotice;
|
|
85
|
+
private handleUpdate;
|
|
86
|
+
private handlePermission;
|
|
87
|
+
private handleClosed;
|
|
88
|
+
private settleFailure;
|
|
89
|
+
/**
|
|
90
|
+
* A mirror can be denied by a policy or skipped by a stop before it runs, leaving
|
|
91
|
+
* the CLI blocked on an answer nobody would give. By the next step the harness has
|
|
92
|
+
* finished with the call, so a request still open for it is refused.
|
|
93
|
+
*/
|
|
94
|
+
private rejectSkippedMirrors;
|
|
95
|
+
private hasWork;
|
|
96
|
+
private replayFields;
|
|
97
|
+
private readStep;
|
|
98
|
+
private nextEvent;
|
|
99
|
+
}
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One harness session's live CLI session. The harness thinks in steps, each one
|
|
3
|
+
* model request plus its tools; a CLI thinks in turns that run many tools before
|
|
4
|
+
* `session/prompt` returns. This class holds a CLI turn open across harness steps:
|
|
5
|
+
* a step ends at every tool call, the mirror tool waits for the CLI's result, and
|
|
6
|
+
* the next step resumes reading the same turn.
|
|
7
|
+
* @module dsh-ccgui/cli-session
|
|
8
|
+
*/
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { LlmError } from '@deepseek-ai/dsh-llm';
|
|
11
|
+
import { abortable } from "./abortable.js";
|
|
12
|
+
import { AsyncQueue } from "./async-queue.js";
|
|
13
|
+
import { nativeReplayState, readNativeSession, unseenUserInput } from "./request-input.js";
|
|
14
|
+
import { toPromptBlocks } from "./prompt-images.js";
|
|
15
|
+
import { openSession, withinStartTimeout } from "./session/connect.js";
|
|
16
|
+
import { driveTool } from "./session/drive-tool.js";
|
|
17
|
+
import { ModelState } from "./session/model-state.js";
|
|
18
|
+
import { errorMessage } from "./session/outcomes.js";
|
|
19
|
+
import { CANCELLED, PermissionQueue } from "./session/permission-queue.js";
|
|
20
|
+
import { readStep } from "./session/read-step.js";
|
|
21
|
+
import { ToolMirror } from "./session/tool-mirror.js";
|
|
22
|
+
import { TurnQueue } from "./session/turn-queue.js";
|
|
23
|
+
const DEFAULT_CANCEL_GRACE_MS = 10_000;
|
|
24
|
+
const DEFAULT_START_TIMEOUT_MS = 120_000;
|
|
25
|
+
const DEFAULT_TOOL_SETTLE_MS = 1_000;
|
|
26
|
+
const NEVER_ABORTED = new AbortController().signal;
|
|
27
|
+
/** Live bridge between one harness session and one CLI session. */
|
|
28
|
+
export class CliSession {
|
|
29
|
+
options;
|
|
30
|
+
events = new AsyncQueue();
|
|
31
|
+
label;
|
|
32
|
+
startTimeoutMs;
|
|
33
|
+
tools;
|
|
34
|
+
permissions = new PermissionQueue();
|
|
35
|
+
model;
|
|
36
|
+
turns;
|
|
37
|
+
seen;
|
|
38
|
+
transport;
|
|
39
|
+
started;
|
|
40
|
+
acpSessionId;
|
|
41
|
+
/** Whether the CLI declared image support when it connected. */
|
|
42
|
+
acceptsImages = false;
|
|
43
|
+
/** The CLI cannot see this conversation, and the user has not been told yet. */
|
|
44
|
+
owesFreshNotice = false;
|
|
45
|
+
failure;
|
|
46
|
+
disposeRequested = false;
|
|
47
|
+
/** This dead session holds user input it can no longer deliver. */
|
|
48
|
+
owesInput = false;
|
|
49
|
+
constructor(options) {
|
|
50
|
+
this.options = options;
|
|
51
|
+
this.label = options.label;
|
|
52
|
+
this.startTimeoutMs = options.startTimeoutMs ?? DEFAULT_START_TIMEOUT_MS;
|
|
53
|
+
this.seen = options.sentMessageIds ?? new Set();
|
|
54
|
+
this.model = new ModelState(options.knownModels);
|
|
55
|
+
this.tools = new ToolMirror({
|
|
56
|
+
mintCallId: options.mintCallId ?? (() => `cli-${randomUUID()}`),
|
|
57
|
+
settleMs: options.toolSettleMs ?? DEFAULT_TOOL_SETTLE_MS,
|
|
58
|
+
onReady: (acpId) => {
|
|
59
|
+
this.events.push({ type: 'tool-ready', acpId });
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
this.turns = new TurnQueue({
|
|
63
|
+
target: () => {
|
|
64
|
+
const { transport, acpSessionId } = this;
|
|
65
|
+
if (this.failure !== undefined || transport === undefined || acpSessionId === undefined)
|
|
66
|
+
return undefined;
|
|
67
|
+
return { transport, acpSessionId };
|
|
68
|
+
},
|
|
69
|
+
onSend: (prompt) => {
|
|
70
|
+
this.seen = new Set([...this.seen, ...prompt.ids]);
|
|
71
|
+
},
|
|
72
|
+
onEnd: (event, wasCancelling) => {
|
|
73
|
+
this.tools.failOpen();
|
|
74
|
+
this.permissions.cancelAll();
|
|
75
|
+
if (!wasCancelling)
|
|
76
|
+
this.events.push(event);
|
|
77
|
+
},
|
|
78
|
+
onCancelTimeout: () => {
|
|
79
|
+
this.dispose(true).catch((error) => this.options.onError?.(error));
|
|
80
|
+
},
|
|
81
|
+
cancelGraceMs: options.cancelGraceMs ?? DEFAULT_CANCEL_GRACE_MS,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/** Whether this session can no longer run turns. */
|
|
85
|
+
get dead() {
|
|
86
|
+
return this.failure !== undefined;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Whether a dead session can be replaced. While its failure is unread it stays,
|
|
90
|
+
* so the next step reports why the CLI died instead of quietly starting another.
|
|
91
|
+
*/
|
|
92
|
+
get replaceable() {
|
|
93
|
+
return this.dead && this.events.size === 0;
|
|
94
|
+
}
|
|
95
|
+
/** Ids of the user messages this session or its predecessors sent to a CLI. */
|
|
96
|
+
get sentMessageIds() {
|
|
97
|
+
return this.seen;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Run one harness step: send any new user input, then read until the next tool
|
|
101
|
+
* call or the end of the CLI turn.
|
|
102
|
+
* @param request - the derived history and cancellation for this step.
|
|
103
|
+
* @yields the step's stream chunks, ending with `finish`.
|
|
104
|
+
*/
|
|
105
|
+
async *stream(request) {
|
|
106
|
+
const { signal } = request;
|
|
107
|
+
signal?.throwIfAborted();
|
|
108
|
+
if (this.failure === undefined) {
|
|
109
|
+
await this.prepareStep(request);
|
|
110
|
+
yield* this.readStep(signal);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (this.events.size === 0)
|
|
114
|
+
throw this.failure;
|
|
115
|
+
// The turn being read may look finished. If this request's input never reached
|
|
116
|
+
// the CLI, a clean finish would swallow the message with nothing on screen. This
|
|
117
|
+
// is remembered because a tool call read here moves the message out of the window
|
|
118
|
+
// `unseenUserInput` looks at, and the next step could no longer tell.
|
|
119
|
+
this.owesInput ||= this.unsentInput(request).blocks.length > 0;
|
|
120
|
+
yield* this.readStep(signal);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Wait for the CLI's result of a published call. This is the body of the mirror
|
|
124
|
+
* tool. It answers every open permission request, not only its own: a parent call
|
|
125
|
+
* can be waiting on a child whose mirror cannot start until this one returns.
|
|
126
|
+
* @param callId - the call id from the harness log.
|
|
127
|
+
* @param ask - asks the harness about a blocked call.
|
|
128
|
+
* @param signal - cancellation; aborting abandons the CLI turn.
|
|
129
|
+
* @returns the finished call's outcome.
|
|
130
|
+
*/
|
|
131
|
+
async waitForTool(callId, ask, signal) {
|
|
132
|
+
const acpId = this.tools.acpIdFor(callId);
|
|
133
|
+
if (acpId === undefined)
|
|
134
|
+
throw new Error(`ccgui: 调用 ${callId} 不是这个 CLI 会话发出的`);
|
|
135
|
+
this.tools.startMirror(acpId);
|
|
136
|
+
try {
|
|
137
|
+
const context = {
|
|
138
|
+
tools: this.tools,
|
|
139
|
+
permissions: this.permissions,
|
|
140
|
+
label: this.label,
|
|
141
|
+
turnSignal: () => this.turns.signal,
|
|
142
|
+
};
|
|
143
|
+
return await driveTool(context, { acpId, callId }, ask, signal ?? NEVER_ABORTED);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
this.abandonTurn();
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
this.tools.forget(acpId);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Stop whatever the CLI is doing for a harness turn that is over. The harness can
|
|
155
|
+
* end a turn between steps, when no stream or mirror is there to notice, so this
|
|
156
|
+
* is driven by the end of the harness turn. It does nothing on an idle session.
|
|
157
|
+
*/
|
|
158
|
+
abandonTurn() {
|
|
159
|
+
this.events.clear();
|
|
160
|
+
this.turns.clearPending();
|
|
161
|
+
this.permissions.cancelAll();
|
|
162
|
+
this.tools.dropUnmirrored();
|
|
163
|
+
const { acpSessionId } = this;
|
|
164
|
+
if (this.turns.active && !this.turns.cancelling && acpSessionId !== undefined) {
|
|
165
|
+
this.turns.beginCancel(() => this.transport?.cancel(acpSessionId));
|
|
166
|
+
}
|
|
167
|
+
this.turns.abortLifetime();
|
|
168
|
+
this.tools.failOpen();
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Stop the CLI process and fail anything still waiting on it.
|
|
172
|
+
* @param force - skip the wait for the CLI to exit by itself.
|
|
173
|
+
* @param reason - why it is being stopped, shown to a turn it cuts off.
|
|
174
|
+
*/
|
|
175
|
+
async dispose(force = false, reason) {
|
|
176
|
+
this.disposeRequested = true;
|
|
177
|
+
this.failure ??= new LlmError(reason === undefined ? `${this.label} 会话已关闭。` : `${this.label} 已停止:${reason}`, 'CLI_START_FAILED');
|
|
178
|
+
this.settleFailure(this.failure);
|
|
179
|
+
await this.transport?.dispose(force);
|
|
180
|
+
}
|
|
181
|
+
async prepareStep(request) {
|
|
182
|
+
const { messages, signal } = request;
|
|
183
|
+
this.rejectSkippedMirrors();
|
|
184
|
+
await abortable(this.ensureStarted(messages), signal);
|
|
185
|
+
if (this.turns.cancelling) {
|
|
186
|
+
await abortable(this.turns.untilEnded(), signal);
|
|
187
|
+
if (this.failure !== undefined) {
|
|
188
|
+
throw new LlmError(`${this.label} 没有响应取消,已被结束。这条消息没有发送,请重新发送。`, 'CLI_PROMPT_FAILED');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// The CLI can exit during either wait above, and a prompt queued on a dead
|
|
192
|
+
// session would never go out, leaving this step waiting forever.
|
|
193
|
+
if (this.failure !== undefined)
|
|
194
|
+
throw this.failure;
|
|
195
|
+
const input = this.unsentInput(request);
|
|
196
|
+
if (input.blocks.length === 0)
|
|
197
|
+
return;
|
|
198
|
+
// Read before anything is committed, so a failed read leaves the step as if never run.
|
|
199
|
+
const blocks = await this.promptBlocks(input, signal);
|
|
200
|
+
// Steering joins a turn already under way, so only a fresh prompt can switch.
|
|
201
|
+
if (!this.turns.busy) {
|
|
202
|
+
await this.applyModel(request.model, signal);
|
|
203
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/only-throw-error -- a callback may set failure during the await, which narrowing cannot see
|
|
204
|
+
if (this.failure !== undefined)
|
|
205
|
+
throw this.failure;
|
|
206
|
+
}
|
|
207
|
+
// Told only now because a failed switch ends the harness turn, which clears the queue,
|
|
208
|
+
// and the user still needs to hear this when they send again.
|
|
209
|
+
if (this.owesFreshNotice) {
|
|
210
|
+
this.owesFreshNotice = false;
|
|
211
|
+
this.pushFreshSessionNotice();
|
|
212
|
+
}
|
|
213
|
+
this.turns.enqueue({ ids: input.ids, blocks });
|
|
214
|
+
}
|
|
215
|
+
async promptBlocks(input, signal) {
|
|
216
|
+
const resolution = { acceptsImages: this.acceptsImages, readImage: this.options.readImage };
|
|
217
|
+
const blocks = await abortable(toPromptBlocks(input.blocks, resolution, signal), signal);
|
|
218
|
+
// The CLI can exit while the images are read; a prompt queued then would never go out.
|
|
219
|
+
if (this.failure !== undefined)
|
|
220
|
+
throw this.failure;
|
|
221
|
+
return blocks;
|
|
222
|
+
}
|
|
223
|
+
async applyModel(model, signal) {
|
|
224
|
+
const { transport, acpSessionId, label } = this;
|
|
225
|
+
if (transport === undefined || acpSessionId === undefined)
|
|
226
|
+
return;
|
|
227
|
+
await this.model.apply(model, { transport, acpSessionId, label }, signal);
|
|
228
|
+
}
|
|
229
|
+
/** Steering still in the queue is not sent yet, but must not be queued a second time. */
|
|
230
|
+
unsentInput({ messages, currentTurnMessageIds }) {
|
|
231
|
+
const handled = new Set([...this.seen, ...this.turns.queuedIds]);
|
|
232
|
+
return unseenUserInput(messages, handled, currentTurnMessageIds);
|
|
233
|
+
}
|
|
234
|
+
ensureStarted(messages) {
|
|
235
|
+
if (this.started !== undefined)
|
|
236
|
+
return this.started;
|
|
237
|
+
// A conversation the new CLI session never saw: a replacement process, a fork, or
|
|
238
|
+
// a session that started on another model. The user should know it starts blank.
|
|
239
|
+
const startsBlind = this.seen.size > 0 || messages.some((message) => message.role === 'assistant');
|
|
240
|
+
const nativeSessionId = readNativeSession(messages, this.options.dshSessionId, this.options.provider);
|
|
241
|
+
this.started = this.connectInTime(nativeSessionId, startsBlind).catch((error) => {
|
|
242
|
+
// Being stopped during startup is not a startup failure, and needs no install hint.
|
|
243
|
+
const failure = this.disposeRequested && this.failure !== undefined
|
|
244
|
+
? this.failure
|
|
245
|
+
: new LlmError(`无法启动 ${this.label}:${errorMessage(error)}。请确认它已安装并完成登录。`, 'CLI_START_FAILED', { cause: error });
|
|
246
|
+
this.failure = failure;
|
|
247
|
+
this.transport?.dispose(true).catch((disposeError) => this.options.onError?.(disposeError));
|
|
248
|
+
throw failure;
|
|
249
|
+
});
|
|
250
|
+
// An aborted step stops waiting for this; the rejection must not go unobserved.
|
|
251
|
+
this.started.catch(() => {
|
|
252
|
+
/* whoever awaits this.started handles the rejection */
|
|
253
|
+
});
|
|
254
|
+
return this.started;
|
|
255
|
+
}
|
|
256
|
+
connectInTime(nativeSessionId, startsBlind) {
|
|
257
|
+
return withinStartTimeout(() => this.connect(nativeSessionId, startsBlind), this.startTimeoutMs);
|
|
258
|
+
}
|
|
259
|
+
async connect(nativeSessionId, startsBlind) {
|
|
260
|
+
await this.options.after;
|
|
261
|
+
// The registry no longer holds a session stopped during that wait, so a CLI
|
|
262
|
+
// started now would have nobody to stop it.
|
|
263
|
+
if (this.failure !== undefined)
|
|
264
|
+
throw this.failure;
|
|
265
|
+
const { acpSessionId, reattachFailed, acceptsImages } = await openSession({
|
|
266
|
+
openTransport: this.options.openTransport,
|
|
267
|
+
handlers: {
|
|
268
|
+
onUpdate: (update) => this.handleUpdate(update),
|
|
269
|
+
onPermission: (request) => this.handlePermission(request),
|
|
270
|
+
onClosed: (error) => this.handleClosed(error),
|
|
271
|
+
},
|
|
272
|
+
cwd: this.options.cwd,
|
|
273
|
+
nativeSessionId,
|
|
274
|
+
model: this.model,
|
|
275
|
+
onTransport: (transport) => {
|
|
276
|
+
this.transport = transport;
|
|
277
|
+
},
|
|
278
|
+
onError: this.options.onError,
|
|
279
|
+
});
|
|
280
|
+
this.acpSessionId = acpSessionId;
|
|
281
|
+
this.acceptsImages = acceptsImages;
|
|
282
|
+
this.owesFreshNotice = reattachFailed || (nativeSessionId === undefined && startsBlind);
|
|
283
|
+
}
|
|
284
|
+
pushFreshSessionNotice() {
|
|
285
|
+
this.events.push({
|
|
286
|
+
type: 'text',
|
|
287
|
+
text: `(${this.label} 无法恢复之前的会话,已开始新会话,它不记得之前的内容。)\n\n`,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
handleUpdate(update) {
|
|
291
|
+
if (update.sessionUpdate === 'config_option_update') {
|
|
292
|
+
this.model.observe(update.configOptions);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
// Outside a turn nothing is reading, and stale output would surface in the next
|
|
296
|
+
// turn. While cancelling, the user has already left this turn behind.
|
|
297
|
+
if (!this.turns.active || this.turns.cancelling)
|
|
298
|
+
return;
|
|
299
|
+
switch (update.sessionUpdate) {
|
|
300
|
+
case 'agent_message_chunk':
|
|
301
|
+
case 'agent_thought_chunk':
|
|
302
|
+
if (update.content.type === 'text') {
|
|
303
|
+
const type = update.sessionUpdate === 'agent_message_chunk' ? 'text' : 'reasoning';
|
|
304
|
+
this.events.push({ type, text: update.content.text });
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
case 'tool_call':
|
|
308
|
+
this.tools.reopen(update.toolCallId);
|
|
309
|
+
this.tools.apply(update, false);
|
|
310
|
+
return;
|
|
311
|
+
case 'tool_call_update':
|
|
312
|
+
if (!this.tools.isClaimed(update.toolCallId))
|
|
313
|
+
this.tools.apply(update, false);
|
|
314
|
+
return;
|
|
315
|
+
default:
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
handlePermission(request) {
|
|
319
|
+
if (!this.turns.active || this.turns.cancelling)
|
|
320
|
+
return Promise.resolve(CANCELLED);
|
|
321
|
+
const { toolCall, options } = request;
|
|
322
|
+
const { promise, resolve } = Promise.withResolvers();
|
|
323
|
+
this.permissions.add({ acpId: toolCall.toolCallId, options, resolve });
|
|
324
|
+
// Its mirror may have been skipped earlier. Asking again reopens the call, and
|
|
325
|
+
// its later updates must reach the new mirror.
|
|
326
|
+
this.tools.reopen(toolCall.toolCallId);
|
|
327
|
+
this.tools.apply(toolCall, true);
|
|
328
|
+
return promise;
|
|
329
|
+
}
|
|
330
|
+
handleClosed(error) {
|
|
331
|
+
this.failure ??= new LlmError(`${this.label} 已退出:${error.message}`, 'CLI_PROMPT_FAILED', { cause: error });
|
|
332
|
+
this.settleFailure(this.failure);
|
|
333
|
+
}
|
|
334
|
+
settleFailure(failure) {
|
|
335
|
+
this.turns.clearPending();
|
|
336
|
+
this.turns.end({ type: 'failure', error: failure });
|
|
337
|
+
this.tools.failOpen();
|
|
338
|
+
this.permissions.cancelAll();
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* A mirror can be denied by a policy or skipped by a stop before it runs, leaving
|
|
342
|
+
* the CLI blocked on an answer nobody would give. By the next step the harness has
|
|
343
|
+
* finished with the call, so a request still open for it is refused.
|
|
344
|
+
*/
|
|
345
|
+
rejectSkippedMirrors() {
|
|
346
|
+
for (const acpId of this.tools.skippedMirrors()) {
|
|
347
|
+
this.permissions.rejectFor(acpId);
|
|
348
|
+
this.tools.forget(acpId);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
hasWork() {
|
|
352
|
+
return this.turns.busy || this.events.size > 0;
|
|
353
|
+
}
|
|
354
|
+
replayFields() {
|
|
355
|
+
return this.acpSessionId === undefined
|
|
356
|
+
? {}
|
|
357
|
+
: { replayState: nativeReplayState(this.acpSessionId, this.options.dshSessionId) };
|
|
358
|
+
}
|
|
359
|
+
readStep(signal) {
|
|
360
|
+
return readStep({
|
|
361
|
+
label: this.label,
|
|
362
|
+
nextEvent: (stepSignal) => this.nextEvent(stepSignal),
|
|
363
|
+
hasWork: () => this.hasWork(),
|
|
364
|
+
owesInput: () => this.owesInput,
|
|
365
|
+
failure: () => this.failure,
|
|
366
|
+
handOff: (acpId) => {
|
|
367
|
+
const record = this.tools.get(acpId);
|
|
368
|
+
if (record !== undefined)
|
|
369
|
+
this.tools.handOff(acpId);
|
|
370
|
+
return record;
|
|
371
|
+
},
|
|
372
|
+
replay: () => this.replayFields(),
|
|
373
|
+
}, signal);
|
|
374
|
+
}
|
|
375
|
+
async nextEvent(signal) {
|
|
376
|
+
try {
|
|
377
|
+
return await this.events.next(signal);
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
this.abandonTurn();
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mirror tool. A CLI runs its tools itself, but the harness can only show and
|
|
3
|
+
* log a call that goes through a registered tool. This tool's body runs nothing:
|
|
4
|
+
* it waits for the CLI to report what happened.
|
|
5
|
+
* @module dsh-ccgui/cli-tool
|
|
6
|
+
*/
|
|
7
|
+
import type { ToolDefinition, ToolRunContext } from '@deepseek-ai/dsh-tools';
|
|
8
|
+
import type { ToolOutcome } from './tool-record.ts';
|
|
9
|
+
/** What the tool needs from the bridge. */
|
|
10
|
+
export interface CliToolDeps {
|
|
11
|
+
waitForTool(exec: ToolRunContext): Promise<ToolOutcome>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Build the tool definition.
|
|
15
|
+
* @param deps - the bridge.
|
|
16
|
+
* @returns the `cli_tool` definition.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createCliTool(deps: CliToolDeps): ToolDefinition;
|
package/lib/cli-tool.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mirror tool. A CLI runs its tools itself, but the harness can only show and
|
|
3
|
+
* log a call that goes through a registered tool. This tool's body runs nothing:
|
|
4
|
+
* it waits for the CLI to report what happened.
|
|
5
|
+
* @module dsh-ccgui/cli-tool
|
|
6
|
+
*/
|
|
7
|
+
import { HarnessError } from '@deepseek-ai/dsh-llm';
|
|
8
|
+
import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools';
|
|
9
|
+
import { CLI_TOOL_NAME } from "./constants.js";
|
|
10
|
+
const KNOWN_KINDS = new Set(['read', 'edit', 'delete', 'move', 'search', 'execute', 'fetch']);
|
|
11
|
+
const DIFF_SCHEMA = {
|
|
12
|
+
type: 'object',
|
|
13
|
+
additionalProperties: false,
|
|
14
|
+
properties: {
|
|
15
|
+
path: { type: 'string', required: true },
|
|
16
|
+
oldText: { oneOf: [{ type: 'string' }, { type: 'null' }], required: true },
|
|
17
|
+
newText: { type: 'string', required: true },
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
function toLocations(paths) {
|
|
21
|
+
return (paths ?? []).map((path) => ({ path }));
|
|
22
|
+
}
|
|
23
|
+
function presentCall(args) {
|
|
24
|
+
const { title, kind, command, input, diffs, locations } = args;
|
|
25
|
+
if (command !== undefined) {
|
|
26
|
+
return { card: 'terminal', title: command, ...(title === command ? {} : { description: title }) };
|
|
27
|
+
}
|
|
28
|
+
if (diffs !== undefined && diffs.length > 0) {
|
|
29
|
+
return { card: 'diff', title, diffs: [...diffs], locations: toLocations(locations) };
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
card: 'generic',
|
|
33
|
+
title,
|
|
34
|
+
kind: (KNOWN_KINDS.has(kind) ? kind : 'other'),
|
|
35
|
+
...(input === undefined ? {} : { rawInput: input }),
|
|
36
|
+
...(locations === undefined ? {} : { locations: toLocations(locations) }),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function resultText(result) {
|
|
40
|
+
return result.content.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n');
|
|
41
|
+
}
|
|
42
|
+
function presentResult(args, result) {
|
|
43
|
+
if (result.isError)
|
|
44
|
+
return undefined;
|
|
45
|
+
if (args.command !== undefined)
|
|
46
|
+
return { card: 'terminal', output: resultText(result) };
|
|
47
|
+
const diffs = result.meta?.diffs;
|
|
48
|
+
return diffs !== undefined && diffs.length > 0 ? { card: 'diff', diffs } : undefined;
|
|
49
|
+
}
|
|
50
|
+
/** The harness aborts with a plain object, which would be logged as `[object Object]`. */
|
|
51
|
+
async function waitForOutcome(deps, exec) {
|
|
52
|
+
try {
|
|
53
|
+
return await deps.waitForTool(exec);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (!exec.signal.aborted)
|
|
57
|
+
throw error;
|
|
58
|
+
const aborted = new HarnessError('tool call aborted', TOOL_ABORTED, { cause: error });
|
|
59
|
+
aborted.name = 'AbortError';
|
|
60
|
+
throw aborted;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Build the tool definition.
|
|
65
|
+
* @param deps - the bridge.
|
|
66
|
+
* @returns the `cli_tool` definition.
|
|
67
|
+
*/
|
|
68
|
+
export function createCliTool(deps) {
|
|
69
|
+
return defineTool({
|
|
70
|
+
name: CLI_TOOL_NAME,
|
|
71
|
+
description: 'Records a tool call made by an external CLI. It runs nothing itself.',
|
|
72
|
+
parameters: {
|
|
73
|
+
title: { type: 'string', required: true, description: 'What the CLI says this call does.' },
|
|
74
|
+
kind: { type: 'string', required: true, description: 'The ACP tool kind.' },
|
|
75
|
+
command: { type: 'string', description: 'The shell command, for an execute call.' },
|
|
76
|
+
input: { type: 'json', description: 'The raw input the CLI reported.' },
|
|
77
|
+
diffs: { type: 'array', items: DIFF_SCHEMA },
|
|
78
|
+
locations: { type: 'array', items: { type: 'string' } },
|
|
79
|
+
},
|
|
80
|
+
output: {
|
|
81
|
+
schema: {
|
|
82
|
+
type: 'object',
|
|
83
|
+
additionalProperties: false,
|
|
84
|
+
properties: {
|
|
85
|
+
text: { type: 'string', required: true },
|
|
86
|
+
diffs: { type: 'array', items: DIFF_SCHEMA, required: true },
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
90
|
+
presentationMeta: (_args, value) => ({ diffs: value.diffs }),
|
|
91
|
+
},
|
|
92
|
+
async execute(_args, exec) {
|
|
93
|
+
const outcome = await waitForOutcome(deps, exec);
|
|
94
|
+
if (outcome.failed)
|
|
95
|
+
throw new Error(outcome.text);
|
|
96
|
+
return { text: outcome.text, diffs: outcome.diffs.map((diff) => ({ ...diff })) };
|
|
97
|
+
},
|
|
98
|
+
presentCall: (args) => presentCall(args),
|
|
99
|
+
presentResult: (args, result) => presentResult(args, result),
|
|
100
|
+
});
|
|
101
|
+
}
|