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.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +327 -2
  3. package/THIRD_PARTY_NOTICES.md +64 -0
  4. package/assets/logo.png +0 -0
  5. package/cordis.patch.yml +32 -0
  6. package/lib/abortable.d.ts +12 -0
  7. package/lib/abortable.js +27 -0
  8. package/lib/adapter.d.ts +39 -0
  9. package/lib/adapter.js +64 -0
  10. package/lib/async-queue.d.ts +26 -0
  11. package/lib/async-queue.js +55 -0
  12. package/lib/cli-session.d.ts +99 -0
  13. package/lib/cli-session.js +384 -0
  14. package/lib/cli-tool.d.ts +18 -0
  15. package/lib/cli-tool.js +101 -0
  16. package/lib/client.js +4039 -0
  17. package/lib/constants.d.ts +22 -0
  18. package/lib/constants.js +22 -0
  19. package/lib/git/git-read.d.ts +65 -0
  20. package/lib/git/git-read.js +139 -0
  21. package/lib/git/git-requests.d.ts +44 -0
  22. package/lib/git/git-requests.js +153 -0
  23. package/lib/git/git-routes.d.ts +38 -0
  24. package/lib/git/git-routes.js +78 -0
  25. package/lib/git/git-run.d.ts +58 -0
  26. package/lib/git/git-run.js +97 -0
  27. package/lib/git/git-status.d.ts +51 -0
  28. package/lib/git/git-status.js +122 -0
  29. package/lib/git/git-write.d.ts +75 -0
  30. package/lib/git/git-write.js +134 -0
  31. package/lib/index.d.ts +78 -0
  32. package/lib/index.js +229 -0
  33. package/lib/model-cache.d.ts +33 -0
  34. package/lib/model-cache.js +101 -0
  35. package/lib/model-catalog.d.ts +63 -0
  36. package/lib/model-catalog.js +107 -0
  37. package/lib/model-options.d.ts +26 -0
  38. package/lib/model-options.js +46 -0
  39. package/lib/model-probe.d.ts +16 -0
  40. package/lib/model-probe.js +91 -0
  41. package/lib/npx-cache.d.ts +39 -0
  42. package/lib/npx-cache.js +97 -0
  43. package/lib/permission.d.ts +17 -0
  44. package/lib/permission.js +34 -0
  45. package/lib/prompt-images.d.ts +44 -0
  46. package/lib/prompt-images.js +46 -0
  47. package/lib/request-input.d.ts +42 -0
  48. package/lib/request-input.js +113 -0
  49. package/lib/session/connect.d.ts +41 -0
  50. package/lib/session/connect.js +68 -0
  51. package/lib/session/drive-tool.d.ts +39 -0
  52. package/lib/session/drive-tool.js +37 -0
  53. package/lib/session/model-state.d.ts +40 -0
  54. package/lib/session/model-state.js +68 -0
  55. package/lib/session/outcomes.d.ts +31 -0
  56. package/lib/session/outcomes.js +67 -0
  57. package/lib/session/permission-queue.d.ts +24 -0
  58. package/lib/session/permission-queue.js +33 -0
  59. package/lib/session/read-step.d.ts +31 -0
  60. package/lib/session/read-step.js +53 -0
  61. package/lib/session/tool-mirror.d.ts +65 -0
  62. package/lib/session/tool-mirror.js +131 -0
  63. package/lib/session/turn-queue.d.ts +76 -0
  64. package/lib/session/turn-queue.js +102 -0
  65. package/lib/session/types.d.ts +58 -0
  66. package/lib/session/types.js +6 -0
  67. package/lib/session-registry.d.ts +124 -0
  68. package/lib/session-registry.js +189 -0
  69. package/lib/stdio-transport.d.ts +28 -0
  70. package/lib/stdio-transport.js +164 -0
  71. package/lib/stream-blocks.d.ts +52 -0
  72. package/lib/stream-blocks.js +95 -0
  73. package/lib/tool-record.d.ts +100 -0
  74. package/lib/tool-record.js +167 -0
  75. package/lib/transport.d.ts +35 -0
  76. package/lib/transport.js +5 -0
  77. package/package.json +136 -4
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The body of the mirror tool: wait for the CLI's result of one call, answering the
3
+ * CLI's permission requests along the way.
4
+ * @module dsh-ccgui/session/drive-tool
5
+ */
6
+ import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval';
7
+ import type { ToolOutcome } from '../tool-record.ts';
8
+ import type { PermissionQueue } from './permission-queue.ts';
9
+ import type { ToolMirror } from './tool-mirror.ts';
10
+ /** A question for the harness about a blocked CLI call. */
11
+ export interface PermissionAsk {
12
+ /** What the person deciding needs to know. */
13
+ readonly reason: string;
14
+ /** The call's id in the harness log; absent when the call has no card to attach to yet. */
15
+ readonly callId?: string;
16
+ }
17
+ /** Asks the harness whether a blocked CLI call may proceed. */
18
+ export type AskPermission = (request: PermissionAsk, signal: AbortSignal) => Promise<ApprovalOutcome>;
19
+ /** The session state a mirror works against. */
20
+ export interface DriveContext {
21
+ readonly tools: ToolMirror;
22
+ readonly permissions: PermissionQueue;
23
+ /** CLI display name used in user-facing messages. */
24
+ readonly label: string;
25
+ /** Aborted when the current CLI turn ends, which withdraws any question still on screen. */
26
+ readonly turnSignal: () => AbortSignal;
27
+ }
28
+ /** One mirrored call. */
29
+ export interface DrivenCall {
30
+ readonly acpId: string;
31
+ readonly callId: string;
32
+ }
33
+ /**
34
+ * Wait until the call finishes. Every open permission request is answered, not only the
35
+ * call's own: a parent call can be waiting on a child whose mirror cannot start until
36
+ * this one returns.
37
+ * @returns the finished call's outcome.
38
+ */
39
+ export declare function driveTool(context: DriveContext, { acpId, callId }: DrivenCall, ask: AskPermission, signal: AbortSignal): Promise<ToolOutcome>;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The body of the mirror tool: wait for the CLI's result of one call, answering the
3
+ * CLI's permission requests along the way.
4
+ * @module dsh-ccgui/session/drive-tool
5
+ */
6
+ import { abortable } from "../abortable.js";
7
+ import { permissionResponse } from "../permission.js";
8
+ import { isFinished, permissionReason, toolOutcome } from "../tool-record.js";
9
+ /**
10
+ * Wait until the call finishes. Every open permission request is answered, not only the
11
+ * call's own: a parent call can be waiting on a child whose mirror cannot start until
12
+ * this one returns.
13
+ * @returns the finished call's outcome.
14
+ */
15
+ export async function driveTool(context, { acpId, callId }, ask, signal) {
16
+ for (;;) {
17
+ signal.throwIfAborted();
18
+ const permission = context.permissions.takeNext();
19
+ if (permission !== undefined) {
20
+ const ownCallId = permission.acpId === acpId ? callId : undefined;
21
+ await answerPermission(context, permission, { callId: ownCallId, ask, signal });
22
+ continue;
23
+ }
24
+ const record = context.tools.get(acpId);
25
+ if (isFinished(record))
26
+ return toolOutcome(record);
27
+ await abortable(context.tools.nextChange(), signal);
28
+ }
29
+ }
30
+ async function answerPermission({ tools, label, turnSignal }, permission, { callId, ask, signal }) {
31
+ const record = tools.get(permission.acpId);
32
+ const reason = record === undefined ? `${label} 请求执行一个操作。` : permissionReason(record);
33
+ const askSignal = AbortSignal.any([signal, turnSignal()]);
34
+ // A failing approval service must not leave the CLI blocked; `unavailable` rejects.
35
+ const outcome = await ask({ reason, ...(callId === undefined ? {} : { callId }) }, askSignal).catch(() => 'unavailable');
36
+ permission.resolve(permissionResponse(outcome, permission.options));
37
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * What is known about a CLI session's model, and switching it to the harness's choice.
3
+ * @module dsh-ccgui/session/model-state
4
+ */
5
+ import { readModelList } from '../model-options.ts';
6
+ import type { CliModelList } from '../model-options.ts';
7
+ import type { AcpTransport } from '../transport.ts';
8
+ type ConfigOptions = Parameters<typeof readModelList>[0];
9
+ /** Where a model switch is sent. */
10
+ export interface ModelTarget {
11
+ readonly transport: AcpTransport;
12
+ readonly acpSessionId: string;
13
+ /** CLI display name used in user-facing messages. */
14
+ readonly label: string;
15
+ }
16
+ /** The model side of one CLI session. */
17
+ export declare class ModelState {
18
+ private readonly knownModels?;
19
+ private optionId;
20
+ /** The model a new session started on, which the `default` selection stands for. */
21
+ private cliDefault;
22
+ /** The CLI's model as far as is known; unset while a change is unconfirmed. */
23
+ private active;
24
+ constructor(knownModels?: (() => CliModelList | undefined) | undefined);
25
+ /**
26
+ * Only a new session says what `default` stands for: a resumed one reports the model it
27
+ * was left on, which may be one the user chose.
28
+ */
29
+ adopt(options: ConfigOptions, isNewSession: boolean): void;
30
+ /** The CLI reported a change of its own. */
31
+ observe(options: ConfigOptions): void;
32
+ /**
33
+ * Switch the CLI to the harness's selection, unless it is already there.
34
+ * @param model - the selected model; omitted or `default` means the CLI's own.
35
+ * @param where - the session to switch.
36
+ * @param signal - cancellation for the wait.
37
+ */
38
+ apply(model: string | undefined, where: ModelTarget, signal: AbortSignal | undefined): Promise<void>;
39
+ }
40
+ export {};
@@ -0,0 +1,68 @@
1
+ /**
2
+ * What is known about a CLI session's model, and switching it to the harness's choice.
3
+ * @module dsh-ccgui/session/model-state
4
+ */
5
+ import { LlmError } from '@deepseek-ai/dsh-llm';
6
+ import { abortable } from "../abortable.js";
7
+ import { DEFAULT_MODEL_ID } from "../constants.js";
8
+ import { readModelList } from "../model-options.js";
9
+ import { errorMessage } from "./outcomes.js";
10
+ /** Used when a CLI reports no model option, as some do when resuming. */
11
+ const CONVENTIONAL_MODEL_OPTION_ID = 'model';
12
+ /** The model side of one CLI session. */
13
+ export class ModelState {
14
+ knownModels;
15
+ optionId;
16
+ /** The model a new session started on, which the `default` selection stands for. */
17
+ cliDefault;
18
+ /** The CLI's model as far as is known; unset while a change is unconfirmed. */
19
+ active;
20
+ constructor(knownModels) {
21
+ this.knownModels = knownModels;
22
+ }
23
+ /**
24
+ * Only a new session says what `default` stands for: a resumed one reports the model it
25
+ * was left on, which may be one the user chose.
26
+ */
27
+ adopt(options, isNewSession) {
28
+ const list = readModelList(options);
29
+ this.optionId = list?.configId;
30
+ this.active = list?.current;
31
+ if (isNewSession)
32
+ this.cliDefault = list?.current;
33
+ }
34
+ /** The CLI reported a change of its own. */
35
+ observe(options) {
36
+ this.active = readModelList(options)?.current ?? this.active;
37
+ }
38
+ /**
39
+ * Switch the CLI to the harness's selection, unless it is already there.
40
+ * @param model - the selected model; omitted or `default` means the CLI's own.
41
+ * @param where - the session to switch.
42
+ * @param signal - cancellation for the wait.
43
+ */
44
+ async apply(model, where, signal) {
45
+ const known = this.knownModels?.();
46
+ const target = model === undefined || model === DEFAULT_MODEL_ID ? (this.cliDefault ?? known?.current) : model;
47
+ if (target === undefined || target === this.active)
48
+ return;
49
+ const optionId = this.optionId ?? known?.configId ?? CONVENTIONAL_MODEL_OPTION_ID;
50
+ // The CLI may still carry out a request this side stops waiting for, so from here
51
+ // its model is unknown until confirmed. The next step will then always set it.
52
+ this.active = undefined;
53
+ try {
54
+ const options = await abortable(where.transport.setConfigOption(where.acpSessionId, optionId, target), signal);
55
+ const confirmed = readModelList(options)?.current;
56
+ if (confirmed !== undefined && confirmed !== target)
57
+ throw new Error(`CLI 仍在使用 ${confirmed}`);
58
+ this.active = target;
59
+ }
60
+ catch (error) {
61
+ if (signal?.aborted === true)
62
+ throw error;
63
+ throw new LlmError(`${where.label} 无法切换到模型 ${target}:${errorMessage(error)}`, 'CLI_MODEL_FAILED', {
64
+ cause: error,
65
+ });
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * How a CLI turn's ending reads to the harness, and the chunks that close a harness step.
3
+ * @module dsh-ccgui/session/outcomes
4
+ */
5
+ import type { StopReason } from '@agentclientprotocol/sdk';
6
+ import { LlmError } from '@deepseek-ai/dsh-llm';
7
+ import type { StreamChunk } from '@deepseek-ai/dsh-llm';
8
+ import type { BlockState, BlockTransition } from '../stream-blocks.ts';
9
+ import type { ToolRecord } from '../tool-record.ts';
10
+ /** Extra fields every `finish` chunk carries so the harness can resume the native session. */
11
+ export type ReplayFields = Partial<Pick<Extract<StreamChunk, {
12
+ type: 'finish';
13
+ }>, 'replayState'>>;
14
+ /** One step of reading, and whether it ends the harness step. */
15
+ export interface StepResult extends BlockTransition {
16
+ readonly done: boolean;
17
+ }
18
+ /** @returns the error's message, or its string form for anything thrown that is not an Error. */
19
+ export declare function errorMessage(error: unknown): string;
20
+ /**
21
+ * End a harness step at a tool call the harness should run as the mirror tool.
22
+ * @returns the tool-call chunks followed by a `tool-calls` finish.
23
+ */
24
+ export declare function toolCallStep(blocks: BlockState, record: ToolRecord, replay: ReplayFields): StepResult;
25
+ /**
26
+ * End a harness step at the end of the CLI turn.
27
+ * @returns the closing chunks followed by the turn's finish.
28
+ */
29
+ export declare function turnEndStep(blocks: BlockState, stopReason: StopReason, label: string, replay: ReplayFields): StepResult;
30
+ /** @returns the turn's failure as the error the harness reports. */
31
+ export declare function turnFailure(error: Error, label: string): LlmError;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * How a CLI turn's ending reads to the harness, and the chunks that close a harness step.
3
+ * @module dsh-ccgui/session/outcomes
4
+ */
5
+ import { LlmError } from '@deepseek-ai/dsh-llm';
6
+ import { CLI_TOOL_NAME } from "../constants.js";
7
+ import { closeOpenBlock, toolCallChunks } from "../stream-blocks.js";
8
+ import { toCliToolArgs } from "../tool-record.js";
9
+ /** @returns the error's message, or its string form for anything thrown that is not an Error. */
10
+ export function errorMessage(error) {
11
+ return error instanceof Error ? error.message : String(error);
12
+ }
13
+ function failureFinish(message, code) {
14
+ return { kind: 'error', failure: { message, code } };
15
+ }
16
+ /**
17
+ * Map an ACP stop reason onto the harness's finish reason.
18
+ * @param stopReason - why the CLI ended its turn.
19
+ * @param label - CLI display name used in user-facing messages.
20
+ * @returns the finish reason the harness should record.
21
+ */
22
+ function finishReason(stopReason, label) {
23
+ switch (stopReason) {
24
+ case 'end_turn':
25
+ return { kind: 'stop' };
26
+ case 'max_tokens':
27
+ return { kind: 'max-tokens' };
28
+ case 'cancelled':
29
+ return { kind: 'aborted', failure: { message: `${label} 已取消本轮。`, code: 'ABORTED' } };
30
+ case 'refusal':
31
+ return failureFinish(`${label} 拒绝继续这个请求。`, 'CLI_REFUSAL');
32
+ case 'max_turn_requests':
33
+ return failureFinish(`${label} 达到了单轮请求次数上限。`, 'CLI_TURN_LIMIT');
34
+ default:
35
+ return failureFinish(`${label} 以未知原因结束:${String(stopReason)}`, 'CLI_UNKNOWN_STOP');
36
+ }
37
+ }
38
+ /**
39
+ * End a harness step at a tool call the harness should run as the mirror tool.
40
+ * @returns the tool-call chunks followed by a `tool-calls` finish.
41
+ */
42
+ export function toolCallStep(blocks, record, replay) {
43
+ const { state, chunks } = toolCallChunks(blocks, {
44
+ id: record.callId,
45
+ name: CLI_TOOL_NAME,
46
+ arguments: JSON.stringify(toCliToolArgs(record)),
47
+ });
48
+ return { state, chunks: [...chunks, { type: 'finish', reason: { kind: 'tool-calls' }, ...replay }], done: true };
49
+ }
50
+ /**
51
+ * End a harness step at the end of the CLI turn.
52
+ * @returns the closing chunks followed by the turn's finish.
53
+ */
54
+ export function turnEndStep(blocks, stopReason, label, replay) {
55
+ const { state, chunks } = closeOpenBlock(blocks);
56
+ return {
57
+ state,
58
+ chunks: [...chunks, { type: 'finish', reason: finishReason(stopReason, label), ...replay }],
59
+ done: true,
60
+ };
61
+ }
62
+ /** @returns the turn's failure as the error the harness reports. */
63
+ export function turnFailure(error, label) {
64
+ return error instanceof LlmError
65
+ ? error
66
+ : new LlmError(`${label} 执行失败:${error.message}`, 'CLI_PROMPT_FAILED', { cause: error });
67
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Permission requests from the CLI that are still waiting for an answer.
3
+ * @module dsh-ccgui/session/permission-queue
4
+ */
5
+ import type { PermissionOption, RequestPermissionResponse } from '@agentclientprotocol/sdk';
6
+ import { CANCELLED } from '../permission.ts';
7
+ export { CANCELLED };
8
+ /** One CLI request blocked on a decision. */
9
+ export interface PendingPermission {
10
+ readonly acpId: string;
11
+ readonly options: readonly PermissionOption[];
12
+ readonly resolve: (response: RequestPermissionResponse) => void;
13
+ }
14
+ /** Open permission requests, answered in the order the CLI asked. */
15
+ export declare class PermissionQueue {
16
+ private open;
17
+ add(permission: PendingPermission): void;
18
+ /** @returns the oldest open request, removed from the queue. */
19
+ takeNext(): PendingPermission | undefined;
20
+ /** Answer every open request as cancelled. */
21
+ cancelAll(): void;
22
+ /** Refuse the open requests of one call, whose mirror will never ask the harness. */
23
+ rejectFor(acpId: string): void;
24
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Permission requests from the CLI that are still waiting for an answer.
3
+ * @module dsh-ccgui/session/permission-queue
4
+ */
5
+ import { CANCELLED, permissionResponse } from "../permission.js";
6
+ export { CANCELLED };
7
+ /** Open permission requests, answered in the order the CLI asked. */
8
+ export class PermissionQueue {
9
+ open = [];
10
+ add(permission) {
11
+ this.open = [...this.open, permission];
12
+ }
13
+ /** @returns the oldest open request, removed from the queue. */
14
+ takeNext() {
15
+ const [next, ...rest] = this.open;
16
+ this.open = rest;
17
+ return next;
18
+ }
19
+ /** Answer every open request as cancelled. */
20
+ cancelAll() {
21
+ const open = this.open;
22
+ this.open = [];
23
+ for (const permission of open)
24
+ permission.resolve(CANCELLED);
25
+ }
26
+ /** Refuse the open requests of one call, whose mirror will never ask the harness. */
27
+ rejectFor(acpId) {
28
+ const own = this.open.filter((permission) => permission.acpId === acpId);
29
+ this.open = this.open.filter((permission) => permission.acpId !== acpId);
30
+ for (const permission of own)
31
+ permission.resolve(permissionResponse('rejected', permission.options));
32
+ }
33
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Reading one harness step out of a CLI turn: text and reasoning stream through, and the
3
+ * step ends at the next tool call or at the end of the turn.
4
+ * @module dsh-ccgui/session/read-step
5
+ */
6
+ import type { LlmError, StreamChunk } from '@deepseek-ai/dsh-llm';
7
+ import type { ToolRecord } from '../tool-record.ts';
8
+ import type { ReplayFields } from './outcomes.ts';
9
+ import type { SessionEvent } from './types.ts';
10
+ /** What reading a step needs from its session. */
11
+ export interface StepSource {
12
+ /** CLI display name used in user-facing messages. */
13
+ readonly label: string;
14
+ /** @returns the next event; a failed or aborted wait abandons the turn. */
15
+ readonly nextEvent: (signal: AbortSignal | undefined) => Promise<SessionEvent>;
16
+ /** Whether a turn is running or waiting, or events are still unread. */
17
+ readonly hasWork: () => boolean;
18
+ /** Whether this dead session holds user input it can no longer deliver. */
19
+ readonly owesInput: () => boolean;
20
+ readonly failure: () => LlmError | undefined;
21
+ /** @returns the call to hand to the harness, marked as handed off; undefined if it is gone. */
22
+ readonly handOff: (acpId: string) => ToolRecord | undefined;
23
+ readonly replay: () => ReplayFields;
24
+ }
25
+ /**
26
+ * Read until the next tool call or the end of the CLI turn.
27
+ * @param source - the session being read.
28
+ * @param signal - cancellation; aborting abandons the turn.
29
+ * @yields the step's stream chunks, ending with `finish`.
30
+ */
31
+ export declare function readStep(source: StepSource, signal: AbortSignal | undefined): AsyncGenerator<StreamChunk>;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Reading one harness step out of a CLI turn: text and reasoning stream through, and the
3
+ * step ends at the next tool call or at the end of the turn.
4
+ * @module dsh-ccgui/session/read-step
5
+ */
6
+ import { appendDelta, closeOpenBlock, EMPTY_BLOCKS } from "../stream-blocks.js";
7
+ import { toolCallStep, turnEndStep, turnFailure } from "./outcomes.js";
8
+ /**
9
+ * Read until the next tool call or the end of the CLI turn.
10
+ * @param source - the session being read.
11
+ * @param signal - cancellation; aborting abandons the turn.
12
+ * @yields the step's stream chunks, ending with `finish`.
13
+ */
14
+ export async function* readStep(source, signal) {
15
+ let blocks = EMPTY_BLOCKS;
16
+ while (source.hasWork()) {
17
+ const event = await source.nextEvent(signal);
18
+ if (source.owesInput() && event.type === 'turn-end')
19
+ continue;
20
+ const step = applyEvent(source, blocks, event);
21
+ blocks = step.state;
22
+ yield* step.chunks;
23
+ if (step.done)
24
+ return;
25
+ }
26
+ if (source.owesInput())
27
+ throw source.failure();
28
+ yield* closeOpenBlock(blocks).chunks;
29
+ yield { type: 'finish', reason: { kind: 'stop' }, ...source.replay() };
30
+ }
31
+ function applyEvent(source, blocks, event) {
32
+ switch (event.type) {
33
+ case 'text':
34
+ case 'reasoning':
35
+ return { ...appendDelta(blocks, event.type, event.text), done: false };
36
+ case 'tool-ready': {
37
+ const record = source.handOff(event.acpId);
38
+ return record === undefined
39
+ ? { state: blocks, chunks: [], done: false }
40
+ : toolCallStep(blocks, record, source.replay());
41
+ }
42
+ case 'turn-end':
43
+ return applyTurnEnd(source, blocks, event.stopReason);
44
+ case 'failure':
45
+ throw turnFailure(event.error, source.label);
46
+ }
47
+ }
48
+ /** Queued steering runs as the next CLI turn inside the same harness turn. */
49
+ function applyTurnEnd(source, blocks, stopReason) {
50
+ if (source.hasWork())
51
+ return { state: blocks, chunks: [], done: false };
52
+ return turnEndStep(blocks, stopReason, source.label, source.replay());
53
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The CLI's tool calls as the harness sees them: what each call has reported, which
3
+ * ones were handed to the harness, which have a mirror tool waiting on them, and which
4
+ * the harness is finished with.
5
+ * @module dsh-ccgui/session/tool-mirror
6
+ */
7
+ import type { ToolCallUpdate } from '@agentclientprotocol/sdk';
8
+ import type { ToolRecord } from '../tool-record.ts';
9
+ /** Construction inputs for a tool mirror. */
10
+ export interface ToolMirrorOptions {
11
+ readonly mintCallId: () => string;
12
+ /** How long a pending command's reports must stay quiet before it counts as started. */
13
+ readonly settleMs: number;
14
+ /** Told once per call when the harness should receive it as a tool call. */
15
+ readonly onReady: (acpId: string) => void;
16
+ }
17
+ /** Tool-call bookkeeping for one CLI session. */
18
+ export declare class ToolMirror {
19
+ private readonly options;
20
+ private tools;
21
+ /** Pending commands waiting for their reports to go quiet. */
22
+ private settling;
23
+ private callIds;
24
+ /** Calls already announced through `onReady`; each is announced once. */
25
+ private published;
26
+ /** Calls the harness received as tool calls; their mirrors may or may not run. */
27
+ private handedOff;
28
+ /** Calls whose mirror is running right now. */
29
+ private mirrors;
30
+ /** Calls the harness is finished with; their late updates are ignored. */
31
+ private claimed;
32
+ private changeWaiters;
33
+ constructor(options: ToolMirrorOptions);
34
+ get(acpId: string): ToolRecord | undefined;
35
+ acpIdFor(callId: string): string | undefined;
36
+ isClaimed(acpId: string): boolean;
37
+ /** A call the CLI reports again, or asks permission for, is live once more. */
38
+ reopen(acpId: string): void;
39
+ handOff(acpId: string): void;
40
+ startMirror(acpId: string): void;
41
+ /** Calls handed to the harness whose mirror never started: denied by a policy or skipped by a stop. */
42
+ skippedMirrors(): readonly string[];
43
+ apply(update: ToolCallUpdate, permission: boolean): void;
44
+ /**
45
+ * A turn can end with calls still open. Their mirrors would otherwise wait forever,
46
+ * so they finish as failed; an unpublished call is still published so the log shows
47
+ * what the CLI attempted.
48
+ */
49
+ failOpen(): void;
50
+ dropUnmirrored(): void;
51
+ forget(acpId: string): void;
52
+ /** @returns a promise that settles at the next change to any call. */
53
+ nextChange(): Promise<void>;
54
+ private publish;
55
+ /**
56
+ * claude-agent-acp goes from pending straight to completed, so a long command would show
57
+ * nothing until it ended. Its input arrives over several updates with nothing marking the
58
+ * end, so a command counts as started once they stop. Only commands, because a file write
59
+ * reports its path and then goes quiet while its content is generated: showing it then
60
+ * would freeze a card with no diff in it.
61
+ */
62
+ private publishOnceQuiet;
63
+ private stopSettling;
64
+ private notifyChange;
65
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The CLI's tool calls as the harness sees them: what each call has reported, which
3
+ * ones were handed to the harness, which have a mirror tool waiting on them, and which
4
+ * the harness is finished with.
5
+ * @module dsh-ccgui/session/tool-mirror
6
+ */
7
+ import { commandOf, isFinished, isReadyToPublish, mergeToolUpdate } from "../tool-record.js";
8
+ const INTERRUPTED_OUTPUT = 'CLI 在这个工具完成之前结束了本轮。';
9
+ /** Tool-call bookkeeping for one CLI session. */
10
+ export class ToolMirror {
11
+ options;
12
+ tools = new Map();
13
+ /** Pending commands waiting for their reports to go quiet. */
14
+ settling = new Map();
15
+ callIds = new Map();
16
+ /** Calls already announced through `onReady`; each is announced once. */
17
+ published = new Set();
18
+ /** Calls the harness received as tool calls; their mirrors may or may not run. */
19
+ handedOff = new Set();
20
+ /** Calls whose mirror is running right now. */
21
+ mirrors = new Set();
22
+ /** Calls the harness is finished with; their late updates are ignored. */
23
+ claimed = new Set();
24
+ changeWaiters = [];
25
+ constructor(options) {
26
+ this.options = options;
27
+ }
28
+ get(acpId) {
29
+ return this.tools.get(acpId);
30
+ }
31
+ acpIdFor(callId) {
32
+ return this.callIds.get(callId);
33
+ }
34
+ isClaimed(acpId) {
35
+ return this.claimed.has(acpId);
36
+ }
37
+ /** A call the CLI reports again, or asks permission for, is live once more. */
38
+ reopen(acpId) {
39
+ this.claimed = new Set([...this.claimed].filter((id) => id !== acpId));
40
+ }
41
+ handOff(acpId) {
42
+ this.handedOff = new Set([...this.handedOff, acpId]);
43
+ }
44
+ startMirror(acpId) {
45
+ this.mirrors = new Set([...this.mirrors, acpId]);
46
+ }
47
+ /** Calls handed to the harness whose mirror never started: denied by a policy or skipped by a stop. */
48
+ skippedMirrors() {
49
+ return [...this.handedOff].filter((acpId) => !this.mirrors.has(acpId));
50
+ }
51
+ apply(update, permission) {
52
+ const merged = mergeToolUpdate(this.tools.get(update.toolCallId), update, this.options.mintCallId);
53
+ const record = permission ? { ...merged, permissionRequested: true } : merged;
54
+ this.tools = new Map([...this.tools, [record.acpId, record]]);
55
+ this.callIds = new Map([...this.callIds, [record.callId, record.acpId]]);
56
+ if (isReadyToPublish(record))
57
+ this.publish(record.acpId);
58
+ else
59
+ this.publishOnceQuiet(record);
60
+ this.notifyChange();
61
+ }
62
+ /**
63
+ * A turn can end with calls still open. Their mirrors would otherwise wait forever,
64
+ * so they finish as failed; an unpublished call is still published so the log shows
65
+ * what the CLI attempted.
66
+ */
67
+ failOpen() {
68
+ for (const record of this.tools.values()) {
69
+ if (isFinished(record))
70
+ continue;
71
+ this.apply({ toolCallId: record.acpId, status: 'failed', rawOutput: INTERRUPTED_OUTPUT }, false);
72
+ }
73
+ }
74
+ dropUnmirrored() {
75
+ for (const acpId of this.tools.keys()) {
76
+ if (!this.mirrors.has(acpId))
77
+ this.forget(acpId);
78
+ }
79
+ }
80
+ forget(acpId) {
81
+ this.stopSettling(acpId);
82
+ const callId = this.tools.get(acpId)?.callId;
83
+ this.tools = new Map([...this.tools].filter(([id]) => id !== acpId));
84
+ this.callIds = new Map([...this.callIds].filter(([id]) => id !== callId));
85
+ this.published = new Set([...this.published].filter((id) => id !== acpId));
86
+ this.handedOff = new Set([...this.handedOff].filter((id) => id !== acpId));
87
+ this.mirrors = new Set([...this.mirrors].filter((id) => id !== acpId));
88
+ this.claimed = new Set([...this.claimed, acpId]);
89
+ }
90
+ /** @returns a promise that settles at the next change to any call. */
91
+ nextChange() {
92
+ const { promise, resolve } = Promise.withResolvers();
93
+ this.changeWaiters = [...this.changeWaiters, resolve];
94
+ return promise;
95
+ }
96
+ publish(acpId) {
97
+ this.stopSettling(acpId);
98
+ if (this.published.has(acpId))
99
+ return;
100
+ this.published = new Set([...this.published, acpId]);
101
+ this.options.onReady(acpId);
102
+ }
103
+ /**
104
+ * claude-agent-acp goes from pending straight to completed, so a long command would show
105
+ * nothing until it ended. Its input arrives over several updates with nothing marking the
106
+ * end, so a command counts as started once they stop. Only commands, because a file write
107
+ * reports its path and then goes quiet while its content is generated: showing it then
108
+ * would freeze a card with no diff in it.
109
+ */
110
+ publishOnceQuiet(record) {
111
+ const { acpId } = record;
112
+ this.stopSettling(acpId);
113
+ if (this.published.has(acpId) || commandOf(record) === undefined)
114
+ return;
115
+ const timer = setTimeout(() => {
116
+ this.publish(acpId);
117
+ }, this.options.settleMs);
118
+ timer.unref();
119
+ this.settling = new Map([...this.settling, [acpId, timer]]);
120
+ }
121
+ stopSettling(acpId) {
122
+ clearTimeout(this.settling.get(acpId));
123
+ this.settling = new Map([...this.settling].filter(([id]) => id !== acpId));
124
+ }
125
+ notifyChange() {
126
+ const waiters = this.changeWaiters;
127
+ this.changeWaiters = [];
128
+ for (const wake of waiters)
129
+ wake();
130
+ }
131
+ }