dsh-ccgui 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.d.ts CHANGED
@@ -50,6 +50,7 @@ export default class CliBridge extends Service {
50
50
  private readonly catalog;
51
51
  /** Ends any model probe still running when this plugin goes away. */
52
52
  private readonly probes;
53
+ private readonly loginPath;
53
54
  constructor(ctx: Context, config: Config);
54
55
  /** Read in the background: the menu works without it, and fills in once it arrives. */
55
56
  private restoreModels;
package/lib/index.js CHANGED
@@ -19,6 +19,8 @@ import { npxCacheDir, startFromNpxCache } from "./npx-cache.js";
19
19
  import { gitHandler, registerGitRoutes } from "./git/git-routes.js";
20
20
  import { CliSessionRegistry } from "./session-registry.js";
21
21
  import { openStdioTransport } from "./stdio-transport.js";
22
+ import { startOnWindows } from "./windows-command.js";
23
+ import { LoginPath, diskDirs, runShell } from "./login-path.js";
22
24
  /**
23
25
  * Shown to turns cut off when this plugin stops. Besides shutdown, the harness restarts a
24
26
  * plugin whose files changed, which takes every CLI it started down with it.
@@ -50,9 +52,20 @@ export default class CliBridge extends Service {
50
52
  catalog;
51
53
  /** Ends any model probe still running when this plugin goes away. */
52
54
  probes = new AbortController();
55
+ loginPath;
53
56
  constructor(ctx, config) {
54
57
  super(ctx, 'cliBridge');
55
58
  this.config = config;
59
+ this.loginPath = new LoginPath({
60
+ platform: process.platform,
61
+ env: process.env,
62
+ home: homedir(),
63
+ dirs: diskDirs,
64
+ run: runShell,
65
+ warn: (message) => ctx.logger.warn(message),
66
+ });
67
+ // Started first so the answer is likely in before the first CLI starts; until then the fallback holds.
68
+ void this.loginPath.load(this.probes.signal);
56
69
  this.registry = new CliSessionRegistry(config.agents, {
57
70
  getAgent: (sessionId) => ctx.agents.get(sessionId),
58
71
  composedPreset: (agent) => ctx.get('agentPresets')?.composedPreset(agent.ctx),
@@ -165,10 +178,11 @@ export default class CliBridge extends Service {
165
178
  });
166
179
  }
167
180
  openTransport(agent, cwd, handlers) {
168
- const { argv, env } = startFromNpxCache({ argv: [agent.command, ...agent.args], env: agent.env }, {
181
+ const fromCache = startFromNpxCache({ argv: [agent.command, ...agent.args], env: this.loginPath.apply(agent.env) }, {
169
182
  npxCache: npxCacheDir(process.env, homedir(), process.platform),
170
183
  path: process.env.PATH,
171
184
  });
185
+ const { argv, env } = startOnWindows(fromCache, process.platform, process.env);
172
186
  return openStdioTransport({
173
187
  argv,
174
188
  cwd,
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Gives CLIs the PATH a terminal would give them. On macOS an app started from the Dock gets
3
+ * launchd's PATH, `/usr/bin:/bin:/usr/sbin:/sbin`, and never reads the shell's rc files, so a
4
+ * node installed through nvm, fnm or Volta is out of sight. Every npx-based CLI then dies
5
+ * with exit code 127 from `/usr/bin/env node`, while the same CLI works in a terminal.
6
+ *
7
+ * The login shell is asked for its PATH once, in the background, since rc files can take
8
+ * seconds. Until it answers, or when it fails, the usual install places are appended to the
9
+ * inherited PATH, which already covers the common nvm and Homebrew setups.
10
+ * @module dsh-ccgui/login-path
11
+ */
12
+ /** What the lookup needs from the file system; separate so tests need not touch the disk. */
13
+ export interface DirProbe {
14
+ readonly isDir: (path: string) => boolean;
15
+ readonly list: (path: string) => readonly string[];
16
+ }
17
+ /** Runs a command and resolves with its stdout. */
18
+ export type ShellRunner = (argv: readonly string[], signal: AbortSignal) => Promise<string>;
19
+ export interface LoginPathOptions {
20
+ readonly platform: NodeJS.Platform;
21
+ readonly env: Readonly<Record<string, string | undefined>>;
22
+ readonly home: string;
23
+ readonly dirs: DirProbe;
24
+ readonly run: ShellRunner;
25
+ readonly warn: (message: string) => void;
26
+ }
27
+ /**
28
+ * Read the PATH out of the login shell's output.
29
+ * @param stdout - everything the shell printed.
30
+ * @returns the PATH, or undefined when the output does not hold one.
31
+ */
32
+ export declare function parseShellPath(stdout: string): string | undefined;
33
+ /**
34
+ * Join PATH values, keeping the first occurrence of each directory.
35
+ * @param paths - PATH values, in order of precedence.
36
+ * @returns one PATH value.
37
+ */
38
+ export declare function mergePaths(...paths: readonly (string | undefined)[]): string;
39
+ /**
40
+ * The usual install places for CLIs and node that exist on this machine.
41
+ * @param home - the user's home directory.
42
+ * @param dirs - the file system.
43
+ * @returns existing directories, newest nvm node first since nvm's default is usually it.
44
+ */
45
+ export declare function knownBinDirs(home: string, dirs: DirProbe): readonly string[];
46
+ /** The PATH handed to CLIs: the login shell's when known, the inherited one plus fallbacks otherwise. */
47
+ export declare class LoginPath {
48
+ private readonly options;
49
+ private login;
50
+ constructor(options: LoginPathOptions);
51
+ /**
52
+ * Ask the login shell for its PATH. Never rejects: a failure only keeps the fallback.
53
+ * @param signal - ends the shell when the plugin goes away.
54
+ */
55
+ load(signal: AbortSignal): Promise<void>;
56
+ /**
57
+ * Give a CLI's environment a full PATH, unless it is configured with one.
58
+ * @param env - the environment configured for the CLI.
59
+ * @returns the environment to start the CLI with.
60
+ */
61
+ apply(env: Readonly<Record<string, string>>): Readonly<Record<string, string>>;
62
+ }
63
+ /**
64
+ * Run a command outside the harness's subprocess service: this is a fact about the machine
65
+ * the plugin runs on, read once, like the npx cache next to it.
66
+ * @param argv - program and arguments.
67
+ * @param signal - ends the command.
68
+ * @returns the command's stdout.
69
+ */
70
+ export declare function runShell(argv: readonly string[], signal: AbortSignal): Promise<string>;
71
+ /** The real file system. */
72
+ export declare const diskDirs: DirProbe;
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Gives CLIs the PATH a terminal would give them. On macOS an app started from the Dock gets
3
+ * launchd's PATH, `/usr/bin:/bin:/usr/sbin:/sbin`, and never reads the shell's rc files, so a
4
+ * node installed through nvm, fnm or Volta is out of sight. Every npx-based CLI then dies
5
+ * with exit code 127 from `/usr/bin/env node`, while the same CLI works in a terminal.
6
+ *
7
+ * The login shell is asked for its PATH once, in the background, since rc files can take
8
+ * seconds. Until it answers, or when it fails, the usual install places are appended to the
9
+ * inherited PATH, which already covers the common nvm and Homebrew setups.
10
+ * @module dsh-ccgui/login-path
11
+ */
12
+ import { execFile } from 'node:child_process';
13
+ import { readdirSync, statSync } from 'node:fs';
14
+ import { delimiter, join } from 'node:path';
15
+ /** Brackets the value, since rc files are free to print greetings around it. */
16
+ const MARKER = '__DSH_CCGUI_PATH__';
17
+ /** `printenv` reads the exported value, so it prints the same in sh, zsh, bash and fish. */
18
+ const PRINT_PATH = `echo ${MARKER}; printenv PATH; echo ${MARKER}`;
19
+ const SHELL_TIMEOUT_MS = 5_000;
20
+ const SHELL_MAX_OUTPUT_BYTES = 1024 * 1024;
21
+ /** The shell macOS gives new accounts; elsewhere the one every system has. */
22
+ const DARWIN_SHELL = '/bin/zsh';
23
+ const POSIX_SHELL = '/bin/sh';
24
+ const SYSTEM_DIRS = ['/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin'];
25
+ const HOME_DIRS = [
26
+ '.local/bin',
27
+ '.volta/bin',
28
+ '.bun/bin',
29
+ '.cargo/bin',
30
+ '.local/share/mise/shims',
31
+ '.asdf/shims',
32
+ 'Library/pnpm',
33
+ '.local/share/pnpm',
34
+ ];
35
+ const NVM_VERSIONS = '.nvm/versions/node';
36
+ /**
37
+ * Read the PATH out of the login shell's output.
38
+ * @param stdout - everything the shell printed.
39
+ * @returns the PATH, or undefined when the output does not hold one.
40
+ */
41
+ export function parseShellPath(stdout) {
42
+ const parts = stdout.split(MARKER);
43
+ // The last pair is ours; an rc file that echoes commands adds an earlier one.
44
+ const value = parts.length < 3 ? undefined : parts[parts.length - 2]?.trim();
45
+ return value === undefined || value === '' ? undefined : value;
46
+ }
47
+ /**
48
+ * Join PATH values, keeping the first occurrence of each directory.
49
+ * @param paths - PATH values, in order of precedence.
50
+ * @returns one PATH value.
51
+ */
52
+ export function mergePaths(...paths) {
53
+ const entries = paths.flatMap((path) => (path ?? '').split(delimiter)).filter((dir) => dir !== '');
54
+ return [...new Set(entries)].join(delimiter);
55
+ }
56
+ function versionParts(version) {
57
+ return (version.match(/\d+/g) ?? []).map(Number);
58
+ }
59
+ function newestFirst(a, b) {
60
+ const [left, right] = [versionParts(a), versionParts(b)];
61
+ const index = left.findIndex((part, i) => part !== right[i]);
62
+ return index === -1 ? right.length - left.length : (right[index] ?? 0) - (left[index] ?? 0);
63
+ }
64
+ /**
65
+ * The usual install places for CLIs and node that exist on this machine.
66
+ * @param home - the user's home directory.
67
+ * @param dirs - the file system.
68
+ * @returns existing directories, newest nvm node first since nvm's default is usually it.
69
+ */
70
+ export function knownBinDirs(home, dirs) {
71
+ const nvm = join(home, NVM_VERSIONS);
72
+ const nvmBins = [...dirs.list(nvm)].sort(newestFirst).map((version) => join(nvm, version, 'bin'));
73
+ return [...SYSTEM_DIRS, ...HOME_DIRS.map((dir) => join(home, dir)), ...nvmBins].filter((dir) => dirs.isDir(dir));
74
+ }
75
+ /** The PATH handed to CLIs: the login shell's when known, the inherited one plus fallbacks otherwise. */
76
+ export class LoginPath {
77
+ options;
78
+ login;
79
+ constructor(options) {
80
+ this.options = options;
81
+ }
82
+ /**
83
+ * Ask the login shell for its PATH. Never rejects: a failure only keeps the fallback.
84
+ * @param signal - ends the shell when the plugin goes away.
85
+ */
86
+ async load(signal) {
87
+ const { platform, env, run, warn } = this.options;
88
+ if (platform === 'win32')
89
+ return;
90
+ const shell = env.SHELL ?? (platform === 'darwin' ? DARWIN_SHELL : POSIX_SHELL);
91
+ try {
92
+ this.login = parseShellPath(await run([shell, '-ilc', PRINT_PATH], signal));
93
+ }
94
+ catch (error) {
95
+ if (!signal.aborted)
96
+ warn(`ccgui: could not read the login shell's PATH: ${String(error)}`);
97
+ }
98
+ }
99
+ /**
100
+ * Give a CLI's environment a full PATH, unless it is configured with one.
101
+ * @param env - the environment configured for the CLI.
102
+ * @returns the environment to start the CLI with.
103
+ */
104
+ apply(env) {
105
+ const { platform, env: inherited, home, dirs } = this.options;
106
+ if (platform === 'win32' || env.PATH !== undefined)
107
+ return env;
108
+ return { ...env, PATH: mergePaths(this.login, inherited.PATH, ...knownBinDirs(home, dirs)) };
109
+ }
110
+ }
111
+ /**
112
+ * Run a command outside the harness's subprocess service: this is a fact about the machine
113
+ * the plugin runs on, read once, like the npx cache next to it.
114
+ * @param argv - program and arguments.
115
+ * @param signal - ends the command.
116
+ * @returns the command's stdout.
117
+ */
118
+ export function runShell(argv, signal) {
119
+ const [program, ...args] = argv;
120
+ return new Promise((resolve, reject) => {
121
+ const child = execFile(program ?? POSIX_SHELL, args, { signal, timeout: SHELL_TIMEOUT_MS, maxBuffer: SHELL_MAX_OUTPUT_BYTES, encoding: 'utf8' }, (error, stdout) => {
122
+ if (error === null)
123
+ resolve(stdout);
124
+ else
125
+ reject(new Error(`${program ?? POSIX_SHELL} failed: ${error.message}`, { cause: error }));
126
+ });
127
+ // An interactive shell must not sit waiting for input nobody will type.
128
+ child.stdin?.end();
129
+ });
130
+ }
131
+ /** The real file system. */
132
+ export const diskDirs = {
133
+ isDir: (path) => statSync(path, { throwIfNoEntry: false })?.isDirectory() ?? false,
134
+ list: (path) => {
135
+ try {
136
+ return readdirSync(path);
137
+ }
138
+ catch {
139
+ // No such directory (no nvm installed) simply means nothing to add.
140
+ return [];
141
+ }
142
+ },
143
+ };
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { DEFAULT_MODEL_ID } from "./constants.js";
6
6
  const CONVENTIONAL_MODEL_OPTION_ID = 'model';
7
+ const NAME_SEPARATOR = ' · ';
7
8
  const isObject = (value) => typeof value === 'object' && value !== null;
8
9
  const isText = (value) => typeof value === 'string' && value.length > 0;
9
10
  function flattenEntries(entries) {
@@ -23,7 +24,26 @@ function readEntry(entry) {
23
24
  if (!isObject(entry) || !isText(entry.value))
24
25
  return [];
25
26
  const name = entry.value === DEFAULT_MODEL_ID && isText(entry.description) ? entry.description : entry.name;
26
- return [{ value: entry.value, name: isText(name) ? name : entry.value }];
27
+ const description = isText(entry.description) ? entry.description : undefined;
28
+ return [{ value: entry.value, name: isText(name) ? name : entry.value, description }];
29
+ }
30
+ /**
31
+ * A CLI can list several models under one name, CodeBuddy's free and paid tiers of Hy4 among
32
+ * them, which would leave rows nobody can tell apart. Such names gain what does tell them apart:
33
+ * the descriptions when those differ, otherwise the ids. The default entry is left alone, as the
34
+ * catalog labels it on its own and it is expected to share its model's name.
35
+ */
36
+ function distinguishNames(entries) {
37
+ const named = entries.filter(({ value }) => value !== DEFAULT_MODEL_ID);
38
+ const peersOf = (name) => named.filter((entry) => entry.name === name);
39
+ return entries.map(({ value, name, description }) => {
40
+ const peers = peersOf(name);
41
+ if (value === DEFAULT_MODEL_ID || peers.length < 2)
42
+ return { value, name };
43
+ const descriptions = new Set(peers.map((peer) => peer.description));
44
+ const byDescription = descriptions.size === peers.length && !descriptions.has(undefined);
45
+ return { value, name: `${name}${NAME_SEPARATOR}${(byDescription ? description : undefined) ?? value}` };
46
+ });
27
47
  }
28
48
  /**
29
49
  * Find the model option. ACP marks it with a category, so that wins; the id is only
@@ -41,6 +61,6 @@ export function readModelList(configOptions) {
41
61
  selects.find((candidate) => candidate.category == null && candidate.id === CONVENTIONAL_MODEL_OPTION_ID);
42
62
  if (option === undefined || !isText(option.id) || !isText(option.currentValue))
43
63
  return undefined;
44
- const options = flattenEntries(option.options).flatMap(readEntry);
64
+ const options = distinguishNames(flattenEntries(option.options).flatMap(readEntry));
45
65
  return options.length === 0 ? undefined : { configId: option.id, current: option.currentValue, options };
46
66
  }
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { appendDelta, closeOpenBlock, EMPTY_BLOCKS } from "../stream-blocks.js";
7
7
  import { toolCallStep, turnEndStep, turnFailure } from "./outcomes.js";
8
+ import { addUsage, toTokenUsage, withUsage } from "./turn-usage.js";
8
9
  /**
9
10
  * Read until the next tool call or the end of the CLI turn.
10
11
  * @param source - the session being read.
@@ -13,11 +14,15 @@ import { toolCallStep, turnEndStep, turnFailure } from "./outcomes.js";
13
14
  */
14
15
  export async function* readStep(source, signal) {
15
16
  let blocks = EMPTY_BLOCKS;
17
+ // Steering can end several CLI turns inside one step; the step reports them all.
18
+ let usage;
16
19
  while (source.hasWork()) {
17
20
  const event = await source.nextEvent(signal);
18
21
  if (source.owesInput() && event.type === 'turn-end')
19
22
  continue;
20
- const step = applyEvent(source, blocks, event);
23
+ if (event.type === 'turn-end')
24
+ usage = addUsage(usage, toTokenUsage(event.usage));
25
+ const step = applyEvent(source, blocks, event, usage);
21
26
  blocks = step.state;
22
27
  yield* step.chunks;
23
28
  if (step.done)
@@ -25,10 +30,11 @@ export async function* readStep(source, signal) {
25
30
  }
26
31
  if (source.owesInput())
27
32
  throw source.failure();
28
- yield* closeOpenBlock(blocks).chunks;
29
- yield { type: 'finish', reason: { kind: 'stop' }, ...source.replay() };
33
+ const { state, chunks } = closeOpenBlock(blocks);
34
+ const finish = { type: 'finish', reason: { kind: 'stop' }, ...source.replay() };
35
+ yield* withUsage({ state, chunks: [...chunks, finish], done: true }, usage).chunks;
30
36
  }
31
- function applyEvent(source, blocks, event) {
37
+ function applyEvent(source, blocks, event, usage) {
32
38
  switch (event.type) {
33
39
  case 'text':
34
40
  case 'reasoning':
@@ -40,14 +46,14 @@ function applyEvent(source, blocks, event) {
40
46
  : toolCallStep(blocks, record, source.replay());
41
47
  }
42
48
  case 'turn-end':
43
- return applyTurnEnd(source, blocks, event.stopReason);
49
+ return applyTurnEnd(source, blocks, event, usage);
44
50
  case 'failure':
45
51
  throw turnFailure(event.error, source.label);
46
52
  }
47
53
  }
48
54
  /** Queued steering runs as the next CLI turn inside the same harness turn. */
49
- function applyTurnEnd(source, blocks, stopReason) {
55
+ function applyTurnEnd(source, blocks, end, usage) {
50
56
  if (source.hasWork())
51
57
  return { state: blocks, chunks: [], done: false };
52
- return turnEndStep(blocks, stopReason, source.label, source.replay());
58
+ return withUsage(turnEndStep(blocks, end.stopReason, source.label, source.replay()), usage);
53
59
  }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * What to tell the user after a CLI fails to start. The default asks them to
3
+ * install and log in, but an adapter pulled by `npx` can fail because npm left
4
+ * its cache half-installed (a skipped optional dependency, an interrupted
5
+ * download). Reinstalling globally does not help there: npx keeps reusing the
6
+ * broken cache until that directory is deleted.
7
+ * @module dsh-ccgui/session/start-hint
8
+ */
9
+ /**
10
+ * @param message - the startup error's text.
11
+ * @returns the sentence to append to the startup failure.
12
+ */
13
+ export declare function startHint(message: string): string;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * What to tell the user after a CLI fails to start. The default asks them to
3
+ * install and log in, but an adapter pulled by `npx` can fail because npm left
4
+ * its cache half-installed (a skipped optional dependency, an interrupted
5
+ * download). Reinstalling globally does not help there: npx keeps reusing the
6
+ * broken cache until that directory is deleted.
7
+ * @module dsh-ccgui/session/start-hint
8
+ */
9
+ import { fileURLToPath } from 'node:url';
10
+ const INSTALL_HINT = '请确认它已安装并完成登录。';
11
+ const REINSTALL_HINT = '它的安装不完整,请按上面的提示重新安装。';
12
+ /** Errors that mean files are missing from the install, not that the CLI itself refused. */
13
+ const INCOMPLETE_INSTALL = /Missing optional dependency|Cannot find module|ERR_MODULE_NOT_FOUND/;
14
+ /** An npx cache entry, `<npm cache>/_npx/<hash>`, as a path or a `file://` URL. */
15
+ const NPX_ENTRY = /[^\s'"()]*[\\/]_npx[\\/][0-9a-f]+/;
16
+ function npxEntry(message) {
17
+ const found = NPX_ENTRY.exec(message)?.[0];
18
+ if (found === undefined)
19
+ return undefined;
20
+ return found.startsWith('file:') ? fileURLToPath(found) : found;
21
+ }
22
+ /**
23
+ * @param message - the startup error's text.
24
+ * @returns the sentence to append to the startup failure.
25
+ */
26
+ export function startHint(message) {
27
+ if (!INCOMPLETE_INSTALL.test(message))
28
+ return INSTALL_HINT;
29
+ const entry = npxEntry(message);
30
+ if (entry === undefined)
31
+ return REINSTALL_HINT;
32
+ return `npx 缓存里的安装不完整,请删除 ${entry} 后重试,下次启动会重新下载。`;
33
+ }
@@ -4,13 +4,12 @@
4
4
  * runs as the next turn.
5
5
  * @module dsh-ccgui/session/turn-queue
6
6
  */
7
- import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientprotocol/sdk';
8
- import type { AcpTransport } from '../transport.ts';
7
+ import type { ContentBlock as AcpContentBlock } from '@agentclientprotocol/sdk';
8
+ import type { AcpTransport, PromptOutcome } from '../transport.ts';
9
9
  /** How a CLI turn ended. */
10
- export type TurnEnd = {
10
+ export type TurnEnd = (PromptOutcome & {
11
11
  readonly type: 'turn-end';
12
- readonly stopReason: StopReason;
13
- } | {
12
+ }) | {
14
13
  readonly type: 'failure';
15
14
  readonly error: Error;
16
15
  };
@@ -93,8 +93,8 @@ export class TurnQueue {
93
93
  this.running = true;
94
94
  this.ended = Promise.withResolvers();
95
95
  this.lifetime = new AbortController();
96
- target.transport.prompt(target.acpSessionId, next.blocks).then((stopReason) => {
97
- this.end({ type: 'turn-end', stopReason });
96
+ target.transport.prompt(target.acpSessionId, next.blocks).then((outcome) => {
97
+ this.end({ type: 'turn-end', ...outcome });
98
98
  }, (error) => {
99
99
  this.end({ type: 'failure', error: error instanceof Error ? error : new Error(String(error)) });
100
100
  });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Token usage a CLI reports for its turn, restated in the harness's terms so the
3
+ * conversation shows speed and cache hits for CLI sessions too.
4
+ *
5
+ * ACP's `PromptResponse.usage` and the harness's `TokenUsage` agree that input
6
+ * excludes cache reads (Claude Code reports Anthropic's split as is; Codex
7
+ * subtracts cached input before reporting), so the counts map one to one.
8
+ * @module dsh-ccgui/session/turn-usage
9
+ */
10
+ import type { Usage } from '@agentclientprotocol/sdk';
11
+ import type { TokenUsage } from '@deepseek-ai/dsh-llm';
12
+ import type { StepResult } from './outcomes.ts';
13
+ /**
14
+ * @param usage - what the CLI reported with its turn's stop reason.
15
+ * @returns the harness's usage, or undefined when the CLI reported none it can use.
16
+ */
17
+ export declare function toTokenUsage(usage: Usage | null | undefined): TokenUsage | undefined;
18
+ /**
19
+ * Combine the usage of CLI turns that end within one harness step, as queued steering does.
20
+ * The total stays only when both turns state one, because it must be exact.
21
+ * @returns the combined usage, or undefined when neither turn reported any.
22
+ */
23
+ export declare function addUsage(a: TokenUsage | undefined, b: TokenUsage | undefined): TokenUsage | undefined;
24
+ /**
25
+ * The harness expects usage right before the terminal finish and nothing after it.
26
+ * @param step - a step whose last chunk is its finish.
27
+ * @param usage - the usage to report, if any.
28
+ * @returns the step with its usage in place.
29
+ */
30
+ export declare function withUsage(step: StepResult, usage: TokenUsage | undefined): StepResult;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Token usage a CLI reports for its turn, restated in the harness's terms so the
3
+ * conversation shows speed and cache hits for CLI sessions too.
4
+ *
5
+ * ACP's `PromptResponse.usage` and the harness's `TokenUsage` agree that input
6
+ * excludes cache reads (Claude Code reports Anthropic's split as is; Codex
7
+ * subtracts cached input before reporting), so the counts map one to one.
8
+ * @module dsh-ccgui/session/turn-usage
9
+ */
10
+ /** Counts that are summed across turns whenever either turn reports them. */
11
+ const SUMMED_COUNTS = ['cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens'];
12
+ function isCount(value) {
13
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0;
14
+ }
15
+ /** Omitting a count, rather than setting it to undefined, keeps "not reported" distinct from zero. */
16
+ function optionalCounts(counts) {
17
+ return Object.fromEntries(Object.entries(counts).filter(([, value]) => isCount(value)));
18
+ }
19
+ /**
20
+ * @param usage - what the CLI reported with its turn's stop reason.
21
+ * @returns the harness's usage, or undefined when the CLI reported none it can use.
22
+ */
23
+ export function toTokenUsage(usage) {
24
+ if (usage === null || usage === undefined)
25
+ return undefined;
26
+ const { inputTokens, outputTokens } = usage;
27
+ if (!isCount(inputTokens) || !isCount(outputTokens))
28
+ return undefined;
29
+ return {
30
+ inputTokens,
31
+ outputTokens,
32
+ ...optionalCounts({
33
+ totalTokens: usage.totalTokens,
34
+ cacheReadTokens: usage.cachedReadTokens,
35
+ cacheWriteTokens: usage.cachedWriteTokens,
36
+ reasoningTokens: usage.thoughtTokens,
37
+ }),
38
+ };
39
+ }
40
+ /**
41
+ * Combine the usage of CLI turns that end within one harness step, as queued steering does.
42
+ * The total stays only when both turns state one, because it must be exact.
43
+ * @returns the combined usage, or undefined when neither turn reported any.
44
+ */
45
+ export function addUsage(a, b) {
46
+ if (a === undefined)
47
+ return b;
48
+ if (b === undefined)
49
+ return a;
50
+ const summed = SUMMED_COUNTS.flatMap((key) => a[key] === undefined && b[key] === undefined ? [] : [[key, (a[key] ?? 0) + (b[key] ?? 0)]]);
51
+ return {
52
+ inputTokens: a.inputTokens + b.inputTokens,
53
+ outputTokens: a.outputTokens + b.outputTokens,
54
+ ...(a.totalTokens === undefined || b.totalTokens === undefined
55
+ ? {}
56
+ : { totalTokens: a.totalTokens + b.totalTokens }),
57
+ ...Object.fromEntries(summed),
58
+ };
59
+ }
60
+ /**
61
+ * The harness expects usage right before the terminal finish and nothing after it.
62
+ * @param step - a step whose last chunk is its finish.
63
+ * @param usage - the usage to report, if any.
64
+ * @returns the step with its usage in place.
65
+ */
66
+ export function withUsage(step, usage) {
67
+ if (usage === undefined)
68
+ return step;
69
+ return {
70
+ ...step,
71
+ chunks: [...step.chunks.slice(0, -1), { type: 'usage', usage }, ...step.chunks.slice(-1)],
72
+ };
73
+ }
@@ -124,8 +124,8 @@ function sessionOperations(agent, untilClosed) {
124
124
  await untilClosed(agent.request(session.close, { sessionId }));
125
125
  },
126
126
  prompt: async (sessionId, blocks) => {
127
- const { stopReason } = await untilClosed(agent.request(session.prompt, { sessionId, prompt: [...blocks] }));
128
- return stopReason;
127
+ const { stopReason, usage } = await untilClosed(agent.request(session.prompt, { sessionId, prompt: [...blocks] }));
128
+ return { stopReason, usage };
129
129
  },
130
130
  cancel: (sessionId) => {
131
131
  // Best effort: if the process is already gone, `closed` has reported it.
@@ -2,7 +2,7 @@
2
2
  * The ACP operations a CLI session needs, separated from process and SDK details.
3
3
  * @module dsh-ccgui/transport
4
4
  */
5
- import type { ContentBlock, InitializeResponse, RequestPermissionRequest, RequestPermissionResponse, SessionConfigOption, SessionUpdate, StopReason } from '@agentclientprotocol/sdk';
5
+ import type { ContentBlock, InitializeResponse, RequestPermissionRequest, RequestPermissionResponse, SessionConfigOption, SessionUpdate, StopReason, Usage } from '@agentclientprotocol/sdk';
6
6
  /** Callbacks from the CLI to its session. */
7
7
  export interface TransportHandlers {
8
8
  /** One `session/update` notification. */
@@ -17,6 +17,12 @@ export interface SessionInfo {
17
17
  readonly sessionId: string;
18
18
  readonly configOptions?: readonly SessionConfigOption[] | null | undefined;
19
19
  }
20
+ /** How a CLI turn ended, as the CLI reported it. */
21
+ export interface PromptOutcome {
22
+ readonly stopReason: StopReason;
23
+ /** Token usage for the turn; many CLIs do not report it. */
24
+ readonly usage?: Usage | null | undefined;
25
+ }
20
26
  /** Client side of one ACP connection. */
21
27
  export interface AcpTransport {
22
28
  initialize(): Promise<InitializeResponse>;
@@ -28,7 +34,7 @@ export interface AcpTransport {
28
34
  deleteSession(sessionId: string): Promise<void>;
29
35
  closeSession(sessionId: string): Promise<void>;
30
36
  /** Resolves when the whole CLI turn ends, however many tools it ran. */
31
- prompt(sessionId: string, blocks: readonly ContentBlock[]): Promise<StopReason>;
37
+ prompt(sessionId: string, blocks: readonly ContentBlock[]): Promise<PromptOutcome>;
32
38
  cancel(sessionId: string): void;
33
39
  /** Stops the CLI. `force` skips the wait for it to exit by itself. */
34
40
  dispose(force?: boolean): Promise<void>;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Turns a configured command into one Windows can start without a shell. The harness spawns
3
+ * with CreateProcess and never through `cmd.exe`, which finds only `.exe` and `.com` files by
4
+ * bare name and, since Node's fix for CVE-2024-27980, refuses `.cmd` and `.bat` outright. Yet
5
+ * `npx`, `codebuddy`, `opencode` and most CLIs installed through a package manager are `.cmd`
6
+ * shims on Windows.
7
+ *
8
+ * Wrapping the shim in `cmd.exe /c` is not an option: the spawn seam has no verbatim-argument
9
+ * switch, so Node's quoting of the command line would break cmd's own parsing. Instead, the
10
+ * shim is read for the script it starts, and that script is run with the shim's interpreter
11
+ * directly, the way the shim would have run it. The interpreter is often not node: npm writes
12
+ * `bun` into the shim of a package whose bin has a bun shebang, such as OMP CLI.
13
+ * @module dsh-ccgui/windows-command
14
+ */
15
+ import type { CliCommand } from './npx-cache.ts';
16
+ /** What the lookup needs from the file system; separate so tests need not run on Windows. */
17
+ export interface FileProbe {
18
+ readonly isFile: (path: string) => boolean;
19
+ readonly read: (path: string) => string | undefined;
20
+ }
21
+ /** Where to look. */
22
+ export interface WindowsLookup {
23
+ readonly path: string | undefined;
24
+ readonly pathExt: string | undefined;
25
+ readonly files: FileProbe;
26
+ }
27
+ /**
28
+ * Resolve a command the way a Windows shell would, without the shell.
29
+ * @param command - the command as configured.
30
+ * @param lookup - where to look.
31
+ * @returns the command with a program CreateProcess can start; unchanged when the program is
32
+ * not found, so the spawn reports it as missing.
33
+ * @throws Error when the program is a batch file that only a shell could run.
34
+ */
35
+ export declare function resolveWindowsCommand(command: CliCommand, lookup: WindowsLookup): CliCommand;
36
+ /**
37
+ * On Windows, resolve the command against the real file system; elsewhere, leave it alone.
38
+ * @param command - the command as configured.
39
+ * @param platform - the current platform.
40
+ * @param env - the process environment, for PATH and PATHEXT.
41
+ * @returns the command to spawn.
42
+ */
43
+ export declare function startOnWindows(command: CliCommand, platform: NodeJS.Platform, env: Readonly<Record<string, string | undefined>>): CliCommand;