@shuind/dsh-codex-harness 0.1.23 → 0.1.25

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/README.md CHANGED
@@ -12,13 +12,14 @@
12
12
  - **旧版 Web 兼容**:在未提供新版模型设置/上下文设置 slot 的 DSH Web 中,Fast 和上下文容量会自动回退到旧版 composer slot;client 注入不依赖纯类型 slot 包。
13
13
  - **Responses 原生 apply_patch**:在 GPT Responses 请求的插件传输边界,把普通 JSON function tool 改写为 `type: "custom"` + OpenAI `lark` grammar;下一轮历史同步改写为 `custom_tool_call`。如果中转站拒绝 custom tool,则自动回退到普通 function tool,不改变 DSH 工具执行器。
14
14
  - **远程搜索与压缩**:OpenAI Responses 请求默认优先使用 hosted `web_search` 和 `/responses/compact`;失败时回退到 DSH 的本地实现。
15
+ - **远程压缩用量**:`/responses/compact` 返回标准 `usage` 时,插件会把 `input_tokens`、缓存读写和 `output_tokens` 原样拆分为 DSH 的用量字段;不再把远程压缩伪造成 `0`。中转站缺失或返回不一致的 usage 时,插件不会编造数字。
15
16
  - **上下文容量**:在 Web 中设置 `1K`–`1000K` tokens,设置值作用于下一次请求。
16
- - **活动状态**:Codex 等待模型响应或进行上下文压缩时,在输入框上方显示对应状态和耗时,让长时间 Deep Diving 不再像卡住。
17
+ - **活动状态**:Codex 请求模型、接收模型回复或进行上下文压缩时,在 `Deep diving...` 右侧显示对应阶段和耗时,让长时间 Deep Diving 不再像卡住。
17
18
 
18
19
  ## 安装
19
20
 
20
21
  ```sh
21
- dsh plugin --profile web add @shuind/dsh-codex-harness@0.1.23
22
+ dsh plugin --profile web add @shuind/dsh-codex-harness@0.1.24
22
23
  ```
23
24
 
24
25
  重启 Web,创建新会话,在模式菜单中选择 **Codex 模式**。
@@ -57,15 +58,20 @@ Fast 设置保存在 `codex` 命名空间,只对 Codex preset 生效。
57
58
 
58
59
  ### 活动状态
59
60
 
60
- Codex 等待模型首个输出时,Web 会在 `Deep diving...` 右侧显示 `正在等待模型响应... · Ns`;开始上下文压缩后会切换为 `正在压缩上下文... · Ns`。收到模型输出、压缩结束或下一轮开始后自动隐藏。该状态由 Host 的 `sessionProjections` 投影驱动,因此本地压缩和远程 `/responses/compact` 都会显示同一种状态。
61
+ Codex 发起模型请求时,Web 会在 `Deep diving...` 右侧显示 `请求模型... · Ns`;收到首个非空模型 token(非流式适配器则收到 `assistant/message`)后切换为 `模型回复... · Ns`。模型回复结束、进入工具调用、压缩结束或下一轮开始后自动隐藏。该状态由 Host 的 `sessionProjections` 投影驱动,因此本地压缩和远程 `/responses/compact` 都会显示同一种状态。
61
62
 
62
63
  如果当前 DSH 组合没有加载 projection 服务,插件会跳过注册,客户端也会自动隐藏这行,不影响普通会话。
63
64
 
64
65
  ## 随包提供的 Codex preset
65
66
 
