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,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asks a CLI which models it offers. ACP only reports them on a session, so this
|
|
3
|
+
* opens one and removes it again. It sends no prompt, so no model is called.
|
|
4
|
+
* @module dsh-ccgui/model-probe
|
|
5
|
+
*/
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
|
+
import { abortable } from "./abortable.js";
|
|
8
|
+
import { readModelList } from "./model-options.js";
|
|
9
|
+
const IGNORING_HANDLERS = {
|
|
10
|
+
onUpdate: () => {
|
|
11
|
+
/* a probe only reads the model list */
|
|
12
|
+
},
|
|
13
|
+
onPermission: () => Promise.resolve({ outcome: { outcome: 'cancelled' } }),
|
|
14
|
+
onClosed: () => {
|
|
15
|
+
/* the caller's dispose handles the close */
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
const CLEANUP_LIMIT_MS = 3_000;
|
|
19
|
+
/**
|
|
20
|
+
* Otherwise every probe would leave an empty session in the CLI's history. Failing to
|
|
21
|
+
* clean up does not make the answer any less true, so it gets its own short limit and
|
|
22
|
+
* can never cost the list already in hand.
|
|
23
|
+
*/
|
|
24
|
+
async function discardSession(transport, sessionId, capabilities, limitMs) {
|
|
25
|
+
const discarding = capabilities?.delete != null
|
|
26
|
+
? transport.deleteSession(sessionId)
|
|
27
|
+
: capabilities?.close != null
|
|
28
|
+
? transport.closeSession(sessionId)
|
|
29
|
+
: undefined;
|
|
30
|
+
if (discarding === undefined)
|
|
31
|
+
return;
|
|
32
|
+
await Promise.race([
|
|
33
|
+
discarding.catch(() => {
|
|
34
|
+
/* a failed cleanup does not change the answer in hand */
|
|
35
|
+
}),
|
|
36
|
+
delay(limitMs, undefined, { ref: false }),
|
|
37
|
+
]);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Runs behind the answer, because removing the session takes Pi seconds and exiting takes
|
|
41
|
+
* Codex as long, with the user watching a menu that could already be filled.
|
|
42
|
+
*/
|
|
43
|
+
async function cleanUp(transport, discard, limitMs, signal) {
|
|
44
|
+
await abortable(discard(limitMs), signal).catch(() => {
|
|
45
|
+
/* a failed cleanup does not change the answer in hand */
|
|
46
|
+
});
|
|
47
|
+
await transport.dispose(signal?.aborted === true).catch(() => {
|
|
48
|
+
/* likewise */
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
async function askModels(transport, cwd) {
|
|
52
|
+
const { agentCapabilities } = await transport.initialize();
|
|
53
|
+
const { sessionId, configOptions } = await transport.newSession(cwd);
|
|
54
|
+
return {
|
|
55
|
+
list: readModelList(configOptions),
|
|
56
|
+
discard: (limitMs) => discardSession(transport, sessionId, agentCapabilities?.sessionCapabilities, limitMs),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Start a CLI just long enough to read its model list.
|
|
61
|
+
* @param open - starts the CLI.
|
|
62
|
+
* @param cwd - working directory for the temporary session.
|
|
63
|
+
* @param timeoutMs - how long to wait, which must allow for a cold `npx` download.
|
|
64
|
+
* @param signal - stops the CLI at once when the bridge is shutting down.
|
|
65
|
+
* @returns the model list, or undefined when the CLI offers none.
|
|
66
|
+
*/
|
|
67
|
+
export async function probeModels(open, cwd, timeoutMs, signal) {
|
|
68
|
+
signal?.throwIfAborted();
|
|
69
|
+
const transport = open(IGNORING_HANDLERS);
|
|
70
|
+
const { promise: timedOut, reject } = Promise.withResolvers();
|
|
71
|
+
const timer = setTimeout(() => {
|
|
72
|
+
reject(new Error('超时,CLI 没有响应'));
|
|
73
|
+
}, timeoutMs);
|
|
74
|
+
const cleanupLimitMs = Math.min(CLEANUP_LIMIT_MS, timeoutMs / 2);
|
|
75
|
+
try {
|
|
76
|
+
const { list, discard } = await abortable(Promise.race([askModels(transport, cwd), timedOut]), signal);
|
|
77
|
+
void cleanUp(transport, discard, cleanupLimitMs, signal).catch(() => {
|
|
78
|
+
/* background cleanup; nobody to report to */
|
|
79
|
+
});
|
|
80
|
+
return list;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
await transport.dispose(true).catch(() => {
|
|
84
|
+
/* the original error is rethrown; a failed dispose must not replace it */
|
|
85
|
+
});
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starts a version-pinned `npx` package straight from the copy npx already cached. On every
|
|
3
|
+
* start `npx` first asks the registry about the package, even with that copy at hand, which
|
|
4
|
+
* measured 4 to 26 seconds here and holds up both the model menu and the first message.
|
|
5
|
+
*
|
|
6
|
+
* Its `--prefer-offline` flag skips the question but is not a fix: it fails on a version newer
|
|
7
|
+
* than the cached metadata, and it reaches the CLI as `npm_config_prefer_offline`, so npm
|
|
8
|
+
* commands the agent runs in the user's project would quietly see stale metadata.
|
|
9
|
+
*
|
|
10
|
+
* Everything here rests on npm's cache layout, so whatever is not plainly there goes to `npx`
|
|
11
|
+
* as configured. A change on npm's side can only make a start slower, never wrong.
|
|
12
|
+
* @module dsh-ccgui/npx-cache
|
|
13
|
+
*/
|
|
14
|
+
/** How to start one CLI. */
|
|
15
|
+
export interface CliCommand {
|
|
16
|
+
readonly argv: readonly string[];
|
|
17
|
+
readonly env: Readonly<Record<string, string>>;
|
|
18
|
+
}
|
|
19
|
+
/** Where to look, and what to build the child's path on. */
|
|
20
|
+
export interface NpxCacheOptions {
|
|
21
|
+
/** npm's `_npx` directory, or undefined where this cannot work. */
|
|
22
|
+
readonly npxCache: string | undefined;
|
|
23
|
+
readonly path: string | undefined;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Point a command at npx's cached copy of its package, when that is safe.
|
|
27
|
+
* @param command - the command as configured.
|
|
28
|
+
* @param options - where to look.
|
|
29
|
+
* @returns the command to run: the same one unless a cached copy can stand in for it.
|
|
30
|
+
*/
|
|
31
|
+
export declare function startFromNpxCache(command: CliCommand, { npxCache, path }: NpxCacheOptions): CliCommand;
|
|
32
|
+
/**
|
|
33
|
+
* Locate npx's cache. A cache moved by `.npmrc` is not found, which only means going by `npx`.
|
|
34
|
+
* @param env - the process environment.
|
|
35
|
+
* @param home - the user's home directory.
|
|
36
|
+
* @param platform - the current platform.
|
|
37
|
+
* @returns the `_npx` directory, or undefined on Windows, whose links need a shell to start.
|
|
38
|
+
*/
|
|
39
|
+
export declare function npxCacheDir(env: Readonly<Record<string, string | undefined>>, home: string, platform: NodeJS.Platform): string | undefined;
|
package/lib/npx-cache.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starts a version-pinned `npx` package straight from the copy npx already cached. On every
|
|
3
|
+
* start `npx` first asks the registry about the package, even with that copy at hand, which
|
|
4
|
+
* measured 4 to 26 seconds here and holds up both the model menu and the first message.
|
|
5
|
+
*
|
|
6
|
+
* Its `--prefer-offline` flag skips the question but is not a fix: it fails on a version newer
|
|
7
|
+
* than the cached metadata, and it reaches the CLI as `npm_config_prefer_offline`, so npm
|
|
8
|
+
* commands the agent runs in the user's project would quietly see stale metadata.
|
|
9
|
+
*
|
|
10
|
+
* Everything here rests on npm's cache layout, so whatever is not plainly there goes to `npx`
|
|
11
|
+
* as configured. A change on npm's side can only make a start slower, never wrong.
|
|
12
|
+
* @module dsh-ccgui/npx-cache
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { basename, delimiter, join } from 'node:path';
|
|
17
|
+
const NPX = 'npx';
|
|
18
|
+
const YES_FLAGS = new Set(['-y', '--yes']);
|
|
19
|
+
const KEY_LENGTH = 16;
|
|
20
|
+
/** Exact versions only: a tag or range means asking the registry, and the name stays inside the cache. */
|
|
21
|
+
const PINNED_SPEC = /^((?:@[a-z0-9~-][a-z0-9._~-]*\/)?[a-z0-9~-][a-z0-9._~-]*)@(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?)$/;
|
|
22
|
+
const isObject = (value) => typeof value === 'object' && value !== null;
|
|
23
|
+
function readPinned([command, ...args]) {
|
|
24
|
+
if (command !== NPX)
|
|
25
|
+
return undefined;
|
|
26
|
+
const [spec, ...rest] = YES_FLAGS.has(args[0] ?? '') ? args.slice(1) : args;
|
|
27
|
+
const match = PINNED_SPEC.exec(spec ?? '');
|
|
28
|
+
if (spec === undefined || match === null)
|
|
29
|
+
return undefined;
|
|
30
|
+
const [, name, version] = match;
|
|
31
|
+
return name === undefined || version === undefined ? undefined : { spec, name, version, args: rest };
|
|
32
|
+
}
|
|
33
|
+
function readManifest(path) {
|
|
34
|
+
try {
|
|
35
|
+
const manifest = JSON.parse(readFileSync(path, 'utf8'));
|
|
36
|
+
return isObject(manifest) ? manifest : undefined;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Follows npm's own choice in `libnpmexec`, so this runs what `npx` would have run. */
|
|
43
|
+
function binOf(bin, name) {
|
|
44
|
+
const unscoped = name.replace(/^@[^/]+\//, '');
|
|
45
|
+
const bins = typeof bin === 'string' ? { [unscoped]: bin } : isObject(bin) ? bin : {};
|
|
46
|
+
const [first] = Object.keys(bins);
|
|
47
|
+
if (new Set(Object.values(bins)).size === 1)
|
|
48
|
+
return first;
|
|
49
|
+
return unscoped in bins ? unscoped : undefined;
|
|
50
|
+
}
|
|
51
|
+
/** A finished install leaves this lockfile, and only the same version will do. */
|
|
52
|
+
function findCachedBin(npxCache, { spec, name, version }) {
|
|
53
|
+
const key = createHash('sha512').update(spec).digest('hex').slice(0, KEY_LENGTH);
|
|
54
|
+
const modules = join(npxCache, key, 'node_modules');
|
|
55
|
+
if (!existsSync(join(modules, '.package-lock.json')))
|
|
56
|
+
return undefined;
|
|
57
|
+
const manifest = readManifest(join(modules, name, 'package.json'));
|
|
58
|
+
if (manifest?.version !== version)
|
|
59
|
+
return undefined;
|
|
60
|
+
const bin = binOf(manifest.bin, name);
|
|
61
|
+
if (bin === undefined || basename(bin) !== bin)
|
|
62
|
+
return undefined;
|
|
63
|
+
const dir = join(modules, '.bin');
|
|
64
|
+
return existsSync(join(dir, bin)) ? { dir, file: join(dir, bin) } : undefined;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Point a command at npx's cached copy of its package, when that is safe.
|
|
68
|
+
* @param command - the command as configured.
|
|
69
|
+
* @param options - where to look.
|
|
70
|
+
* @returns the command to run: the same one unless a cached copy can stand in for it.
|
|
71
|
+
*/
|
|
72
|
+
export function startFromNpxCache(command, { npxCache, path }) {
|
|
73
|
+
if (npxCache === undefined)
|
|
74
|
+
return command;
|
|
75
|
+
const pinned = readPinned(command.argv);
|
|
76
|
+
const found = pinned === undefined ? undefined : findCachedBin(npxCache, pinned);
|
|
77
|
+
if (pinned === undefined || found === undefined)
|
|
78
|
+
return command;
|
|
79
|
+
const inherited = command.env.PATH ?? path;
|
|
80
|
+
return {
|
|
81
|
+
argv: [found.file, ...pinned.args],
|
|
82
|
+
// `npx` puts these on the path too, for a CLI that starts a dependency's command by name.
|
|
83
|
+
env: { ...command.env, PATH: inherited === undefined ? found.dir : `${found.dir}${delimiter}${inherited}` },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Locate npx's cache. A cache moved by `.npmrc` is not found, which only means going by `npx`.
|
|
88
|
+
* @param env - the process environment.
|
|
89
|
+
* @param home - the user's home directory.
|
|
90
|
+
* @param platform - the current platform.
|
|
91
|
+
* @returns the `_npx` directory, or undefined on Windows, whose links need a shell to start.
|
|
92
|
+
*/
|
|
93
|
+
export function npxCacheDir(env, home, platform) {
|
|
94
|
+
if (platform === 'win32')
|
|
95
|
+
return undefined;
|
|
96
|
+
return join(env.npm_config_cache ?? join(home, '.npm'), '_npx');
|
|
97
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps a harness approval decision onto the options a CLI offered.
|
|
3
|
+
* @module dsh-ccgui/permission
|
|
4
|
+
*/
|
|
5
|
+
import type { PermissionOption, RequestPermissionResponse } from '@agentclientprotocol/sdk';
|
|
6
|
+
import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval';
|
|
7
|
+
/** The answer for a request nobody will decide on. */
|
|
8
|
+
export declare const CANCELLED: RequestPermissionResponse;
|
|
9
|
+
/**
|
|
10
|
+
* Answer a CLI permission request. A harness approval covers one operation, so it
|
|
11
|
+
* never selects `allow_always`: with no one-time option on offer, the safe answer is
|
|
12
|
+
* no. `unavailable` means nobody could answer and is also a no, which fails closed.
|
|
13
|
+
* @param outcome - the harness approval decision.
|
|
14
|
+
* @param options - the options the CLI offered.
|
|
15
|
+
* @returns the ACP response.
|
|
16
|
+
*/
|
|
17
|
+
export declare function permissionResponse(outcome: ApprovalOutcome, options: readonly PermissionOption[]): RequestPermissionResponse;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps a harness approval decision onto the options a CLI offered.
|
|
3
|
+
* @module dsh-ccgui/permission
|
|
4
|
+
*/
|
|
5
|
+
/** The answer for a request nobody will decide on. */
|
|
6
|
+
export const CANCELLED = { outcome: { outcome: 'cancelled' } };
|
|
7
|
+
function selectFirst(options, kinds) {
|
|
8
|
+
for (const kind of kinds) {
|
|
9
|
+
const match = options.find((option) => option.kind === kind);
|
|
10
|
+
if (match !== undefined)
|
|
11
|
+
return { outcome: { outcome: 'selected', optionId: match.optionId } };
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Answer a CLI permission request. A harness approval covers one operation, so it
|
|
17
|
+
* never selects `allow_always`: with no one-time option on offer, the safe answer is
|
|
18
|
+
* no. `unavailable` means nobody could answer and is also a no, which fails closed.
|
|
19
|
+
* @param outcome - the harness approval decision.
|
|
20
|
+
* @param options - the options the CLI offered.
|
|
21
|
+
* @returns the ACP response.
|
|
22
|
+
*/
|
|
23
|
+
export function permissionResponse(outcome, options) {
|
|
24
|
+
const reject = selectFirst(options, ['reject_once', 'reject_always']) ?? CANCELLED;
|
|
25
|
+
switch (outcome) {
|
|
26
|
+
case 'allowed-once':
|
|
27
|
+
return selectFirst(options, ['allow_once']) ?? reject;
|
|
28
|
+
case 'rejected':
|
|
29
|
+
case 'unavailable':
|
|
30
|
+
return reject;
|
|
31
|
+
case 'cancelled':
|
|
32
|
+
return CANCELLED;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns the images in a user message into what the CLI can take. The harness keeps
|
|
3
|
+
* images as references to its attachment store; a CLI only understands inline bytes,
|
|
4
|
+
* so each one is read here, just before the prompt goes out.
|
|
5
|
+
* @module dsh-ccgui/prompt-images
|
|
6
|
+
*/
|
|
7
|
+
import type { ContentBlock as AcpContentBlock } from '@agentclientprotocol/sdk';
|
|
8
|
+
/**
|
|
9
|
+
* The fields of the harness's image reference this bridge relies on. The full type
|
|
10
|
+
* lives in `@deepseek-ai/dsh-attachment`, which the host provides but this package does
|
|
11
|
+
* not depend on; the reference is passed back to the host untouched.
|
|
12
|
+
*/
|
|
13
|
+
export interface ImageRef {
|
|
14
|
+
readonly attachmentId: string;
|
|
15
|
+
readonly mediaType: string;
|
|
16
|
+
readonly name?: string;
|
|
17
|
+
}
|
|
18
|
+
/** One piece of user input before its images are resolved. */
|
|
19
|
+
export type InputBlock = {
|
|
20
|
+
readonly type: 'text';
|
|
21
|
+
readonly text: string;
|
|
22
|
+
} | {
|
|
23
|
+
readonly type: 'image';
|
|
24
|
+
readonly ref: ImageRef;
|
|
25
|
+
readonly offloaded: boolean;
|
|
26
|
+
};
|
|
27
|
+
/** Reads one image's stored bytes. */
|
|
28
|
+
export type ReadImage = (ref: ImageRef, signal?: AbortSignal) => Promise<Uint8Array>;
|
|
29
|
+
/** What resolving images needs to know. */
|
|
30
|
+
export interface ImageResolution {
|
|
31
|
+
/** The CLI declared `promptCapabilities.image`; ACP forbids sending images otherwise. */
|
|
32
|
+
readonly acceptsImages: boolean;
|
|
33
|
+
/** Absent when the harness runs without an attachment service. */
|
|
34
|
+
readonly readImage?: ReadImage | undefined;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolve user input into ACP prompt blocks, in order.
|
|
38
|
+
* @param blocks - text and image references from the user's messages.
|
|
39
|
+
* @param resolution - whether the CLI takes images and how to read them.
|
|
40
|
+
* @param signal - cancellation for the reads.
|
|
41
|
+
* @returns the prompt to send.
|
|
42
|
+
* @throws LlmError when an image cannot be read; sending without it would hide it from the CLI.
|
|
43
|
+
*/
|
|
44
|
+
export declare function toPromptBlocks(blocks: readonly InputBlock[], resolution: ImageResolution, signal?: AbortSignal): Promise<AcpContentBlock[]>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns the images in a user message into what the CLI can take. The harness keeps
|
|
3
|
+
* images as references to its attachment store; a CLI only understands inline bytes,
|
|
4
|
+
* so each one is read here, just before the prompt goes out.
|
|
5
|
+
* @module dsh-ccgui/prompt-images
|
|
6
|
+
*/
|
|
7
|
+
import { LlmError, offloadedImageText, textOnlyImageText } from '@deepseek-ai/dsh-llm';
|
|
8
|
+
function imageLabel(ref) {
|
|
9
|
+
return ref.name ?? ref.attachmentId;
|
|
10
|
+
}
|
|
11
|
+
async function imageBlock(ref, readImage, signal) {
|
|
12
|
+
if (readImage === undefined) {
|
|
13
|
+
throw new LlmError('当前环境没有附件服务,无法把图片发送给 CLI。', 'UNSUPPORTED_CONTENT');
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const data = await readImage(ref, signal);
|
|
17
|
+
return { type: 'image', mimeType: ref.mediaType, data: Buffer.from(data).toString('base64') };
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
signal?.throwIfAborted();
|
|
21
|
+
throw new LlmError(`无法读取图片「${imageLabel(ref)}」,这条消息没有发送。`, 'CLI_PROMPT_FAILED', {
|
|
22
|
+
cause: error,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function toBlock(block, resolution, signal) {
|
|
27
|
+
if (block.type === 'text')
|
|
28
|
+
return Promise.resolve(block);
|
|
29
|
+
// `ref` is the host's own reference object, so its placeholder texts accept it as is.
|
|
30
|
+
if (block.offloaded)
|
|
31
|
+
return Promise.resolve({ type: 'text', text: offloadedImageText(block.ref) });
|
|
32
|
+
if (!resolution.acceptsImages)
|
|
33
|
+
return Promise.resolve({ type: 'text', text: textOnlyImageText(block.ref) });
|
|
34
|
+
return imageBlock(block.ref, resolution.readImage, signal);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolve user input into ACP prompt blocks, in order.
|
|
38
|
+
* @param blocks - text and image references from the user's messages.
|
|
39
|
+
* @param resolution - whether the CLI takes images and how to read them.
|
|
40
|
+
* @param signal - cancellation for the reads.
|
|
41
|
+
* @returns the prompt to send.
|
|
42
|
+
* @throws LlmError when an image cannot be read; sending without it would hide it from the CLI.
|
|
43
|
+
*/
|
|
44
|
+
export function toPromptBlocks(blocks, resolution, signal) {
|
|
45
|
+
return Promise.all(blocks.map((block) => toBlock(block, resolution, signal)));
|
|
46
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads what the CLI still needs from a harness model request. The CLI holds its
|
|
3
|
+
* own conversation, so only new user input and the native session id matter.
|
|
4
|
+
* @module dsh-ccgui/request-input
|
|
5
|
+
*/
|
|
6
|
+
import type { ReplayEnvelope, RequestMessage } from '@deepseek-ai/dsh-llm';
|
|
7
|
+
import type { InputBlock } from './prompt-images.ts';
|
|
8
|
+
/** User input the CLI has not received yet. */
|
|
9
|
+
export interface UserInput {
|
|
10
|
+
readonly ids: readonly string[];
|
|
11
|
+
/** Text and image references; images are read only when the prompt is sent. */
|
|
12
|
+
readonly blocks: readonly InputBlock[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Collect the user input still owed to the CLI: messages after the last assistant or
|
|
16
|
+
* tool message, from the current turn, not sent yet. A tail ending in a tool result
|
|
17
|
+
* yields nothing, which is how a continuation step is told apart from a new prompt.
|
|
18
|
+
* @param messages - the derived request history.
|
|
19
|
+
* @param seen - ids of messages already sent to the CLI.
|
|
20
|
+
* @param currentTurn - ids of the user messages in the open turn; omitted, all qualify.
|
|
21
|
+
* @returns new message ids and their text and image blocks, in conversation order.
|
|
22
|
+
*/
|
|
23
|
+
export declare function unseenUserInput(messages: readonly RequestMessage[], seen: ReadonlySet<string>, currentTurn?: ReadonlySet<string>): UserInput;
|
|
24
|
+
/**
|
|
25
|
+
* Build the replay state that ties a harness session to its CLI session.
|
|
26
|
+
* @param acpSessionId - the CLI's session id.
|
|
27
|
+
* @param dshSessionId - the harness session that owns it.
|
|
28
|
+
* @returns the envelope carried by `finish` and stored on the assistant message.
|
|
29
|
+
*/
|
|
30
|
+
export declare function nativeReplayState(acpSessionId: string, dshSessionId: string): ReplayEnvelope;
|
|
31
|
+
/**
|
|
32
|
+
* Find the CLI session to reattach to, from the latest assistant message.
|
|
33
|
+
* The owner check keeps a fork, which inherits its parent's messages under a new
|
|
34
|
+
* id, from writing into the parent's CLI session. The provider check is needed because
|
|
35
|
+
* all CLIs share one adapter, so the harness does not strip another route's replay state,
|
|
36
|
+
* and a session id means nothing to any other CLI.
|
|
37
|
+
* @param messages - the derived request history.
|
|
38
|
+
* @param dshSessionId - the harness session asking.
|
|
39
|
+
* @param provider - the CLI route asking.
|
|
40
|
+
* @returns the CLI session id, or undefined when there is none to reuse.
|
|
41
|
+
*/
|
|
42
|
+
export declare function readNativeSession(messages: readonly RequestMessage[], dshSessionId: string, provider: string): string | undefined;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads what the CLI still needs from a harness model request. The CLI holds its
|
|
3
|
+
* own conversation, so only new user input and the native session id matter.
|
|
4
|
+
* @module dsh-ccgui/request-input
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Message sources written by a person. The harness also uses the user role for its
|
|
8
|
+
* own reminders and instructions; those are meant for its own model, and forwarded
|
|
9
|
+
* they would start CLI turns nobody asked for.
|
|
10
|
+
*/
|
|
11
|
+
const FORWARDED_KINDS = new Set(['user', 'schedule']);
|
|
12
|
+
/**
|
|
13
|
+
* Starts the harness's note about a schedule edit. It has the same source kind as a
|
|
14
|
+
* real reminder and says it needs no action, and this text is the only difference.
|
|
15
|
+
*/
|
|
16
|
+
const SCHEDULE_NOTICE_PREFIX = '[SCHEDULE CHANGE]';
|
|
17
|
+
function messagesAfterLastReply(messages) {
|
|
18
|
+
const lastReply = messages.findLastIndex((message) => message.role === 'assistant' || message.role === 'tool');
|
|
19
|
+
return messages.slice(lastReply + 1);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The reference's type comes from a package this one does not depend on, so its shape
|
|
23
|
+
* is checked here instead of trusted.
|
|
24
|
+
*/
|
|
25
|
+
function imageRefOf(block) {
|
|
26
|
+
const { attachment } = block;
|
|
27
|
+
if (typeof attachment !== 'object' || attachment === null)
|
|
28
|
+
return undefined;
|
|
29
|
+
const { attachmentId, mediaType } = attachment;
|
|
30
|
+
return typeof attachmentId === 'string' && typeof mediaType === 'string' ? attachment : undefined;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Text and images are what a person writes. Files arrive already turned into text by
|
|
34
|
+
* the host, and the remaining block kinds belong to model or tool output.
|
|
35
|
+
*/
|
|
36
|
+
function inputBlocks(message) {
|
|
37
|
+
return message.content.flatMap((block) => {
|
|
38
|
+
if (block.type === 'text')
|
|
39
|
+
return [{ type: 'text', text: block.text }];
|
|
40
|
+
if (block.type === 'image') {
|
|
41
|
+
const ref = imageRefOf(block);
|
|
42
|
+
return ref === undefined ? [] : [{ type: 'image', ref, offloaded: block.offloaded === true }];
|
|
43
|
+
}
|
|
44
|
+
return [];
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function isScheduleNotice(message) {
|
|
48
|
+
const [first] = message.content;
|
|
49
|
+
return first?.type === 'text' && first.text.startsWith(SCHEDULE_NOTICE_PREFIX);
|
|
50
|
+
}
|
|
51
|
+
function isForwarded(message) {
|
|
52
|
+
// The agent loop and other producers extend this union; compare it as a plain string.
|
|
53
|
+
const kind = message.source?.kind;
|
|
54
|
+
if (message.role !== 'user')
|
|
55
|
+
return false;
|
|
56
|
+
if (kind === 'schedule' && isScheduleNotice(message))
|
|
57
|
+
return false;
|
|
58
|
+
return kind === undefined || FORWARDED_KINDS.has(kind);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Collect the user input still owed to the CLI: messages after the last assistant or
|
|
62
|
+
* tool message, from the current turn, not sent yet. A tail ending in a tool result
|
|
63
|
+
* yields nothing, which is how a continuation step is told apart from a new prompt.
|
|
64
|
+
* @param messages - the derived request history.
|
|
65
|
+
* @param seen - ids of messages already sent to the CLI.
|
|
66
|
+
* @param currentTurn - ids of the user messages in the open turn; omitted, all qualify.
|
|
67
|
+
* @returns new message ids and their text and image blocks, in conversation order.
|
|
68
|
+
*/
|
|
69
|
+
export function unseenUserInput(messages, seen, currentTurn) {
|
|
70
|
+
const fresh = messagesAfterLastReply(messages).filter((message) => isForwarded(message) &&
|
|
71
|
+
(message.id === undefined || (!seen.has(message.id) && (currentTurn?.has(message.id) ?? true))));
|
|
72
|
+
return {
|
|
73
|
+
ids: fresh.flatMap((message) => (message.id === undefined ? [] : [message.id])),
|
|
74
|
+
blocks: fresh.flatMap(inputBlocks),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Build the replay state that ties a harness session to its CLI session.
|
|
79
|
+
* @param acpSessionId - the CLI's session id.
|
|
80
|
+
* @param dshSessionId - the harness session that owns it.
|
|
81
|
+
* @returns the envelope carried by `finish` and stored on the assistant message.
|
|
82
|
+
*/
|
|
83
|
+
export function nativeReplayState(acpSessionId, dshSessionId) {
|
|
84
|
+
return { response: { acpSessionId, dshSessionId } };
|
|
85
|
+
}
|
|
86
|
+
function parseReplayResponse(replayState) {
|
|
87
|
+
const response = replayState?.response;
|
|
88
|
+
if (typeof response !== 'object' || response === null)
|
|
89
|
+
return undefined;
|
|
90
|
+
const { acpSessionId, dshSessionId } = response;
|
|
91
|
+
return typeof acpSessionId === 'string' && typeof dshSessionId === 'string'
|
|
92
|
+
? { acpSessionId, dshSessionId }
|
|
93
|
+
: undefined;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Find the CLI session to reattach to, from the latest assistant message.
|
|
97
|
+
* The owner check keeps a fork, which inherits its parent's messages under a new
|
|
98
|
+
* id, from writing into the parent's CLI session. The provider check is needed because
|
|
99
|
+
* all CLIs share one adapter, so the harness does not strip another route's replay state,
|
|
100
|
+
* and a session id means nothing to any other CLI.
|
|
101
|
+
* @param messages - the derived request history.
|
|
102
|
+
* @param dshSessionId - the harness session asking.
|
|
103
|
+
* @param provider - the CLI route asking.
|
|
104
|
+
* @returns the CLI session id, or undefined when there is none to reuse.
|
|
105
|
+
*/
|
|
106
|
+
export function readNativeSession(messages, dshSessionId, provider) {
|
|
107
|
+
const latest = messages.findLast((message) => message.role === 'assistant');
|
|
108
|
+
const source = latest?.source;
|
|
109
|
+
if (source?.provider !== provider)
|
|
110
|
+
return undefined;
|
|
111
|
+
const replay = parseReplayResponse(source.replayState);
|
|
112
|
+
return replay?.dshSessionId === dshSessionId ? replay.acpSessionId : undefined;
|
|
113
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opening the ACP connection to a CLI and getting a session on it: a new one, or the
|
|
3
|
+
* native session an earlier process left behind.
|
|
4
|
+
* @module dsh-ccgui/session/connect
|
|
5
|
+
*/
|
|
6
|
+
import type { AcpTransport, TransportHandlers } from '../transport.ts';
|
|
7
|
+
import type { ModelState } from './model-state.ts';
|
|
8
|
+
/** What opening a session needs. */
|
|
9
|
+
export interface OpenSessionRequest {
|
|
10
|
+
readonly openTransport: (handlers: TransportHandlers) => AcpTransport;
|
|
11
|
+
readonly handlers: TransportHandlers;
|
|
12
|
+
readonly cwd: string;
|
|
13
|
+
/** The CLI's own session for this conversation, when an earlier process left one. */
|
|
14
|
+
readonly nativeSessionId: string | undefined;
|
|
15
|
+
readonly model: ModelState;
|
|
16
|
+
/** Told as soon as the transport exists, so a stop during startup can still close it. */
|
|
17
|
+
readonly onTransport: (transport: AcpTransport) => void;
|
|
18
|
+
/** Receives a failed reattach, which falls back to a new session rather than failing. */
|
|
19
|
+
readonly onError?: ((error: unknown) => void) | undefined;
|
|
20
|
+
}
|
|
21
|
+
/** The session the CLI is working in. */
|
|
22
|
+
export interface OpenedSession {
|
|
23
|
+
readonly acpSessionId: string;
|
|
24
|
+
/** The native session could not be continued, so the CLI starts without the conversation. */
|
|
25
|
+
readonly reattachFailed: boolean;
|
|
26
|
+
/** The CLI takes image blocks in a prompt. */
|
|
27
|
+
readonly acceptsImages: boolean;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Connect to the CLI and open or continue its session.
|
|
31
|
+
* @param request - transport factory, handlers and what to continue.
|
|
32
|
+
* @returns the session and whether continuing it failed.
|
|
33
|
+
*/
|
|
34
|
+
export declare function openSession(request: OpenSessionRequest): Promise<OpenedSession>;
|
|
35
|
+
/**
|
|
36
|
+
* A stalled `npx` or a CLI waiting for a login would otherwise hang every later message too.
|
|
37
|
+
* @param work - the startup to bound.
|
|
38
|
+
* @param timeoutMs - how long it may take.
|
|
39
|
+
* @returns the startup, rejected with a readable reason once the time is up.
|
|
40
|
+
*/
|
|
41
|
+
export declare function withinStartTimeout(work: () => Promise<void>, timeoutMs: number): Promise<void>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opening the ACP connection to a CLI and getting a session on it: a new one, or the
|
|
3
|
+
* native session an earlier process left behind.
|
|
4
|
+
* @module dsh-ccgui/session/connect
|
|
5
|
+
*/
|
|
6
|
+
const MS_PER_SECOND = 1000;
|
|
7
|
+
/**
|
|
8
|
+
* Connect to the CLI and open or continue its session.
|
|
9
|
+
* @param request - transport factory, handlers and what to continue.
|
|
10
|
+
* @returns the session and whether continuing it failed.
|
|
11
|
+
*/
|
|
12
|
+
export async function openSession(request) {
|
|
13
|
+
const { openTransport, handlers, cwd, nativeSessionId, model, onTransport } = request;
|
|
14
|
+
const transport = openTransport(handlers);
|
|
15
|
+
onTransport(transport);
|
|
16
|
+
const { agentCapabilities } = await transport.initialize();
|
|
17
|
+
const acceptsImages = agentCapabilities?.promptCapabilities?.image === true;
|
|
18
|
+
if (nativeSessionId === undefined) {
|
|
19
|
+
const { sessionId, configOptions } = await transport.newSession(cwd);
|
|
20
|
+
model.adopt(configOptions, true);
|
|
21
|
+
return { acpSessionId: sessionId, reattachFailed: false, acceptsImages };
|
|
22
|
+
}
|
|
23
|
+
const attached = await reattach(request, transport, nativeSessionId, {
|
|
24
|
+
resume: agentCapabilities?.sessionCapabilities?.resume != null,
|
|
25
|
+
load: agentCapabilities?.loadSession === true,
|
|
26
|
+
});
|
|
27
|
+
return { ...attached, acceptsImages };
|
|
28
|
+
}
|
|
29
|
+
async function reattach({ cwd, model, onError }, transport, sessionId, can) {
|
|
30
|
+
try {
|
|
31
|
+
if (can.resume) {
|
|
32
|
+
model.adopt(await transport.resumeSession(sessionId, cwd), false);
|
|
33
|
+
return { acpSessionId: sessionId, reattachFailed: false };
|
|
34
|
+
}
|
|
35
|
+
if (can.load) {
|
|
36
|
+
// session/load replays the conversation as updates. No turn is active yet,
|
|
37
|
+
// so the update handler drops them; the harness already has that history.
|
|
38
|
+
model.adopt(await transport.loadSession(sessionId, cwd), false);
|
|
39
|
+
return { acpSessionId: sessionId, reattachFailed: false };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
onError?.(error);
|
|
44
|
+
}
|
|
45
|
+
const { sessionId: fresh, configOptions } = await transport.newSession(cwd);
|
|
46
|
+
model.adopt(configOptions, true);
|
|
47
|
+
return { acpSessionId: fresh, reattachFailed: true };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A stalled `npx` or a CLI waiting for a login would otherwise hang every later message too.
|
|
51
|
+
* @param work - the startup to bound.
|
|
52
|
+
* @param timeoutMs - how long it may take.
|
|
53
|
+
* @returns the startup, rejected with a readable reason once the time is up.
|
|
54
|
+
*/
|
|
55
|
+
export async function withinStartTimeout(work, timeoutMs) {
|
|
56
|
+
const { promise: timedOut, reject } = Promise.withResolvers();
|
|
57
|
+
const seconds = Math.ceil(timeoutMs / MS_PER_SECOND);
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
reject(new Error(`等了 ${seconds} 秒没有响应`));
|
|
60
|
+
}, timeoutMs);
|
|
61
|
+
timer.unref();
|
|
62
|
+
try {
|
|
63
|
+
await Promise.race([work(), timedOut]);
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
}
|
|
68
|
+
}
|