@khorsheed/dsh-local-agent-codex 0.2.0-rc.3 → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Generated by scripts/sync-icon-artwork.mts from @deepseek-ai/dsh-client-ui-primitives
3
- * (v0.1.7-rc.1, harness 46a7f68b09) — do not edit by hand. The 0.1.5 and rc.1 host lines
3
+ * (v0.2.1-rc.1, harness 1600d8a1e1) — do not edit by hand. The 0.1.5 and rc.1 host lines
4
4
  * share no icon export name, so the rc.1 artwork is inlined here: pure SVG,
5
5
  * no host runtime identity, identical on every line. Re-run the generator
6
6
  * after adding an icon import or bumping the harness checkout.
@@ -13,7 +13,7 @@
13
13
  * it too (a deliberate user re-enable conflicts loud, by design).
14
14
  * @module @khorsheed/dsh-local-agent-codex/codex-cli-provider
15
15
  */
16
- import type { ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm';
16
+ import type { ContentBlock, ReasoningEffortId, TokenUsage } from '@deepseek-ai/dsh-llm';
17
17
  import { type ResolvedSubagentStartRequest, type SubagentCapabilities, type SubagentProvider, type SubagentRun, type SubagentStartRequest, type SubagentStopReason } from '@deepseek-ai/dsh-subagent';
18
18
  import type { Context } from '@deepseek-ai/cordis';
19
19
  import type { Session } from '@deepseek-ai/dsh-session';
@@ -21,6 +21,14 @@ import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-sub
21
21
  import type { DelegationExecTarget, LocalAgentToolCalls } from '@khorsheed/dsh-local-agent/types';
22
22
  import type { CodexLiveDriver } from './live-driver.ts';
23
23
  import { type CodexEndpointAuth } from './endpoint-auth.ts';
24
+ /**
25
+ * The synthesized result for a call whose result the stream never recorded
26
+ * (a killed run, or a result-less item type like web_search_call). The
27
+ * released-log contract resolves every advertised call before its step
28
+ * closes, so an interrupted call folds with this error result. Same text as
29
+ * the kimi provider's placeholder, for family consistency.
30
+ */
31
+ export declare const CODEX_INTERRUPTED_TOOL_RESULT = "\uFF08\u5DE5\u5177\u8C03\u7528\u88AB\u4E2D\u65AD\uFF0C\u672A\u80FD\u8BB0\u5F55\u8FD4\u56DE\u7ED3\u679C\uFF09";
24
32
  /** Default POSIX grace between subprocess termination tiers. */
25
33
  export declare const DEFAULT_DISPOSE_GRACE_MS = 3000;
26
34
  /**
@@ -308,7 +316,8 @@ export declare class CodexStreamParser implements CodexStreamFoldState {
308
316
  * not put one on the wire; the settle path then reads the rollout's
309
317
  * turn_context). Item types covered: `reasoning` (thinking), `agent_message`
310
318
  * (reply text), `command_execution` (shell command with aggregated output),
311
- * `web_search_call`, and `function_call_output`. The last `agent_message`
319
+ * `web_search_call`, `custom_tool_call`/`custom_tool_call_output` (deferred
320
+ * and dynamic tools), and `function_call_output`. The last `agent_message`
312
321
  * wins as the run output (the final answer), and `turn.completed` carries the
313
322
  * turn's usage. Malformed lines are skipped.
314
323
  * @param stream - the collected stdout NDJSON text.
@@ -336,6 +345,17 @@ export declare function parseCodexJsonStream(stream: string): {
336
345
  * @returns the published run after the child starts.
337
346
  */
338
347
  export declare function startCodexCliRun(request: SubagentStartRequest, spec: CodexCliRunSpec): Promise<SubagentRun>;
348
+ /**
349
+ * Write the round's request/header when the config differs from the child
350
+ * log's last one (the host logs changed snapshots, not per-call duplicates):
351
+ * the first write is `initial`, a differing one `change`, an identical one
352
+ * skipped. Context Lens reads the child log's config from these headers.
353
+ */
354
+ export declare function appendCodexRequestHeader(childSession: Session, config: {
355
+ provider: string;
356
+ model: string;
357
+ reasoningEffort?: ReasoningEffortId;
358
+ }): void;
339
359
  /** One assistant-role message event, attributed to the codex route. */
340
360
  export declare function codexAssistantEvent(blocks: readonly ContentBlock[]): import("@deepseek-ai/dsh-llm").AssistantMessage;
341
361
  /**
@@ -344,8 +364,17 @@ export declare function codexAssistantEvent(blocks: readonly ContentBlock[]): im
344
364
  * assembler requires: without a boundary the step never enters the location
345
365
  * index, the line's assistant message resolves to a turn-level location, and
346
366
  * the real-time view renders nothing (only a full rebuild, which mints step
347
- * drafts from the explicit coordinates, recovers it). Tool cards escape that
348
- * fate only because their definition does not consult the step location.
367
+ * drafts from the explicit coordinates, recovers it).
368
+ *
369
+ * A tool line folds the NATIVE lifecycle inside its one step: an assistant
370
+ * message advertising the tool-call block, the `tool/call`, then its
371
+ * `tool/result` — the released-log reader pairs call and result against the
372
+ * advertisement and resolves every advertised call before the step closes.
373
+ * Every fold sees a COMPLETED item (the exec live mirror holds the volatile
374
+ * last line; the live driver folds at item/completed), so a result the
375
+ * stream has not recorded by fold time will never arrive — the call folds
376
+ * with the synthesized interrupted result instead of leaving the step
377
+ * unresolvable.
349
378
  * @returns whether the line folded (false when the options skipped it).
350
379
  */
351
380
  export declare function appendCodexTranscriptLine(childSession: Session, turn: number, step: number, line: CodexTranscriptLine, usage: TokenUsage | undefined): boolean;
@@ -23,6 +23,14 @@ import { LiveChannelUnavailableError } from './live-driver.js';
23
23
  import { endpointAuthEnv, endpointConfigOverrides, hasEndpointCredential } from './endpoint-auth.js';
24
24
  import { readCodexBaseUrl } from './provision.js';
25
25
  import { codexRolloutRoundFacts, usageFromCodex } from './records.js';
26
+ /**
27
+ * The synthesized result for a call whose result the stream never recorded
28
+ * (a killed run, or a result-less item type like web_search_call). The
29
+ * released-log contract resolves every advertised call before its step
30
+ * closes, so an interrupted call folds with this error result. Same text as
31
+ * the kimi provider's placeholder, for family consistency.
32
+ */
33
+ export const CODEX_INTERRUPTED_TOOL_RESULT = '(工具调用被中断,未能记录返回结果)';
26
34
  /** Quote one TOML basic string for the `-c` config override. */
27
35
  function tomlString(value) {
28
36
  return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
@@ -50,7 +58,10 @@ export function registerCodexMemberRun(ctx, childSessionId, parentSessionId) {
50
58
  + `command=${tomlString(bridge.command)},`
51
59
  + `args=[${bridge.args.map(tomlString).join(',')}],`
52
60
  + `env={${MEMBER_BRIDGE_SOCKET_ENV}=${tomlString(registry.memberBridgeSocketPath())},${MEMBER_BRIDGE_TOKEN_ENV}=${tomlString(token)}},`
53
- + `default_tools_approval_mode=${tomlString('approve')}`
61
+ + `default_tools_approval_mode=${tomlString('approve')},`
62
+ // A bridge call may wait for the human (ask_user_question, a mode tool's
63
+ // approval): Codex's 60s MCP default would fail it first.
64
+ + `tool_timeout_sec=1800`
54
65
  + `}`;
55
66
  let released = false;
56
67
  return {
@@ -651,6 +662,40 @@ function foldCodexStreamLine(state, raw) {
651
662
  }
652
663
  }
653
664
  }
665
+ else if (item.type === 'custom_tool_call') {
666
+ // Custom tools (the deferred `exec` discovery tool, dynamic tools): the
667
+ // call rides `input`, its result the paired custom_tool_call_output.
668
+ // Without this branch a tool-loop round mirrored as two bare adjacent
669
+ // replies with the call between them invisible.
670
+ countToolCall(state, 'custom_tool_call');
671
+ state.lines.push({
672
+ kind: 'tool',
673
+ id: typeof item.id === 'string' ? item.id : `codex-tool-${state.lines.length}`,
674
+ name: item.name ?? 'custom',
675
+ ...typeof item.input === 'string' && item.input !== '' ? { args: item.input } : {},
676
+ });
677
+ }
678
+ else if (item.type === 'custom_tool_call_output') {
679
+ // {call_id, output: string | content blocks} — pair by recency, the same
680
+ // idiom function_call_output uses (the stream orders a result right
681
+ // behind its call).
682
+ const output = typeof item.output === 'string' ? item.output
683
+ : Array.isArray(item.output)
684
+ ? item.output
685
+ .filter((block) => typeof block === 'object' && block !== null && typeof block.text === 'string')
686
+ .map(block => block.text)
687
+ .join('\n')
688
+ : undefined;
689
+ if (output !== undefined && output.trim() !== '') {
690
+ const last = state.lines[state.lines.length - 1];
691
+ if (last !== undefined && last.kind === 'tool') {
692
+ state.lines[state.lines.length - 1] = {
693
+ ...last,
694
+ result: last.result === undefined ? output : `${last.result}\n${output}`,
695
+ };
696
+ }
697
+ }
698
+ }
654
699
  }
655
700
  /**
656
701
  * Incremental `codex exec --json` parser: feed stdout chunks as they arrive
@@ -685,7 +730,8 @@ export class CodexStreamParser {
685
730
  * not put one on the wire; the settle path then reads the rollout's
686
731
  * turn_context). Item types covered: `reasoning` (thinking), `agent_message`
687
732
  * (reply text), `command_execution` (shell command with aggregated output),
688
- * `web_search_call`, and `function_call_output`. The last `agent_message`
733
+ * `web_search_call`, `custom_tool_call`/`custom_tool_call_output` (deferred
734
+ * and dynamic tools), and `function_call_output`. The last `agent_message`
689
735
  * wins as the run output (the final answer), and `turn.completed` carries the
690
736
  * turn's usage. Malformed lines are skipped.
691
737
  * @param stream - the collected stdout NDJSON text.
@@ -772,6 +818,17 @@ export function startCodexCliRun(request, spec) {
772
818
  // The turn opens at the real spawn moment so the timing projection
773
819
  // measures actual CLI runtime, not the post-hoc append time.
774
820
  spec.childSession?.append('turn/start', { turn });
821
+ // The provider's own launch facts are the child log's request config —
822
+ // codex's stream never carries model/effort, so the argv the provider
823
+ // spawns with is the authoritative source. The header contract requires a
824
+ // model: an unconfigured round records nothing rather than inventing one.
825
+ if (spec.model !== undefined && spec.childSession !== undefined) {
826
+ appendCodexRequestHeader(spec.childSession, {
827
+ provider: 'codex-local',
828
+ model: spec.model,
829
+ ...spec.effort === undefined ? {} : { reasoningEffort: spec.effort },
830
+ });
831
+ }
775
832
  // Live mirror: fold the NDJSON stream as chunks arrive so the child session
776
833
  // shows the run's progress before settle; the settle mirror below resumes
777
834
  // from the live counters and stays a no-op when live mirroring kept up.
@@ -896,29 +953,12 @@ export function startCodexCliRun(request, spec) {
896
953
  signal: request.signal,
897
954
  onAbort,
898
955
  }).then((settled) => {
899
- // Every terminal path closes the turn so the timing window never stays
900
- // open on a failed or cancelled run: success settles 'completed', a
901
- // non-zero exit 'error', and a locally cancelled run 'aborted'. The
902
- // turn/end timestamp is the real settle moment.
903
956
  // A completed round proves the scoped credential is live — the endpoint
904
957
  // answered. Reported here rather than from the mirror so it lands whether
905
- // or not the round is session-backed.
958
+ // or not the round is session-backed. The turn boundary itself is the
959
+ // post-exit chain's job (it must follow the settle mirror).
906
960
  if (settled.stopReason === 'completed')
907
961
  spec.onAuthSuccess?.();
908
- if (spec.childSession !== undefined) {
909
- if (settled.stopReason === 'completed') {
910
- spec.childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
911
- }
912
- else if (settled.stopReason === 'aborted') {
913
- spec.childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
914
- }
915
- else {
916
- spec.childSession.append('turn/end', {
917
- turn,
918
- reason: { kind: 'error', error: { message: `codex exec exited with code ${String(exitCode)}`, code: 'UNKNOWN' } },
919
- });
920
- }
921
- }
922
962
  return settled;
923
963
  });
924
964
  // After the child EXITS — however it ended (completed, killed by dispose, or
@@ -926,9 +966,13 @@ export function startCodexCliRun(request, spec) {
926
966
  // subagent session, so a cancelled round still preserves its partial work
927
967
  // (reasoning, commands, replies) and real token usage. The thread id is
928
968
  // recorded even on abort so the partial thread stays resumable. Waits for
929
- // the settle chain first (so turn/end is already appended) AND for the
930
- // process to actually exit (so stdout is drained before parsing).
931
- void result.then(() => child.done).then(() => {
969
+ // the settle chain first AND for the process to actually exit (so stdout is
970
+ // drained before parsing). The turn closes AFTER the settle mirror, still
971
+ // serialized behind the live mirror's queue: every mirrored step must land
972
+ // before turn/end — the released-log reader rejects a step outside an open
973
+ // turn on every cold read. The run's result still settles at the real
974
+ // moment (abort latency unchanged); only the boundary event moves.
975
+ void result.then(() => child.done).then(async () => {
932
976
  if (spec.onAuthFailure !== undefined) {
933
977
  // The seam's collected buffers are complete at process exit; the
934
978
  // streamed variables can lag `done` by a tick.
@@ -939,7 +983,30 @@ export function startCodexCliRun(request, spec) {
939
983
  spec.onAuthFailure(authDetail.split('\n').find(line => line.trim() !== '') ?? 'auth failure');
940
984
  }
941
985
  }
942
- return mirrorCodexAfterExit(spec, task, turn, output, liveMirror, startedAtMs);
986
+ await mirrorCodexAfterExit(spec, task, turn, output, liveMirror, startedAtMs);
987
+ // Every terminal path closes the turn so the timing window never stays
988
+ // open on a failed or cancelled run: success settles 'completed', a
989
+ // non-zero exit 'error', and a locally cancelled run 'aborted'.
990
+ if (spec.childSession !== undefined) {
991
+ const settled = await result;
992
+ if (settled.stopReason === 'completed') {
993
+ spec.childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
994
+ }
995
+ else if (settled.stopReason === 'aborted') {
996
+ spec.childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
997
+ }
998
+ else {
999
+ spec.childSession.append('turn/end', {
1000
+ turn,
1001
+ reason: { kind: 'error', error: { message: `codex exec exited with code ${String(exitCode)}`, code: 'UNKNOWN' } },
1002
+ });
1003
+ }
1004
+ if (spec.ctx !== undefined) {
1005
+ await persistChildSession(spec.ctx, spec.childSession).catch((error) => {
1006
+ spec.ctx?.logger.warn(`subagent-codex: turn/end persistence failed: ${thrown(error).message}`);
1007
+ });
1008
+ }
1009
+ }
943
1010
  }, () => { });
944
1011
  return Promise.resolve(subprocessRunHandle({
945
1012
  // Host 0.2.1: the published run id is a fresh PER-EXECUTION identity —
@@ -955,6 +1022,27 @@ export function startCodexCliRun(request, spec) {
955
1022
  teardown: disposeProcess,
956
1023
  }));
957
1024
  }
1025
+ /**
1026
+ * Write the round's request/header when the config differs from the child
1027
+ * log's last one (the host logs changed snapshots, not per-call duplicates):
1028
+ * the first write is `initial`, a differing one `change`, an identical one
1029
+ * skipped. Context Lens reads the child log's config from these headers.
1030
+ */
1031
+ export function appendCodexRequestHeader(childSession, config) {
1032
+ const serialized = JSON.stringify(config);
1033
+ let last;
1034
+ for (const event of childSession.snapshotEvents()) {
1035
+ if (event.type === 'request/header') {
1036
+ last = JSON.stringify(event.data.header?.config);
1037
+ }
1038
+ }
1039
+ if (last === serialized)
1040
+ return;
1041
+ childSession.append('request/header', {
1042
+ header: { config },
1043
+ reason: last === undefined ? 'initial' : 'change',
1044
+ });
1045
+ }
958
1046
  /** One assistant-role message event, attributed to the codex route. */
959
1047
  export function codexAssistantEvent(blocks) {
960
1048
  return createAssistantMessage({
@@ -968,35 +1056,48 @@ export function codexAssistantEvent(blocks) {
968
1056
  * assembler requires: without a boundary the step never enters the location
969
1057
  * index, the line's assistant message resolves to a turn-level location, and
970
1058
  * the real-time view renders nothing (only a full rebuild, which mints step
971
- * drafts from the explicit coordinates, recovers it). Tool cards escape that
972
- * fate only because their definition does not consult the step location.
1059
+ * drafts from the explicit coordinates, recovers it).
1060
+ *
1061
+ * A tool line folds the NATIVE lifecycle inside its one step: an assistant
1062
+ * message advertising the tool-call block, the `tool/call`, then its
1063
+ * `tool/result` — the released-log reader pairs call and result against the
1064
+ * advertisement and resolves every advertised call before the step closes.
1065
+ * Every fold sees a COMPLETED item (the exec live mirror holds the volatile
1066
+ * last line; the live driver folds at item/completed), so a result the
1067
+ * stream has not recorded by fold time will never arrive — the call folds
1068
+ * with the synthesized interrupted result instead of leaving the step
1069
+ * unresolvable.
973
1070
  * @returns whether the line folded (false when the options skipped it).
974
1071
  */
975
1072
  export function appendCodexTranscriptLine(childSession, turn, step, line, usage) {
976
1073
  childSession.append('step/start', { turn, step });
977
1074
  if (line.kind === 'tool') {
978
- // Native tool card: the call event now, the result event when the stream
979
- // already carries it. Tool lines never carry the round's usage — the
980
- // callers attach usage to the round's final line, which is an agent
981
- // message on every well-formed stream.
1075
+ // A tool-only call's usage rides the advertising assistant/message (the
1076
+ // tool/call and tool/result events themselves have no usage slot).
1077
+ const args = line.args ?? '';
1078
+ childSession.append('assistant/message', {
1079
+ turn,
1080
+ step,
1081
+ message: codexAssistantEvent([{ type: 'tool-call', id: line.id, name: line.name, arguments: args }]),
1082
+ stream: [],
1083
+ ...usage === undefined ? {} : { usage },
1084
+ }, { surfaceOp: 'append' });
982
1085
  const call = childSession.append('tool/call', {
983
1086
  turn,
984
1087
  step,
985
1088
  callId: line.id,
986
1089
  name: line.name,
987
- arguments: line.args ?? '',
1090
+ arguments: args,
988
1091
  });
989
- if (line.result !== undefined) {
990
- childSession.append('tool/result', {
991
- turn,
992
- step,
993
- message: createToolResultMessage({
994
- callId: line.id,
995
- content: [{ type: 'text', text: line.result }],
996
- isError: false,
997
- }),
998
- }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] });
999
- }
1092
+ childSession.append('tool/result', {
1093
+ turn,
1094
+ step,
1095
+ message: createToolResultMessage({
1096
+ callId: line.id,
1097
+ content: [{ type: 'text', text: line.result ?? CODEX_INTERRUPTED_TOOL_RESULT }],
1098
+ isError: line.result === undefined,
1099
+ }),
1100
+ }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] });
1000
1101
  childSession.append('step/end', { turn, step });
1001
1102
  return true;
1002
1103
  }
@@ -42,11 +42,6 @@ export declare const DEFAULT_LIVE_CHANNEL_RETRY_MS: number;
42
42
  export declare const DEFAULT_SNAPSHOT_MIN_INTERVAL_MS = 50;
43
43
  /** Default minimum text growth between one streaming item's snapshot messages. */
44
44
  export declare const DEFAULT_SNAPSHOT_MIN_CHARS = 0;
45
- /**
46
- * The app-server channel could not come up (spawn failure or handshake
47
- * timeout/mismatch). The provider catches exactly this and falls back to the
48
- * exec one-shot; any other error is a real round failure.
49
- */
50
45
  export declare class LiveChannelUnavailableError extends Error {
51
46
  constructor(message: string);
52
47
  }