66
- 安装插件后,首次启动对应的 DSH profile 时,插件会把完整 preset 从随包提供的 `presets/codex` 安装到 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/codex/`;如果你已经创建了同名 preset,插件会保留你的文件。普通用户不需要自己创建 preset。
67
+ 安装插件后,首次启动对应的 DSH profile 时,插件会安装两套随包提供的 preset
67
68
 
68
- Web 的 **Agent 预设** 中选择 **Codex**,再新建会话即可使用随包提供的 Codex 工具、网页搜索、远程压缩和 Skills 组合。Fast、思考强度、GPT 图片输入和上下文容量可以在对应的设置位置配置。
69
+ - **Codex 模式**:使用精简版 Codex 提示词。
70
+ - **Codex 协作模式**:在精简版 Codex 提示词后追加协作提示词。
71
+
72
+ 它们分别位于 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/codex/` 和 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/codex-collaboration/`。如果对应目录已经存在,插件会保留你的文件,不会覆盖自定义内容。
73
+
74
+ 在 Web 的 **Agent 预设** 中选择 **Codex 模式** 或 **Codex 协作模式**,再新建会话即可使用随包提供的 Codex 工具、网页搜索、远程压缩和 Skills 组合。两套 preset 共用 Fast、思考强度、GPT 图片输入和上下文容量设置;只有系统提示词是否包含协作指导不同。
69
75
 
70
76
  ## 自定义 preset
71
77
 
package/lib/client.js CHANGED
@@ -13,16 +13,56 @@ window.__ModuleLoader__.load({
13
13
  /** User-selectable Codex context controls use whole K-token units. */
14
14
  const CODEX_CONTEXT_UNIT = 1e3;
15
15
  const CODEX_CONTEXT_MAX = 1e6;
16
+ const CODEX_PRESET_IDS = /* @__PURE__ */ new Set(["codex", "codex-collaboration"]);
17
+ /** Both shipped presets use the same Codex request controls in Web. */
18
+ function isCodexPresetId(value) {
19
+ return value !== void 0 && CODEX_PRESET_IDS.has(value);
20
+ }
16
21
  //#endregion
17
22
  //#region lib/types/client/activity.js
18
23
  /** Client-only inference used while the host activity projection frame is in flight. */
19
- /** Infer the active model phase from the existing conversation snapshot. */
24
+ /** Infer the request-phase clock from the existing conversation snapshot. */
20
25
  function awaitingModelStartedAt(snapshot) {
21
26
  if (!snapshot.running || snapshot.runningCalls.length > 0) return void 0;
22
27
  let latest;
23
28
  for (const timing of snapshot.turnTimings.values()) if (timing.endTime === void 0) latest = timing.startTime;
24
29
  return latest;
25
30
  }
31
+ /** Infer the first non-empty model token timestamp from the current snapshot. */
32
+ function modelReplyStartedAt(snapshot) {
33
+ if (!snapshot.running || snapshot.runningCalls.length > 0) return void 0;
34
+ let openTurn;
35
+ for (const [turn, timing] of snapshot.turnTimings) if (timing.endTime === void 0) openTurn = {
36
+ turn,
37
+ startTime: timing.startTime
38
+ };
39
+ if (openTurn === void 0) return void 0;
40
+ if (snapshot.partial !== null && snapshot.partial.turn === openTurn.turn) {
41
+ const partialReplyStartedAt = snapshot.partial.replyStartedAt;
42
+ if (partialReplyStartedAt !== void 0) return partialReplyStartedAt;
43
+ }
44
+ const chat = snapshot.chat;
45
+ const step = (chat?.timeline.turns.get(openTurn.turn))?.steps.findLast((candidate) => candidate.end === void 0);
46
+ if (step === void 0) return void 0;
47
+ for (const node of [...chat?.nodes.values() ?? []].reverse()) {
48
+ if (node.kind !== "assistant-step") continue;
49
+ const data = node.data;
50
+ if (data.turn === openTurn.turn && data.step === step.step && typeof data.replyStartedAt === "number") return data.replyStartedAt;
51
+ }
52
+ }
53
+ /** Infer the two-phase model activity from the current client snapshot. */
54
+ function modelActivity(snapshot) {
55
+ const startedAt = awaitingModelStartedAt(snapshot);
56
+ if (startedAt === void 0) return void 0;
57
+ const replyStartedAt = modelReplyStartedAt(snapshot);
58
+ return replyStartedAt === void 0 ? {
59
+ activity: "requesting-model",
60
+ startedAt
61
+ } : {
62
+ activity: "model-reply",
63
+ startedAt: replyStartedAt
64
+ };
65
+ }
26
66
  //#endregion
27
67
  //#region lib/types/client/index.js
28
68
  /** Browser controls for the Codex request settings mounted by the Host plugin. */
@@ -45,7 +85,8 @@ window.__ModuleLoader__.load({
45
85
  contextSizeDescription: "Next request capacity in K tokens. The meter above is current.",
46
86
  contextRestore: "Restore model default",
47
87
  activityCompacting: "Compacting context...",
48
- activityAwaitingModel: "Waiting for model response..."
88
+ activityRequestingModel: "Requesting model...",
89
+ activityModelReply: "Model replying..."
49
90
  };
50
91
  const zh = {
51
92
  fast: "Fast",
@@ -57,13 +98,14 @@ window.__ModuleLoader__.load({
57
98
  contextSizeDescription: "设置下次请求的上下文容量,单位为 K tokens;上方是当前请求。",
58
99
  contextRestore: "恢复模型默认值",
59
100
  activityCompacting: "正在压缩上下文...",
60
- activityAwaitingModel: "正在等待模型响应..."
101
+ activityRequestingModel: "请求模型...",
102
+ activityModelReply: "模型回复..."
61
103
  };
62
104
  function FastModeButton({ sessionId, useSessions, useSettings, setSetting, t }) {
63
105
  const agentPreset = useSessions((state) => state.byId[sessionId]?.agentPreset);
64
106
  const snapshot = useSettings((state) => state);
65
107
  const fast = snapshot.value?.fast ?? false;
66
- if (agentPreset !== "codex") return null;
108
+ if (!isCodexPresetId(agentPreset)) return null;
67
109
  return (0, react_jsx_runtime.jsxs)("button", {
68
110
  type: "button",
69
111
  role: "menuitemcheckbox",
@@ -108,7 +150,7 @@ window.__ModuleLoader__.load({
108
150
  const agentPreset = useSessions((state) => state.byId[sessionId]?.agentPreset);
109
151
  const snapshot = useSettings((state) => state);
110
152
  const fast = snapshot.value?.fast ?? false;
111
- if (agentPreset !== "codex") return null;
153
+ if (!isCodexPresetId(agentPreset)) return null;
112
154
  return (0, react_jsx_runtime.jsxs)("button", {
113
155
  type: "button",
114
156
  role: "switch",
@@ -278,22 +320,27 @@ window.__ModuleLoader__.load({
278
320
  function ActivityLine({ sessionId, useSession, useSessions, useProjection, t }) {
279
321
  const agentPreset = useSessions((state) => state.byId[sessionId]?.agentPreset);
280
322
  const projectedActivity = useProjection("codexActivity");
281
- const fallbackStartedAt = useSession((snapshot) => awaitingModelStartedAt(snapshot));
282
- const activity = projectedActivity !== void 0 ? projectedActivity : agentPreset === "codex" && fallbackStartedAt !== void 0 ? {
283
- activity: "awaiting-model",
284
- startedAt: fallbackStartedAt
285
- } : null;
323
+ const fallbackActivity = useSession((snapshot) => modelActivity(snapshot));
324
+ const normalizedProjectedActivity = projectedActivity?.activity === "awaiting-model" ? {
325
+ activity: fallbackActivity?.activity ?? "requesting-model",
326
+ startedAt: fallbackActivity?.startedAt ?? projectedActivity.startedAt
327
+ } : projectedActivity;
328
+ const activity = projectedActivity !== void 0 ? normalizedProjectedActivity : isCodexPresetId(agentPreset) && fallbackActivity !== void 0 ? fallbackActivity : null;
286
329
  const [now, setNow] = (0, react.useState)(() => Date.now());
287
330
  (0, react.useEffect)(() => {
288
- if (agentPreset !== "codex" || activity === void 0 || activity === null) return void 0;
331
+ if (!isCodexPresetId(agentPreset) || activity === void 0 || activity === null) return void 0;
289
332
  setNow(Date.now());
290
333
  const timer = setInterval(() => {
291
334
  setNow(Date.now());
292
335
  }, 1e3);
293
336
  return () => clearInterval(timer);
294
- }, [agentPreset, activity?.startedAt]);
295
- if (agentPreset !== "codex" || activity === void 0 || activity === null) return null;
296
- const label = activity.activity === "awaiting-model" ? t("activityAwaitingModel") : t("activityCompacting");
337
+ }, [
338
+ agentPreset,
339
+ activity?.activity,
340
+ activity?.startedAt
341
+ ]);
342
+ if (!isCodexPresetId(agentPreset) || activity === void 0 || activity === null) return null;
343
+ const label = activity.activity === "compaction" ? t("activityCompacting") : activity.activity === "model-reply" ? t("activityModelReply") : t("activityRequestingModel");
297
344
  return (0, react_jsx_runtime.jsxs)("span", {
298
345
  id: "codex-activity",
299
346
  role: "status",
package/lib/index.js CHANGED
@@ -5,6 +5,7 @@ import { randomBytes } from "node:crypto";
5
5
  import { resolve } from "node:path";
6
6
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
7
7
  import { AsyncLocalStorage } from "node:async_hooks";
8
+ import { isTokenDelta } from "@deepseek-ai/dsh-llm/message";
8
9
  import { z as z$1 } from "zod";
9
10
  //#region lib/types/context.js
10
11
  const CODEX_CONTEXT_MAX = 1e6;
@@ -13,9 +14,8 @@ const CODEX_CONTEXT_MAX = 1e6;
13
14
  /** Parser and line-oriented applicator for Codex's `apply_patch` language. */
14
15
  const APPLY_PATCH_DESCRIPTION = "Edits files using Codex patch syntax with Begin/End Patch markers and file update directives. In hunk lines, the first character is the operation marker; repeat a source-leading marker when the source line itself starts with one.";
15
16
  /** The grammar sent to providers that support OpenAI custom grammar tools. */
16
- const APPLY_PATCH_GRAMMAR = String.raw`start: begin_patch environment_id? hunk+ end_patch
17
+ const APPLY_PATCH_GRAMMAR = String.raw`start: begin_patch hunk+ end_patch
17
18
  begin_patch: "*** Begin Patch" LF
18
- environment_id: "*** Environment ID: " filename LF
19
19
  end_patch: "*** End Patch" LF?
20
20
  hunk: add_hunk | delete_hunk | update_hunk
21
21
  add_hunk: "*** Add File: " filename LF add_line+
@@ -37,7 +37,6 @@ function isFileHeader(line) {
37
37
  const trimmed = line.trim();
38
38
  return trimmed.startsWith("*** Add File: ") || trimmed.startsWith("*** Delete File: ") || trimmed.startsWith("*** Update File: ");
39
39
  }
40
- const ENVIRONMENT_ID_MARKER = "*** Environment ID:";
41
40
  function unwrapHeredoc(input) {
42
41
  const normalized = input.replaceAll("\r\n", "\n").trim();
43
42
  const lines = normalized.split("\n");
@@ -66,11 +65,7 @@ function parsePatch(input) {
66
65
  const files = [];
67
66
  let index = 1;
68
67
  const end = lines.length - 1;
69
- if (lines[index]?.trim().startsWith(ENVIRONMENT_ID_MARKER) === true) {
70
- if (lines[index].trim().slice(19).trim().length === 0) invalid("environment id cannot be empty");
71
- index++;
72
- if (lines[index]?.trim().startsWith(ENVIRONMENT_ID_MARKER) === true) invalid("environment id cannot be specified more than once");
73
- }
68
+ if (lines[index]?.trim().startsWith("*** Environment ID:") === true) invalid("environment selection is unavailable in this single-environment DSH plugin");
74
69
  while (index < end) {
75
70
  const header = lines[index++]?.trim();
76
71
  if (header === void 0) invalid("unexpected end of input");
@@ -218,6 +213,7 @@ function applyPatchHunks(original, hunks, label = "file") {
218
213
  /** Codex `exec_command` and `write_stdin` execution over dsh capability seams. */
219
214
  const STATES = /* @__PURE__ */ new WeakMap();
220
215
  const MIN_YIELD_TIME_MS = 250;
216
+ const WINDOWS_INITIAL_EXEC_YIELD_TIME_MS = 1e4;
221
217
  const MIN_EMPTY_YIELD_TIME_MS = 5e3;
222
218
  const MAX_YIELD_TIME_MS = 3e4;
223
219
  const MAX_EMPTY_YIELD_TIME_MS = 3e5;
@@ -236,8 +232,9 @@ function stateFor(agent) {
236
232
  function positiveFinite(name, value) {
237
233
  if (value !== void 0 && (!Number.isFinite(value) || value < 0)) throw new Error(`${name} must be a non-negative finite number`);
238
234
  }
239
- function normalizeWaitMs(value, fallback, empty = false) {
240
- return Math.max(empty ? MIN_EMPTY_YIELD_TIME_MS : MIN_YIELD_TIME_MS, Math.min(empty ? MAX_EMPTY_YIELD_TIME_MS : MAX_YIELD_TIME_MS, Math.trunc(value ?? fallback)));
235
+ function normalizeWaitMs(value, fallback, empty = false, initialExec = false) {
236
+ const lower = empty ? MIN_EMPTY_YIELD_TIME_MS : initialExec && process.platform === "win32" ? WINDOWS_INITIAL_EXEC_YIELD_TIME_MS : MIN_YIELD_TIME_MS;
237
+ return Math.max(lower, Math.min(empty ? MAX_EMPTY_YIELD_TIME_MS : MAX_YIELD_TIME_MS, Math.trunc(value ?? fallback)));
241
238
  }
242
239
  function outputLimit(maxOutputBytes, maxOutputTokens) {
243
240
  const tokens = maxOutputTokens ?? DEFAULT_OUTPUT_TOKENS;
@@ -369,7 +366,7 @@ async function runExecCommand(ctx, args, exec, config) {
369
366
  const sendRequest = {
370
367
  text: commandFor(args),
371
368
  submit: true,
372
- waitMs: normalizeWaitMs(args.yield_time_ms, config.defaultYieldTimeMs),
369
+ waitMs: normalizeWaitMs(args.yield_time_ms, config.defaultYieldTimeMs, false, true),
373
370
  signal: exec.signal
374
371
  };
375
372
  const result = await terminals.startSend(agent, spawned.sessionId, sendRequest).done;
@@ -432,7 +429,7 @@ async function runExecCommand(ctx, args, exec, config) {
432
429
  signal: exec.signal
433
430
  }));
434
431
  try {
435
- await waitForShell(process, normalizeWaitMs(args.yield_time_ms, config.defaultYieldTimeMs), exec.signal);
432
+ await waitForShell(process, normalizeWaitMs(args.yield_time_ms, config.defaultYieldTimeMs, false, true), exec.signal);
436
433
  const output = readShellOutput(process.readOutput(), maxBytes);
437
434
  if (process.status === "running") {
438
435
  if (exec.agent === void 0) {
@@ -899,6 +896,29 @@ function compactText(body) {
899
896
  const encrypted = text.join("\n\n").trim();
900
897
  return encrypted.length === 0 ? "" : `${REMOTE_COMPACTION_OPEN}${encrypted}${REMOTE_COMPACTION_CLOSE}`;
901
898
  }
899
+ function nonNegativeInteger(value) {
900
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : void 0;
901
+ }
902
+ /** Map the Responses compact usage object without inventing missing counts. */
903
+ function compactUsage(body) {
904
+ if (typeof body.usage !== "object" || body.usage === null) return void 0;
905
+ const raw = body.usage;
906
+ const inputTokens = nonNegativeInteger(raw.input_tokens);
907
+ const outputTokens = nonNegativeInteger(raw.output_tokens);
908
+ if (inputTokens === void 0 || outputTokens === void 0) return void 0;
909
+ const details = typeof raw.input_tokens_details === "object" && raw.input_tokens_details !== null ? raw.input_tokens_details : void 0;
910
+ const cacheReadTokens = nonNegativeInteger(details?.cached_tokens);
911
+ const cacheWriteTokens = nonNegativeInteger(details?.cache_write_tokens);
912
+ const cached = cacheReadTokens ?? 0;
913
+ const writes = cacheWriteTokens ?? 0;
914
+ if (cached + writes > inputTokens) return void 0;
915
+ return {
916
+ inputTokens: inputTokens - cached - writes,
917
+ outputTokens,
918
+ ...cacheReadTokens !== void 0 && cacheReadTokens > 0 ? { cacheReadTokens } : {},
919
+ ...cacheWriteTokens !== void 0 && cacheWriteTokens > 0 ? { cacheWriteTokens } : {}
920
+ };
921
+ }
902
922
  async function remoteCompact(ctx, options) {
903
923
  const profile = profileOf(ctx, options.provider);
904
924
  if (!supportsResponses(profile, options.provider) || profile?.baseURL === void 0) throw new Error("Codex remote compaction requires an OpenAI Responses provider with baseURL");
@@ -911,15 +931,20 @@ async function remoteCompact(ctx, options) {
911
931
  ...options.signal === void 0 ? {} : { signal: options.signal }
912
932
  });
913
933
  if (!response.ok) throw new Error(`remote compaction returned HTTP ${response.status}`);
914
- const text = compactText(await response.json());
934
+ const body = await response.json();
935
+ const text = compactText(body);
915
936
  if (text.length === 0) throw new Error("remote compaction returned no compaction text");
916
- return text;
937
+ const usage = compactUsage(body);
938
+ return {
939
+ text,
940
+ ...usage === void 0 ? {} : { usage }
941
+ };
917
942
  }
918
943
  /** Remote-first compaction waterfall with the existing DSH path as fallback. */
919
944
  function remoteCompactStream(ctx, options, next) {
920
945
  return (async function* () {
921
946
  try {
922
- const text = await remoteCompact(ctx, options);
947
+ const result = await remoteCompact(ctx, options);
923
948
  yield {
924
949
  type: "block-start",
925
950
  index: 0,
@@ -928,22 +953,19 @@ function remoteCompactStream(ctx, options, next) {
928
953
  yield {
929
954
  type: "text-delta",
930
955
  index: 0,
931
- text
956
+ text: result.text
932
957
  };
933
958
  yield {
934
959
  type: "block-end",
935
960
  index: 0,
936
961
  block: {
937
962
  type: "text",
938
- text
963
+ text: result.text
939
964
  }
940
965
  };
941
- yield {
966
+ if (result.usage !== void 0) yield {
942
967
  type: "usage",
943
- usage: {
944
- inputTokens: 0,
945
- outputTokens: 0
946
- }
968
+ usage: result.usage
947
969
  };
948
970
  yield {
949
971
  type: "finish",
@@ -959,13 +981,28 @@ function remoteCompactStream(ctx, options, next) {
959
981
  }
960
982
  //#endregion
961
983
  //#region lib/types/activity.js
962
- /** Host-side activity projection for the Codex compaction status line. */
984
+ /** Host-side activity projection for the Codex status line. */
985
+ /** Only a non-empty model delta starts the reply phase; usage and finish frames do not. */
986
+ function startsModelReply(event) {
987
+ if (event.type === "assistant/message") return true;
988
+ if (event.type !== "assistant/chunk" || typeof event.data !== "object" || event.data === null) return false;
989
+ const chunk = event.data.chunk;
990
+ return chunk !== void 0 && isTokenDelta(chunk);
991
+ }
963
992
  /** Persisted and wire shape of the Codex activity value. */
964
993
  const codexActivitySchema = z$1.union([
965
994
  z$1.object({
966
995
  activity: z$1.literal("compaction"),
967
996
  startedAt: z$1.number().int().nonnegative()
968
997
  }),
998
+ z$1.object({
999
+ activity: z$1.literal("requesting-model"),
1000
+ startedAt: z$1.number().int().nonnegative()
1001
+ }),
1002
+ z$1.object({
1003
+ activity: z$1.literal("model-reply"),
1004
+ startedAt: z$1.number().int().nonnegative()
1005
+ }),
969
1006
  z$1.object({
970
1007
  activity: z$1.literal("awaiting-model"),
971
1008
  startedAt: z$1.number().int().nonnegative()
@@ -979,11 +1016,15 @@ function applyCodexActivity(state, event) {
979
1016
  startedAt: event.time
980
1017
  };
981
1018
  if (event.type === "step/start" || event.type === "llm/retry-started") return {
982
- activity: "awaiting-model",
1019
+ activity: "requesting-model",
1020
+ startedAt: event.time
1021
+ };
1022
+ if (startsModelReply(event) && (state?.activity === "requesting-model" || state?.activity === "awaiting-model")) return {
1023
+ activity: "model-reply",
983
1024
  startedAt: event.time
984
1025
  };
985
1026
  if (event.type === "turn/start" || event.type === "turn/end" || event.type === "compaction/end") return null;
986
- if (state?.activity === "awaiting-model" && (event.type === "llm/retry" || event.type === "tool/call" || event.type === "step/end")) return null;
1027
+ if ((state?.activity === "requesting-model" || state?.activity === "model-reply" || state?.activity === "awaiting-model") && (event.type === "llm/retry" || event.type === "tool/call" || event.type === "step/end")) return null;
987
1028
  return state;
988
1029
  }
989
1030
  const codexActivityProjection = {
@@ -997,7 +1038,7 @@ const codexActivityProjection = {
997
1038
  },
998
1039
  schema: codexActivitySchema,
999
1040
  view: (state) => state,
1000
- stateVersion: 1
1041
+ stateVersion: 2
1001
1042
  };
1002
1043
  /** Register the optional activity unit without requiring the projection seam in every assembly. */
1003
1044
  function registerCodexActivityProjection(ctx) {
@@ -1018,12 +1059,13 @@ const inject = [
1018
1059
  "credentials",
1019
1060
  "settings"
1020
1061
  ];
1062
+ const DEFAULT_MAX_OUTPUT_BYTES = 1048576;
1021
1063
  /** Runtime configuration schema for the Codex tool bridge. */
1022
1064
  const Config = z.object({
1023
1065
  defaultYieldTimeMs: z.number().step(1).min(0).default(1e4),
1024
1066
  pollYieldTimeMs: z.number().step(1).min(0).default(5e3),
1025
1067
  writeYieldTimeMs: z.number().step(1).min(0).default(250),
1026
- maxOutputBytes: z.number().step(1).min(1).default(64e3),
1068
+ maxOutputBytes: z.number().step(1).min(1).default(DEFAULT_MAX_OUTPUT_BYTES),
1027
1069
  hostedWebSearch: z.boolean().default(true),
1028
1070
  remoteCompact: z.boolean().default(true),
1029
1071
  collaborationPrompt: z.boolean().default(false)
@@ -1566,7 +1608,7 @@ function apply(ctx, config = {}) {
1566
1608
  defaultYieldTimeMs: config.defaultYieldTimeMs ?? 1e4,
1567
1609
  pollYieldTimeMs: config.pollYieldTimeMs ?? 5e3,
1568
1610
  writeYieldTimeMs: config.writeYieldTimeMs ?? 250,
1569
- maxOutputBytes: config.maxOutputBytes ?? 64e3,
1611
+ maxOutputBytes: config.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
1570
1612
  hostedWebSearch: config.hostedWebSearch ?? true,
1571
1613
  remoteCompact: config.remoteCompact ?? true,
1572
1614
  collaborationPrompt: config.collaborationPrompt ?? false
package/lib/installer.js CHANGED
@@ -4,9 +4,9 @@ import { dirname, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  //#region lib/types/installer.js
6
6
  /** Install the user-visible Codex agent preset supplied by this bundle. */
7
- const PRESET_ID = "codex";
8
7
  const PRESET_FILES = ["agent.cordis.yml", "preset.yml"];
9
8
  const SOURCE_PRESET_DIR = fileURLToPath(new URL("../presets/codex/", import.meta.url));
9
+ const SOURCE_COLLABORATION_PRESET_DIR = fileURLToPath(new URL("../presets/codex-collaboration/", import.meta.url));
10
10
  function dshHomePath(...segments) {
11
11
  const configured = process.env.DSH_HOME?.trim();
12
12
  const expanded = configured === void 0 || configured.length === 0 ? join(homedir(), ".dsh") : configured === "~" ? homedir() : configured.startsWith("~/") || configured.startsWith("~\\") ? join(homedir(), configured.slice(2)) : configured;
@@ -14,21 +14,11 @@ function dshHomePath(...segments) {
14
14
  }
15
15
  /** Bundle plugin name for the preset installer. */
16
16
  const name = "codex-preset-installer";
17
- /**
18
- * Install the shipped Codex preset only when the user has not authored one.
19
- *
20
- * The directory is committed with a staging rename so a failed copy cannot
21
- * leave a half-written preset that hides the mode from the roster. Existing
22
- * directories are intentionally preserved, including user customizations.
23
- *
24
- * @param targetDir - destination preset directory.
25
- * @param sourceDir - directory containing the packaged preset files.
26
- */
27
- function installCodexPreset(targetDir = dshHomePath(".agent-presets", PRESET_ID), sourceDir = SOURCE_PRESET_DIR) {
17
+ function installPreset(targetDir, sourceDir, presetId) {
28
18
  if (existsSync(targetDir)) return;
29
19
  const parentDir = dirname(targetDir);
30
20
  mkdirSync(parentDir, { recursive: true });
31
- const stagingDir = mkdtempSync(join(parentDir, `.${PRESET_ID}-`));
21
+ const stagingDir = mkdtempSync(join(parentDir, `.${presetId}-`));
32
22
  try {
33
23
  for (const file of PRESET_FILES) copyFileSync(join(sourceDir, file), join(stagingDir, file));
34
24
  try {
@@ -43,12 +33,30 @@ function installCodexPreset(targetDir = dshHomePath(".agent-presets", PRESET_ID)
43
33
  });
44
34
  }
45
35
  }
36
+ /**
37
+ * Install the shipped Codex preset only when the user has not authored one.
38
+ *
39
+ * The directory is committed with a staging rename so a failed copy cannot
40
+ * leave a half-written preset that hides the mode from the roster. Existing
41
+ * directories are intentionally preserved, including user customizations.
42
+ *
43
+ * @param targetDir - destination preset directory.
44
+ * @param sourceDir - directory containing the packaged preset files.
45
+ */
46
+ function installCodexPreset(targetDir = dshHomePath(".agent-presets", "codex"), sourceDir = SOURCE_PRESET_DIR) {
47
+ installPreset(targetDir, sourceDir, "codex");
48
+ }
49
+ /** Install the shipped Codex preset with the optional collaboration guidance enabled. */
50
+ function installCodexCollaborationPreset(targetDir = dshHomePath(".agent-presets", "codex-collaboration"), sourceDir = SOURCE_COLLABORATION_PRESET_DIR) {
51
+ installPreset(targetDir, sourceDir, "codex-collaboration");
52
+ }
46
53
  /** Install the preset during profile boot without changing the host tool catalog. */
47
54
  function apply(ctx) {
48
55
  try {
49
56
  installCodexPreset();
57
+ installCodexCollaborationPreset();
50
58
  } catch (error) {
51
- ctx.logger.warn(`dsh-codex: could not install the Codex preset: ${String(error)}`);
59
+ ctx.logger.warn(`dsh-codex: could not install the Codex presets: ${String(error)}`);
52
60
  }
53
61
  }
54
62
  var installer_default = {
@@ -56,4 +64,4 @@ var installer_default = {
56
64
  apply
57
65
  };
58
66
  //#endregion
59
- export { apply, installer_default as default, installCodexPreset, name };
67
+ export { apply, installer_default as default, installCodexCollaborationPreset, installCodexPreset, name };
@@ -1,6 +1,6 @@
1
1
  /** Pure client/host-shared type for the Codex activity projection value. */
2
2
  export type CodexActivity = {
3
- activity: 'compaction' | 'awaiting-model';
3
+ activity: 'compaction' | 'requesting-model' | 'model-reply' | 'awaiting-model';
4
4
  startedAt: number;
5
5
  };
6
6
  //# sourceMappingURL=activity-types.d.ts.map
@@ -1,14 +1,15 @@
1
- /** Host-side activity projection for the Codex compaction status line. */
1
+ /** Host-side activity projection for the Codex status line. */
2
2
  import type { Context } from '@deepseek-ai/cordis';
3
3
  import { z, type ZodType } from 'zod';
4
4
  import type { CodexActivity } from './activity-types.ts';
5
5
  export type { CodexActivity } from './activity-types.ts';
6
- /** The only long-running Codex activity currently surfaced to the client. */
6
+ /** Long-running Codex activities currently surfaced to the client. */
7
7
  export type CodexActivityState = CodexActivity | null;
8
8
  /** Minimal event shape needed by this projection; no compaction package import is required. */
9
9
  export interface CodexActivityEvent {
10
10
  type: string;
11
11
  time: number;
12
+ data?: unknown;
12
13
  }
13
14
  /** Persisted and wire shape of the Codex activity value. */
14
15
  export declare const codexActivitySchema: ZodType<CodexActivityState>;
@@ -25,7 +26,7 @@ export declare const codexActivityProjection: {
25
26
  };
26
27
  readonly schema: z.ZodType<CodexActivityState, unknown, z.core.$ZodTypeInternals<CodexActivityState, unknown>>;
27
28
  readonly view: (state: CodexActivityState) => CodexActivityState;
28
- readonly stateVersion: 1;
29
+ readonly stateVersion: 2;
29
30
  };
30
31
  /** Register the optional activity unit without requiring the projection seam in every assembly. */
31
32
  export declare function registerCodexActivityProjection(ctx: Context): void;
@@ -1,11 +1,31 @@
1
- /** Host-side activity projection for the Codex compaction status line. */
1
+ /** Host-side activity projection for the Codex status line. */
2
+ import { isTokenDelta } from '@deepseek-ai/dsh-llm/message';
2
3
  import { z } from 'zod';
4
+ /** Only a non-empty model delta starts the reply phase; usage and finish frames do not. */
5
+ function startsModelReply(event) {
6
+ if (event.type === 'assistant/message')
7
+ return true;
8
+ if (event.type !== 'assistant/chunk' || typeof event.data !== 'object' || event.data === null)
9
+ return false;
10
+ const chunk = event.data.chunk;
11
+ return chunk !== undefined && isTokenDelta(chunk);
12
+ }
3
13
  /** Persisted and wire shape of the Codex activity value. */
4
14
  export const codexActivitySchema = z.union([
5
15
  z.object({
6
16
  activity: z.literal('compaction'),
7
17
  startedAt: z.number().int().nonnegative(),
8
18
  }),
19
+ z.object({
20
+ activity: z.literal('requesting-model'),
21
+ startedAt: z.number().int().nonnegative(),
22
+ }),
23
+ z.object({
24
+ activity: z.literal('model-reply'),
25
+ startedAt: z.number().int().nonnegative(),
26
+ }),
27
+ // Accepted for rolling upgrades: an older Codex host may still send this
28
+ // ambiguous state until its projection definition is refreshed.
9
29
  z.object({
10
30
  activity: z.literal('awaiting-model'),
11
31
  startedAt: z.number().int().nonnegative(),
@@ -17,14 +37,20 @@ export function applyCodexActivity(state, event) {
17
37
  if (event.type === 'compaction/start')
18
38
  return { activity: 'compaction', startedAt: event.time };
19
39
  if (event.type === 'step/start' || event.type === 'llm/retry-started') {
20
- return { activity: 'awaiting-model', startedAt: event.time };
40
+ return { activity: 'requesting-model', startedAt: event.time };
41
+ }
42
+ if (startsModelReply(event)
43
+ && (state?.activity === 'requesting-model' || state?.activity === 'awaiting-model')) {
44
+ return { activity: 'model-reply', startedAt: event.time };
21
45
  }
22
46
  if (event.type === 'turn/start' || event.type === 'turn/end' || event.type === 'compaction/end')
23
47
  return null;
24
48
  // Keep the line through reasoning and streamed output. It is the only
25
49
  // visible indication that the model is still progressing; tool cards have
26
50
  // their own status, so a tool dispatch retires this ambient line.
27
- if (state?.activity === 'awaiting-model'
51
+ if ((state?.activity === 'requesting-model'
52
+ || state?.activity === 'model-reply'
53
+ || state?.activity === 'awaiting-model')
28
54
  && (event.type === 'llm/retry'
29
55
  || event.type === 'tool/call'
30
56
  || event.type === 'step/end'))
@@ -44,7 +70,7 @@ export const codexActivityProjection = {
44
70
  // the `stateSchema`/`wire` split.
45
71
  schema: codexActivitySchema,
46
72
  view: (state) => state,
47
- stateVersion: 1,
73
+ stateVersion: 2,
48
74
  };
49
75
  /** Register the optional activity unit without requiring the projection seam in every assembly. */
50
76
  export function registerCodexActivityProjection(ctx) {
@@ -1,6 +1,17 @@
1
1
  /** Client-only inference used while the host activity projection frame is in flight. */
2
2
  import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client';
3
- export type ActivitySessionSnapshot = Pick<ConversationSnapshot, 'running' | 'partial' | 'runningCalls' | 'turnTimings'>;
4
- /** Infer the active model phase from the existing conversation snapshot. */
3
+ export type ActivitySessionSnapshot = Pick<ConversationSnapshot, 'running' | 'partial' | 'runningCalls' | 'turnTimings'> & {
4
+ /** Optional for hosts that predate the assembled Chat snapshot. */
5
+ readonly chat?: ConversationSnapshot['chat'];
6
+ };
7
+ export type CodexModelActivity = {
8
+ activity: 'requesting-model' | 'model-reply';
9
+ startedAt: number;
10
+ };
11
+ /** Infer the request-phase clock from the existing conversation snapshot. */
5
12
  export declare function awaitingModelStartedAt(snapshot: ActivitySessionSnapshot): number | undefined;
13
+ /** Infer the first non-empty model token timestamp from the current snapshot. */
14
+ export declare function modelReplyStartedAt(snapshot: ActivitySessionSnapshot): number | undefined;
15
+ /** Infer the two-phase model activity from the current client snapshot. */
16
+ export declare function modelActivity(snapshot: ActivitySessionSnapshot): CodexModelActivity | undefined;
6
17
  //# sourceMappingURL=activity.d.ts.map
@@ -1,5 +1,5 @@
1
1
  /** Client-only inference used while the host activity projection frame is in flight. */
2
- /** Infer the active model phase from the existing conversation snapshot. */
2
+ /** Infer the request-phase clock from the existing conversation snapshot. */
3
3
  export function awaitingModelStartedAt(snapshot) {
4
4
  if (!snapshot.running || snapshot.runningCalls.length > 0)
5
5
  return undefined;
@@ -10,4 +10,48 @@ export function awaitingModelStartedAt(snapshot) {
10
10
  }
11
11
  return latest;
12
12
  }
13
+ /** Infer the first non-empty model token timestamp from the current snapshot. */
14
+ export function modelReplyStartedAt(snapshot) {
15
+ if (!snapshot.running || snapshot.runningCalls.length > 0)
16
+ return undefined;
17
+ let openTurn;
18
+ for (const [turn, timing] of snapshot.turnTimings) {
19
+ if (timing.endTime === undefined)
20
+ openTurn = { turn, startTime: timing.startTime };
21
+ }
22
+ if (openTurn === undefined)
23
+ return undefined;
24
+ if (snapshot.partial !== null && snapshot.partial.turn === openTurn.turn) {
25
+ const partialReplyStartedAt = snapshot.partial.replyStartedAt;
26
+ if (partialReplyStartedAt !== undefined)
27
+ return partialReplyStartedAt;
28
+ }
29
+ // A non-streaming adapter has no partial row. The assembled Chat view still
30
+ // carries the reply boundary, but only accept it for the currently open step
31
+ // so an earlier tool step in the same turn cannot mask a new request.
32
+ const chat = snapshot.chat;
33
+ const turn = chat?.timeline.turns.get(openTurn.turn);
34
+ const step = turn?.steps.findLast(candidate => candidate.end === undefined);
35
+ if (step === undefined)
36
+ return undefined;
37
+ for (const node of [...(chat?.nodes.values() ?? [])].reverse()) {
38
+ if (node.kind !== 'assistant-step')
39
+ continue;
40
+ const data = node.data;
41
+ if (data.turn === openTurn.turn && data.step === step.step && typeof data.replyStartedAt === 'number') {
42
+ return data.replyStartedAt;
43
+ }
44
+ }
45
+ return undefined;
46
+ }
47
+ /** Infer the two-phase model activity from the current client snapshot. */
48
+ export function modelActivity(snapshot) {
49
+ const startedAt = awaitingModelStartedAt(snapshot);
50
+ if (startedAt === undefined)
51
+ return undefined;
52
+ const replyStartedAt = modelReplyStartedAt(snapshot);
53
+ return replyStartedAt === undefined
54
+ ? { activity: 'requesting-model', startedAt }
55
+ : { activity: 'model-reply', startedAt: replyStartedAt };
56
+ }
13
57
  //# sourceMappingURL=activity.js.map
@@ -11,7 +11,8 @@ declare const en: {
11
11
  readonly contextSizeDescription: "Next request capacity in K tokens. The meter above is current.";
12
12
  readonly contextRestore: "Restore model default";
13
13
  readonly activityCompacting: "Compacting context...";
14
- readonly activityAwaitingModel: "Waiting for model response...";
14
+ readonly activityRequestingModel: "Requesting model...";
15
+ readonly activityModelReply: "Model replying...";
15
16
  };
16
17
  type CodexKey = keyof typeof en;
17
18
  declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /** Browser controls for the Codex request settings mounted by the Host plugin. */
3
3
  import { useEffect, useState } from 'react';
4
- import { CODEX_CONTEXT_MAX, CODEX_CONTEXT_UNIT, CODEX_PRESET_ID } from "../context.js";
5
- import { awaitingModelStartedAt } from "./activity.js";
4
+ import { CODEX_CONTEXT_MAX, CODEX_CONTEXT_UNIT, isCodexPresetId } from "../context.js";
5
+ import { modelActivity } from "./activity.js";
6
6
  const NS = 'codex';
7
7
  const SETTINGS_NAMESPACE = 'codex';
8
8
  const MODEL_SETTINGS_SLOT = 'conversation.input.model.settings';
@@ -24,7 +24,8 @@ const en = {
24
24
  contextSizeDescription: 'Next request capacity in K tokens. The meter above is current.',
25
25
  contextRestore: 'Restore model default',
26
26
  activityCompacting: 'Compacting context...',
27
- activityAwaitingModel: 'Waiting for model response...',
27
+ activityRequestingModel: 'Requesting model...',
28
+ activityModelReply: 'Model replying...',
28
29
  };
29
30
  const zh = {
30
31
  fast: 'Fast',
@@ -36,13 +37,14 @@ const zh = {
36
37
  contextSizeDescription: '设置下次请求的上下文容量,单位为 K tokens;上方是当前请求。',
37
38
  contextRestore: '恢复模型默认值',
38
39
  activityCompacting: '正在压缩上下文...',
39
- activityAwaitingModel: '正在等待模型响应...',
40
+ activityRequestingModel: '请求模型...',
41
+ activityModelReply: '模型回复...',
40
42
  };
41
43
  function FastModeButton({ sessionId, useSessions, useSettings, setSetting, t }) {
42
44
  const agentPreset = useSessions(state => state.byId[sessionId]?.agentPreset);
43
45
  const snapshot = useSettings(state => state);
44
46
  const fast = snapshot.value?.fast ?? false;
45
- if (agentPreset !== CODEX_PRESET_ID)
47
+ if (!isCodexPresetId(agentPreset))
46
48
  return null;
47
49
  return (_jsxs("button", { type: "button", role: "menuitemcheckbox", disabled: snapshot.writable === false, "aria-pressed": fast, "aria-label": fast ? t('fastOn') : t('fastOff'), title: fast ? t('fastOn') : t('fastOff'), onClick: () => { void setSetting('fast', !fast); }, style: {
48
50
  boxSizing: 'border-box',
@@ -70,7 +72,7 @@ function FastModeFallback(props) {
70
72
  const agentPreset = useSessions(state => state.byId[sessionId]?.agentPreset);
71
73
  const snapshot = useSettings(state => state);
72
74
  const fast = snapshot.value?.fast ?? false;
73
- if (agentPreset !== CODEX_PRESET_ID)
75
+ if (!isCodexPresetId(agentPreset))
74
76
  return null;
75
77
  return (_jsxs("button", { type: "button", role: "switch", disabled: snapshot.writable === false, "aria-checked": fast, "aria-label": fast ? t('fastOn') : t('fastOff'), title: fast ? t('fastOn') : t('fastOff'), onClick: () => { void setSetting('fast', !fast); }, style: {
76
78
  boxSizing: 'border-box',
@@ -143,28 +145,36 @@ export function ActivityLine({ sessionId, useSession, useSessions, useProjection
143
145
  // Keep the projection seam optional: this package must still load when the
144
146
  // host has no session-projection registry or has not carried this key.
145
147
  const projectedActivity = useProjection('codexActivity');
146
- // The projection is the authoritative clock. The session snapshot fallback
147
- // only covers the short wire gap before the next projection frame arrives;
148
- // running tool calls and streamed output intentionally remain silent.
149
- const fallbackStartedAt = useSession((snapshot) => awaitingModelStartedAt(snapshot));
148
+ // The projection is the authoritative phase and clock. The session snapshot
149
+ // fallback only covers the short wire gap before the next projection frame;
150
+ // tool calls intentionally remain silent because their cards own the status.
151
+ const fallbackActivity = useSession((snapshot) => modelActivity(snapshot));
150
152
  // `undefined` means the key has not arrived yet; `null` is an authoritative
151
153
  // clear and must not be resurrected by the local snapshot fallback.
152
- const activity = projectedActivity !== undefined ? projectedActivity : (agentPreset === CODEX_PRESET_ID && fallbackStartedAt !== undefined
153
- ? { activity: 'awaiting-model', startedAt: fallbackStartedAt }
154
+ const normalizedProjectedActivity = projectedActivity?.activity === 'awaiting-model'
155
+ ? {
156
+ activity: fallbackActivity?.activity ?? 'requesting-model',
157
+ startedAt: fallbackActivity?.startedAt ?? projectedActivity.startedAt,
158
+ }
159
+ : projectedActivity;
160
+ const activity = projectedActivity !== undefined ? normalizedProjectedActivity : (isCodexPresetId(agentPreset) && fallbackActivity !== undefined
161
+ ? fallbackActivity
154
162
  : null);
155
163
  const [now, setNow] = useState(() => Date.now());
156
164
  useEffect(() => {
157
- if (agentPreset !== CODEX_PRESET_ID || activity === undefined || activity === null)
165
+ if (!isCodexPresetId(agentPreset) || activity === undefined || activity === null)
158
166
  return undefined;
159
167
  setNow(Date.now());
160
168
  const timer = setInterval(() => { setNow(Date.now()); }, 1_000);
161
169
  return () => clearInterval(timer);
162
- }, [agentPreset, activity?.startedAt]);
163
- if (agentPreset !== CODEX_PRESET_ID || activity === undefined || activity === null)
170
+ }, [agentPreset, activity?.activity, activity?.startedAt]);
171
+ if (!isCodexPresetId(agentPreset) || activity === undefined || activity === null)
164
172
  return null;
165
- const label = activity.activity === 'awaiting-model'
166
- ? t('activityAwaitingModel')
167
- : t('activityCompacting');
173
+ const label = activity.activity === 'compaction'
174
+ ? t('activityCompacting')
175
+ : activity.activity === 'model-reply'
176
+ ? t('activityModelReply')
177
+ : t('activityRequestingModel');
168
178
  return (_jsxs("span", { id: "codex-activity", role: "status", "aria-live": "polite", style: {
169
179
  display: 'inline-flex',
170
180
  alignItems: 'center',
@@ -2,4 +2,7 @@
2
2
  export declare const CODEX_CONTEXT_UNIT = 1000;
3
3
  export declare const CODEX_CONTEXT_MAX = 1000000;
4
4
  export declare const CODEX_PRESET_ID = "codex";
5
+ export declare const CODEX_COLLABORATION_PRESET_ID = "codex-collaboration";
6
+ /** Both shipped presets use the same Codex request controls in Web. */
7
+ export declare function isCodexPresetId(value: string | undefined): boolean;
5
8
  //# sourceMappingURL=context.d.ts.map
@@ -2,4 +2,13 @@
2
2
  export const CODEX_CONTEXT_UNIT = 1_000;
3
3
  export const CODEX_CONTEXT_MAX = 1_000_000;
4
4
  export const CODEX_PRESET_ID = 'codex';
5
+ export const CODEX_COLLABORATION_PRESET_ID = 'codex-collaboration';
6
+ const CODEX_PRESET_IDS = new Set([
7
+ CODEX_PRESET_ID,
8
+ CODEX_COLLABORATION_PRESET_ID,
9
+ ]);
10
+ /** Both shipped presets use the same Codex request controls in Web. */
11
+ export function isCodexPresetId(value) {
12
+ return value !== undefined && CODEX_PRESET_IDS.has(value);
13
+ }
5
14
  //# sourceMappingURL=context.js.map
@@ -29,7 +29,7 @@ export interface ExecResult {
29
29
  exit_code?: number;
30
30
  original_token_count?: number;
31
31
  }
32
- export declare function normalizeWaitMs(value: number | undefined, fallback: number, empty?: boolean): number;
32
+ export declare function normalizeWaitMs(value: number | undefined, fallback: number, empty?: boolean, initialExec?: boolean): number;
33
33
  /** Render the ordinary Responses tool result text used by Codex's unified exec tools. */
34
34
  export declare function renderExecResult(result: ExecResult): string;
35
35
  /** Execute one Codex command through the configured pipe or PTY capability. */
package/lib/types/exec.js CHANGED
@@ -3,6 +3,7 @@ import { randomBytes } from 'node:crypto';
3
3
  import { resolve as resolvePath } from 'node:path';
4
4
  const STATES = new WeakMap();
5
5
  const MIN_YIELD_TIME_MS = 250;
6
+ const WINDOWS_INITIAL_EXEC_YIELD_TIME_MS = 10_000;
6
7
  const MIN_EMPTY_YIELD_TIME_MS = 5_000;
7
8
  const MAX_YIELD_TIME_MS = 30_000;
8
9
  const MAX_EMPTY_YIELD_TIME_MS = 300_000;
@@ -21,8 +22,12 @@ function positiveFinite(name, value) {
21
22
  throw new Error(`${name} must be a non-negative finite number`);
22
23
  }
23
24
  }
24
- export function normalizeWaitMs(value, fallback, empty = false) {
25
- const lower = empty ? MIN_EMPTY_YIELD_TIME_MS : MIN_YIELD_TIME_MS;
25
+ export function normalizeWaitMs(value, fallback, empty = false, initialExec = false) {
26
+ const lower = empty
27
+ ? MIN_EMPTY_YIELD_TIME_MS
28
+ : initialExec && process.platform === 'win32'
29
+ ? WINDOWS_INITIAL_EXEC_YIELD_TIME_MS
30
+ : MIN_YIELD_TIME_MS;
26
31
  const upper = empty ? MAX_EMPTY_YIELD_TIME_MS : MAX_YIELD_TIME_MS;
27
32
  return Math.max(lower, Math.min(upper, Math.trunc(value ?? fallback)));
28
33
  }
@@ -178,7 +183,7 @@ export async function runExecCommand(ctx, args, exec, config) {
178
183
  const sendRequest = {
179
184
  text: commandFor(args),
180
185
  submit: true,
181
- waitMs: normalizeWaitMs(args.yield_time_ms, config.defaultYieldTimeMs),
186
+ waitMs: normalizeWaitMs(args.yield_time_ms, config.defaultYieldTimeMs, false, true),
182
187
  signal: exec.signal,
183
188
  };
184
189
  const operation = terminals.startSend(agent, spawned.sessionId, sendRequest);
@@ -250,7 +255,7 @@ export async function runExecCommand(ctx, args, exec, config) {
250
255
  signal: exec.signal,
251
256
  }));
252
257
  try {
253
- await waitForShell(process, normalizeWaitMs(args.yield_time_ms, config.defaultYieldTimeMs), exec.signal);
258
+ await waitForShell(process, normalizeWaitMs(args.yield_time_ms, config.defaultYieldTimeMs, false, true), exec.signal);
254
259
  const output = readShellOutput(process.readOutput(), maxBytes);
255
260
  if (process.status === 'running') {
256
261
  if (exec.agent === undefined) {
@@ -9,12 +9,13 @@ import { hostedWebSearchStream, installHostedWebSearch, remoteCompactStream } fr
9
9
  import { registerCodexActivityProjection } from "./activity.js";
10
10
  export const name = 'codex';
11
11
  export const inject = ['tools', 'systemPrompt', 'shell', 'fs', 'llm', 'credentials', 'settings'];
12
+ const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024;
12
13
  /** Runtime configuration schema for the Codex tool bridge. */
13
14
  export const Config = z.object({
14
15
  defaultYieldTimeMs: z.number().step(1).min(0).default(10_000),
15
16
  pollYieldTimeMs: z.number().step(1).min(0).default(5_000),
16
17
  writeYieldTimeMs: z.number().step(1).min(0).default(250),
17
- maxOutputBytes: z.number().step(1).min(1).default(64_000),
18
+ maxOutputBytes: z.number().step(1).min(1).default(DEFAULT_MAX_OUTPUT_BYTES),
18
19
  hostedWebSearch: z.boolean().default(true),
19
20
  remoteCompact: z.boolean().default(true),
20
21
  collaborationPrompt: z.boolean().default(false),
@@ -451,7 +452,7 @@ export function apply(ctx, config = {}) {
451
452
  defaultYieldTimeMs: config.defaultYieldTimeMs ?? 10_000,
452
453
  pollYieldTimeMs: config.pollYieldTimeMs ?? 5_000,
453
454
  writeYieldTimeMs: config.writeYieldTimeMs ?? 250,
454
- maxOutputBytes: config.maxOutputBytes ?? 64_000,
455
+ maxOutputBytes: config.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
455
456
  hostedWebSearch: config.hostedWebSearch ?? true,
456
457
  remoteCompact: config.remoteCompact ?? true,
457
458
  collaborationPrompt: config.collaborationPrompt ?? false,
@@ -13,6 +13,8 @@ export declare const name = "codex-preset-installer";
13
13
  * @param sourceDir - directory containing the packaged preset files.
14
14
  */
15
15
  export declare function installCodexPreset(targetDir?: string, sourceDir?: string): void;
16
+ /** Install the shipped Codex preset with the optional collaboration guidance enabled. */
17
+ export declare function installCodexCollaborationPreset(targetDir?: string, sourceDir?: string): void;
16
18
  /** Install the preset during profile boot without changing the host tool catalog. */
17
19
  export declare function apply(ctx: Context): void;
18
20
  declare const _default: {
@@ -3,9 +3,9 @@ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, renameSync, rmSync }
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- const PRESET_ID = 'codex';
7
6
  const PRESET_FILES = ['agent.cordis.yml', 'preset.yml'];
8
7
  const SOURCE_PRESET_DIR = fileURLToPath(new URL('../presets/codex/', import.meta.url));
8
+ const SOURCE_COLLABORATION_PRESET_DIR = fileURLToPath(new URL('../presets/codex-collaboration/', import.meta.url));
9
9
  function dshHomePath(...segments) {
10
10
  const configured = process.env.DSH_HOME?.trim();
11
11
  const expanded = configured === undefined || configured.length === 0
@@ -19,22 +19,12 @@ function dshHomePath(...segments) {
19
19
  }
20
20
  /** Bundle plugin name for the preset installer. */
21
21
  export const name = 'codex-preset-installer';
22
- /**
23
- * Install the shipped Codex preset only when the user has not authored one.
24
- *
25
- * The directory is committed with a staging rename so a failed copy cannot
26
- * leave a half-written preset that hides the mode from the roster. Existing
27
- * directories are intentionally preserved, including user customizations.
28
- *
29
- * @param targetDir - destination preset directory.
30
- * @param sourceDir - directory containing the packaged preset files.
31
- */
32
- export function installCodexPreset(targetDir = dshHomePath('.agent-presets', PRESET_ID), sourceDir = SOURCE_PRESET_DIR) {
22
+ function installPreset(targetDir, sourceDir, presetId) {
33
23
  if (existsSync(targetDir))
34
24
  return;
35
25
  const parentDir = dirname(targetDir);
36
26
  mkdirSync(parentDir, { recursive: true });
37
- const stagingDir = mkdtempSync(join(parentDir, `.${PRESET_ID}-`));
27
+ const stagingDir = mkdtempSync(join(parentDir, `.${presetId}-`));
38
28
  try {
39
29
  for (const file of PRESET_FILES)
40
30
  copyFileSync(join(sourceDir, file), join(stagingDir, file));
@@ -51,13 +41,31 @@ export function installCodexPreset(targetDir = dshHomePath('.agent-presets', PRE
51
41
  rmSync(stagingDir, { recursive: true, force: true });
52
42
  }
53
43
  }
44
+ /**
45
+ * Install the shipped Codex preset only when the user has not authored one.
46
+ *
47
+ * The directory is committed with a staging rename so a failed copy cannot
48
+ * leave a half-written preset that hides the mode from the roster. Existing
49
+ * directories are intentionally preserved, including user customizations.
50
+ *
51
+ * @param targetDir - destination preset directory.
52
+ * @param sourceDir - directory containing the packaged preset files.
53
+ */
54
+ export function installCodexPreset(targetDir = dshHomePath('.agent-presets', 'codex'), sourceDir = SOURCE_PRESET_DIR) {
55
+ installPreset(targetDir, sourceDir, 'codex');
56
+ }
57
+ /** Install the shipped Codex preset with the optional collaboration guidance enabled. */
58
+ export function installCodexCollaborationPreset(targetDir = dshHomePath('.agent-presets', 'codex-collaboration'), sourceDir = SOURCE_COLLABORATION_PRESET_DIR) {
59
+ installPreset(targetDir, sourceDir, 'codex-collaboration');
60
+ }
54
61
  /** Install the preset during profile boot without changing the host tool catalog. */
55
62
  export function apply(ctx) {
56
63
  try {
57
64
  installCodexPreset();
65
+ installCodexCollaborationPreset();
58
66
  }
59
67
  catch (error) {
60
- ctx.logger.warn(`dsh-codex: could not install the Codex preset: ${String(error)}`);
68
+ ctx.logger.warn(`dsh-codex: could not install the Codex presets: ${String(error)}`);
61
69
  }
62
70
  }
63
71
  export default { name, apply };
@@ -1,9 +1,8 @@
1
1
  /** Parser and line-oriented applicator for Codex's `apply_patch` language. */
2
2
  export const APPLY_PATCH_DESCRIPTION = 'Edits files using Codex patch syntax with Begin/End Patch markers and file update directives. In hunk lines, the first character is the operation marker; repeat a source-leading marker when the source line itself starts with one.';
3
3
  /** The grammar sent to providers that support OpenAI custom grammar tools. */
4
- export const APPLY_PATCH_GRAMMAR = String.raw `start: begin_patch environment_id? hunk+ end_patch
4
+ export const APPLY_PATCH_GRAMMAR = String.raw `start: begin_patch hunk+ end_patch
5
5
  begin_patch: "*** Begin Patch" LF
6
- environment_id: "*** Environment ID: " filename LF
7
6
  end_patch: "*** End Patch" LF?
8
7
  hunk: add_hunk | delete_hunk | update_hunk
9
8
  add_hunk: "*** Add File: " filename LF add_line+
@@ -27,7 +26,6 @@ function isFileHeader(line) {
27
26
  || trimmed.startsWith('*** Delete File: ')
28
27
  || trimmed.startsWith('*** Update File: ');
29
28
  }
30
- const ENVIRONMENT_ID_MARKER = '*** Environment ID:';
31
29
  function unwrapHeredoc(input) {
32
30
  const normalized = input.replaceAll('\r\n', '\n').trim();
33
31
  const lines = normalized.split('\n');
@@ -63,14 +61,8 @@ export function parsePatch(input) {
63
61
  const files = [];
64
62
  let index = 1;
65
63
  const end = lines.length - 1;
66
- if (lines[index]?.trim().startsWith(ENVIRONMENT_ID_MARKER) === true) {
67
- const environmentId = lines[index].trim().slice(ENVIRONMENT_ID_MARKER.length).trim();
68
- if (environmentId.length === 0)
69
- invalid('environment id cannot be empty');
70
- index++;
71
- if (lines[index]?.trim().startsWith(ENVIRONMENT_ID_MARKER) === true) {
72
- invalid('environment id cannot be specified more than once');
73
- }
64
+ if (lines[index]?.trim().startsWith('*** Environment ID:') === true) {
65
+ invalid('environment selection is unavailable in this single-environment DSH plugin');
74
66
  }
75
67
  while (index < end) {
76
68
  const header = lines[index++]?.trim();
@@ -437,6 +437,36 @@ function compactText(body) {
437
437
  const encrypted = text.join('\n\n').trim();
438
438
  return encrypted.length === 0 ? '' : `${REMOTE_COMPACTION_OPEN}${encrypted}${REMOTE_COMPACTION_CLOSE}`;
439
439
  }
440
+ function nonNegativeInteger(value) {
441
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
442
+ }
443
+ /** Map the Responses compact usage object without inventing missing counts. */
444
+ function compactUsage(body) {
445
+ if (typeof body.usage !== 'object' || body.usage === null)
446
+ return undefined;
447
+ const raw = body.usage;
448
+ const inputTokens = nonNegativeInteger(raw.input_tokens);
449
+ const outputTokens = nonNegativeInteger(raw.output_tokens);
450
+ if (inputTokens === undefined || outputTokens === undefined)
451
+ return undefined;
452
+ const details = typeof raw.input_tokens_details === 'object' && raw.input_tokens_details !== null
453
+ ? raw.input_tokens_details
454
+ : undefined;
455
+ const cacheReadTokens = nonNegativeInteger(details?.cached_tokens);
456
+ const cacheWriteTokens = nonNegativeInteger(details?.cache_write_tokens);
457
+ const cached = cacheReadTokens ?? 0;
458
+ const writes = cacheWriteTokens ?? 0;
459
+ // Responses input_tokens includes the cache buckets. Reject an inconsistent
460
+ // response instead of clamping it and reporting numbers that no longer sum.
461
+ if (cached + writes > inputTokens)
462
+ return undefined;
463
+ return {
464
+ inputTokens: inputTokens - cached - writes,
465
+ outputTokens,
466
+ ...cacheReadTokens !== undefined && cacheReadTokens > 0 ? { cacheReadTokens } : {},
467
+ ...cacheWriteTokens !== undefined && cacheWriteTokens > 0 ? { cacheWriteTokens } : {},
468
+ };
469
+ }
440
470
  async function remoteCompact(ctx, options) {
441
471
  const profile = profileOf(ctx, options.provider);
442
472
  if (!supportsResponses(profile, options.provider) || profile?.baseURL === undefined) {
@@ -457,17 +487,19 @@ async function remoteCompact(ctx, options) {
457
487
  const text = compactText(body);
458
488
  if (text.length === 0)
459
489
  throw new Error('remote compaction returned no compaction text');
460
- return text;
490
+ const usage = compactUsage(body);
491
+ return { text, ...usage === undefined ? {} : { usage } };
461
492
  }
462
493
  /** Remote-first compaction waterfall with the existing DSH path as fallback. */
463
494
  export function remoteCompactStream(ctx, options, next) {
464
495
  return (async function* () {
465
496
  try {
466
- const text = await remoteCompact(ctx, options);
497
+ const result = await remoteCompact(ctx, options);
467
498
  yield { type: 'block-start', index: 0, blockType: 'text' };
468
- yield { type: 'text-delta', index: 0, text };
469
- yield { type: 'block-end', index: 0, block: { type: 'text', text } };
470
- yield { type: 'usage', usage: { inputTokens: 0, outputTokens: 0 } };
499
+ yield { type: 'text-delta', index: 0, text: result.text };
500
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: result.text } };
501
+ if (result.usage !== undefined)
502
+ yield { type: 'usage', usage: result.usage };
471
503
  yield { type: 'finish', reason: { kind: 'stop' } };
472
504
  }
473
505
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shuind/dsh-codex-harness",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "description": "A Codex harness with a streamlined system prompt for GPT models that do not fit DSH's native interface, while remaining compatible with the DSH plugin ecosystem.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -35,6 +35,7 @@
35
35
  "lib/client.js",
36
36
  "cordis.patch.yml",
37
37
  "presets/codex",
38
+ "presets/codex-collaboration",
38
39
  "README.md",
39
40
  "README.zh.md",
40
41
  "LICENSE"
@@ -0,0 +1,91 @@
1
+ # The Codex collaboration preset. It shares the standard Codex composition
2
+ # while enabling the optional collaboration guidance in the prompt layer.
3
+
4
+ - id: persona
5
+ name: '@deepseek-ai/dsh-persona'
6
+ config:
7
+ # Keep identity, model, cwd, and runtime context in one persona section.
8
+ text: >-
9
+ You are Codex, a coding agent based on the {{model}} model. Your working directory is {{cwd}}.
10
+ includeRuntimeContext: true
11
+
12
+ - id: agent-instructions
13
+ name: '@deepseek-ai/dsh-agent-instructions'
14
+ config:
15
+ maxBytes: 65536
16
+
17
+ # The collaboration variant only changes this flag; execution remains
18
+ # delegated to the same dsh Codex tool layer.
19
+ - id: codex-tools
20
+ name: '@shuind/dsh-codex-harness'
21
+ config:
22
+ collaborationPrompt: true
23
+
24
+ # Background jobs use the host-plane registry. A busy agent receives a
25
+ # completion in its next step; an idle agent keeps it pending until the next
26
+ # user/tool wake, so finishing work never opens an unsolicited model turn.
27
+ - id: codex-jobs
28
+ name: '@deepseek-ai/dsh-tool-jobs'
29
+ config:
30
+ completionDelivery: quiet
31
+
32
+ # The generic DSH pi-ai adapter still owns the user's configured provider,
33
+ # endpoint, API key, and model. Codex's remote-first transport wrapper is
34
+ # installed only in this Codex scope and falls back to the generic DSH path.
35
+
36
+ # Keep the local tool mounted as the fallback. Codex rewrites the final GPT
37
+ # Responses request to a hosted web_search tool first; a failed remote request
38
+ # is retried through this unchanged local function-tool path.
39
+ - id: codex-web-search
40
+ name: '@deepseek-ai/dsh-tool-web'
41
+ config:
42
+ search: true
43
+ fetch: false
44
+
45
+ # PTY execution is optional. Pipe-backed exec_command remains available on all
46
+ # platforms through the shell capability consumed by the Codex tool layer.
47
+ - id: codex-terminal
48
+ name: cordis:group
49
+ group: true
50
+ isolate:
51
+ terminals: true
52
+ config:
53
+ - id: terminals
54
+ name: '@deepseek-ai/dsh-terminal'
55
+
56
+ - id: terminal-bash
57
+ name: '@deepseek-ai/dsh-terminal-bash'
58
+ disabled: !!js process.platform === 'win32'
59
+ config:
60
+ timeoutMs: 300000
61
+
62
+ # Context compaction is agent-scoped in Web. The token meter stays on the host
63
+ # plane, while this group owns the backend, `/compact`, and optional tool-result
64
+ # pruning for Codex sessions.
65
+ - id: compaction
66
+ name: cordis:group
67
+ group: true
68
+ isolate:
69
+ compaction: true
70
+ toolResultPruner: true
71
+ config:
72
+ - id: compaction-basic
73
+ name: '@deepseek-ai/dsh-compaction-basic'
74
+
75
+ - id: command-compact
76
+ name: '@deepseek-ai/dsh-command-compact'
77
+
78
+ - id: tool-result-pruner
79
+ name: '@deepseek-ai/dsh-compaction-tool-result-pruner'
80
+ config:
81
+ thresholdChars: 8192
82
+ headChars: 4096
83
+ tailChars: 1024
84
+
85
+ # Skills are the dsh extensibility seam and do not change the four Codex core
86
+ # tool names.
87
+ - id: skill-filesystem
88
+ name: '@deepseek-ai/dsh-skill-filesystem'
89
+
90
+ - id: tool-skill
91
+ name: '@deepseek-ai/dsh-tool-skill'
@@ -0,0 +1,3 @@
1
+ name: Codex 协作模式
2
+ description: 使用精简版 Codex 提示词,并加入协作提示词、统一执行工具、后台任务控制和 dsh Skills。
3
+ order: 6