@shuind/dsh-codex-harness 0.1.15 → 0.1.16

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
@@ -10,28 +10,8 @@
10
10
  - **Fast**:仅在 Codex preset 的模型选择菜单中显示,开启后向 Responses 请求发送 `service_tier: "priority"`。
11
11
  - **远程搜索与压缩**:OpenAI Responses 请求默认优先使用 hosted `web_search` 和 `/responses/compact`;失败时回退到 DSH 的本地实现。
12
12
  - **上下文容量**:在 Web 中设置 `1K`–`1000K` tokens,设置值作用于下一次请求。
13
+ - **活动状态**:Codex 等待模型响应或进行上下文压缩时,在输入框上方显示对应状态和耗时,让长时间 Deep Diving 不再像卡住。
13
14
 
14
- ## 可选协作提示词(私货)
15
-
16
- 这段提示词默认关闭,不会注入系统提示词。需要时,在自定义 `agent.cordis.yml` 中开启:
17
-
18
- ```yaml
19
- - id: codex-tools
20
- name: '@shuind/dsh-codex-harness'
21
- config:
22
- collaborationPrompt: true
23
- ```
24
-
25
- 开启后,Codex preset 会在系统提示词中注入以下协作要求:
26
-
27
- ```text
28
- Ask, align, and clarify whenever uncertainty, assumptions, tradeoffs, or decisions could materially affect the outcome.
29
- Understand the user's full picture, align it with your own, and leave no hidden assumptions or gaps.
30
- Keep only the essential logic and core actions. There's no need to explain or test what was removed or why something wasn't done.
31
- Convey enough valuable information with as few words as possible. Stay focused on the end goal.
32
- Solve problems by thinking from first principles and at a higher level.
33
- Make things as effortless as possible for the user.
34
- ```
35
15
  ## 安装
36
16
 
37
17
  ```sh
@@ -72,16 +52,68 @@ Fast 设置保存在 `codex` 命名空间,只对 Codex preset 生效。
72
52
 
73
53
  上下文容量使用整数 K tokens,范围为 `1K` 到 `1000K`。实际可用上限仍取决于模型和中转站支持情况。
74
54
 
55
+ ### 活动状态
56
+
57
+ Codex 等待模型首个输出时,Web 输入框上方会显示 `正在等待模型响应... · Ns`;开始上下文压缩后会切换为 `正在压缩上下文... · Ns`。收到模型输出、压缩结束或下一轮开始后自动隐藏。该状态由 Host 的 `sessionProjections` 投影驱动,因此本地压缩和远程 `/responses/compact` 都会显示同一种状态。
58
+
59
+ 如果当前 DSH 组合没有加载 projection 服务,插件会跳过注册,客户端也会自动隐藏这行,不影响普通会话。
60
+
61
+ ## 随包提供的 Codex preset
62
+
63
+ 安装插件后,首次启动对应的 DSH profile 时,插件会把完整 preset 从随包提供的 `presets/codex` 安装到 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/codex/`;如果你已经创建了同名 preset,插件会保留你的文件。普通用户不需要自己创建 preset。
64
+
65
+ 在 Web 的 **Agent 预设** 中选择 **Codex**,再新建会话即可使用随包提供的 Codex 工具、网页搜索、远程压缩和 Skills 组合。Fast、思考强度、GPT 图片输入和上下文容量可以在对应的设置位置配置。
66
+
75
67
  ## 自定义 preset
76
68
 
77
- 在自定义 `agent.cordis.yml` 中挂载 Codex 工具层:
69
+ 只有在想自己组合工具、提示词,或修改插件配置时才需要自定义 preset。自定义 preset 放在 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/` 目录中。
70
+
71
+ ### 示例:创建一个带协作提示词的 Codex preset
72
+
73
+ 例如创建目录 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/my-codex/`,并写入 `agent.cordis.yml`:
74
+
75
+ ```yaml
76
+ - id: persona
77
+ name: '@deepseek-ai/dsh-persona'
78
+ config:
79
+ text: 你是一个使用 DSH 工具工作的 Codex 编程助手。
80
+
81
+ - id: codex-tools
82
+ name: '@shuind/dsh-codex-harness'
83
+ config:
84
+ collaborationPrompt: true
85
+ ```
86
+
87
+ 再写入 `preset.yml`,让 Web 中显示更清晰的名称:
88
+
89
+ ```yaml
90
+ name: My Codex
91
+ description: 使用 Codex 工具并开启可选协作提示词。
92
+ order: 10
93
+ ```
94
+
95
+ 然后在 Web 的 **Agent 预设** 中选择 `My Codex`,再新建会话。这个 preset 会加载 Codex 工具,并开启协作提示词;网页搜索、远程压缩和 Skills 等其他功能需要在同一个 `agent.cordis.yml` 中按需添加。
96
+
97
+ ### 可选协作提示词(私货)
98
+
99
+ 这段提示词默认关闭,不会注入系统提示词。需要时,在自定义 `agent.cordis.yml` 中开启:
78
100
 
79
101
  ```yaml
80
102
  - id: codex-tools
81
103
  name: '@shuind/dsh-codex-harness'
104
+ config:
105
+ collaborationPrompt: true
82
106
  ```
83
107
 
84
- 如果需要保留 Codex preset 的完整工具、搜索、压缩和 Skills 组合,建议直接使用随包提供的 `presets/codex`。
108
+ 开启后,Codex preset 会在系统提示词中注入以下协作要求:
109
+
110
+ ```text
111
+ Ask, align, and clarify first. Try your best to understand the user's full picture.
112
+ Keep only the essential logic and core actions. There's no need to explain or test what was removed or why something wasn't done.
113
+ Convey enough valuable information with as few words as possible. Stay focused on the end goal.
114
+ Solve problems by thinking from first principles and at a higher level.
115
+ Make things as effortless as possible for the user.
116
+ ```
85
117
 
86
118
  ## 注意事项
87
119
 
package/lib/client.js CHANGED
@@ -398,15 +398,20 @@ window.__ModuleLoader__.load({
398
398
  exports.jsxs = q;
399
399
  }));
400
400
  //#endregion
401
- //#region lib/types/context.js
402
- var import_jsx_runtime = (/* @__PURE__ */ __commonJSMin(((exports, module) => {
401
+ //#region node_modules/.pnpm/react@18.3.1/node_modules/react/jsx-runtime.js
402
+ var require_jsx_runtime = /* @__PURE__ */ __commonJSMin(((exports, module) => {
403
403
  module.exports = require_react_jsx_runtime_production_min();
404
- })))();
404
+ }));
405
+ //#endregion
406
+ //#region lib/types/context.js
407
+ var import_react = require_react();
408
+ var import_jsx_runtime = require_jsx_runtime();
405
409
  /** User-selectable Codex context controls use whole K-token units. */
406
410
  const CODEX_CONTEXT_UNIT = 1e3;
407
411
  const CODEX_CONTEXT_MAX = 1e6;
408
412
  //#endregion
409
413
  //#region lib/types/client/index.js
414
+ /** Browser controls for the Codex request settings mounted by the Host plugin. */
410
415
  const NS = "codex";
411
416
  const SETTINGS_NAMESPACE = "codex";
412
417
  const en = {
@@ -417,7 +422,9 @@ window.__ModuleLoader__.load({
417
422
  fastStateOff: "Off",
418
423
  contextSize: "Context size",
419
424
  contextSizeDescription: "Next request capacity in K tokens. The meter above is current.",
420
- contextRestore: "Restore model default"
425
+ contextRestore: "Restore model default",
426
+ activityCompacting: "Compacting context...",
427
+ activityAwaitingModel: "Waiting for model response..."
421
428
  };
422
429
  const zh = {
423
430
  fast: "Fast",
@@ -427,7 +434,9 @@ window.__ModuleLoader__.load({
427
434
  fastStateOff: "关",
428
435
  contextSize: "上下文大小",
429
436
  contextSizeDescription: "设置下次请求的上下文容量,单位为 K tokens;上方是当前请求。",
430
- contextRestore: "恢复模型默认值"
437
+ contextRestore: "恢复模型默认值",
438
+ activityCompacting: "正在压缩上下文...",
439
+ activityAwaitingModel: "正在等待模型响应..."
431
440
  };
432
441
  function FastModeButton({ sessionId, useSessions, useSettings, setSetting, t }) {
433
442
  const agentPreset = useSessions((state) => state.byId[sessionId]?.agentPreset);
@@ -588,6 +597,60 @@ window.__ModuleLoader__.load({
588
597
  ]
589
598
  });
590
599
  }
600
+ function elapsedSeconds(startedAt, now) {
601
+ return Math.max(0, Math.floor((now - startedAt) / 1e3));
602
+ }
603
+ function ActivityLine({ sessionId, useSessions, useProjection, t }) {
604
+ const agentPreset = useSessions((state) => state.byId[sessionId]?.agentPreset);
605
+ const activity = useProjection("codexActivity");
606
+ const [now, setNow] = (0, import_react.useState)(() => Date.now());
607
+ (0, import_react.useEffect)(() => {
608
+ if (agentPreset !== "codex" || activity === void 0 || activity === null) return void 0;
609
+ setNow(Date.now());
610
+ const timer = setInterval(() => {
611
+ setNow(Date.now());
612
+ }, 1e3);
613
+ return () => clearInterval(timer);
614
+ }, [agentPreset, activity?.startedAt]);
615
+ if (agentPreset !== "codex" || activity === void 0 || activity === null) return null;
616
+ const label = activity.activity === "awaiting-model" ? t("activityAwaitingModel") : t("activityCompacting");
617
+ return (0, import_jsx_runtime.jsxs)("div", {
618
+ id: "codex-activity",
619
+ role: "status",
620
+ "aria-live": "polite",
621
+ style: {
622
+ display: "flex",
623
+ alignItems: "center",
624
+ gap: 7,
625
+ minHeight: 28,
626
+ padding: "4px 10px",
627
+ color: "var(--dsw-alias-label-secondary)",
628
+ fontSize: 12,
629
+ lineHeight: "20px"
630
+ },
631
+ children: [
632
+ (0, import_jsx_runtime.jsx)("span", {
633
+ "aria-hidden": "true",
634
+ style: {
635
+ width: 7,
636
+ height: 7,
637
+ flex: "0 0 auto",
638
+ borderRadius: "50%",
639
+ background: "var(--dsw-static-blue-500)"
640
+ }
641
+ }),
642
+ (0, import_jsx_runtime.jsx)("span", { children: label }),
643
+ (0, import_jsx_runtime.jsxs)("span", {
644
+ style: { color: "var(--dsw-alias-label-tertiary)" },
645
+ children: [
646
+ "· ",
647
+ elapsedSeconds(activity.startedAt, now),
648
+ "s"
649
+ ]
650
+ })
651
+ ]
652
+ });
653
+ }
591
654
  const inject = [
592
655
  "slots",
593
656
  "locale",
@@ -619,12 +682,19 @@ window.__ModuleLoader__.load({
619
682
  locale: NS,
620
683
  inject: injected
621
684
  }, ContextSizeControl));
685
+ ctx.slots.inject("conversation.input.dock", () => ctx.slots.register({
686
+ name: "conversation.input.dock",
687
+ id: "codex-activity",
688
+ order: 5,
689
+ locale: NS
690
+ }, ActivityLine));
622
691
  }
623
692
  var client_default = {
624
693
  inject,
625
694
  apply
626
695
  };
627
696
  //#endregion
697
+ exports.ActivityLine = ActivityLine;
628
698
  exports.apply = apply;
629
699
  exports.default = client_default;
630
700
  exports.inject = inject;
package/lib/index.js CHANGED
@@ -5,10 +5,12 @@ 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 { z as z$1 } from "zod";
8
9
  //#region lib/types/context.js
9
10
  const CODEX_CONTEXT_MAX = 1e6;
10
- String.raw`start: begin_patch hunk+ end_patch
11
+ String.raw`start: begin_patch environment_id? hunk+ end_patch
11
12
  begin_patch: "*** Begin Patch" LF
13
+ environment_id: "*** Environment ID: " filename LF
12
14
  end_patch: "*** End Patch" LF?
13
15
  hunk: add_hunk | delete_hunk | update_hunk
14
16
  add_hunk: "*** Add File: " filename LF add_line+
@@ -30,6 +32,15 @@ function isFileHeader(line) {
30
32
  const trimmed = line.trim();
31
33
  return trimmed.startsWith("*** Add File: ") || trimmed.startsWith("*** Delete File: ") || trimmed.startsWith("*** Update File: ");
32
34
  }
35
+ const ENVIRONMENT_ID_MARKER = "*** Environment ID:";
36
+ function unwrapHeredoc(input) {
37
+ const normalized = input.replaceAll("\r\n", "\n").trim();
38
+ const lines = normalized.split("\n");
39
+ const first = lines[0]?.trim();
40
+ const last = lines.at(-1)?.trim();
41
+ if (lines.length >= 4 && (first === "<<EOF" || first === "<<'EOF'" || first === "<<\"EOF\"") && last === "EOF") return lines.slice(1, -1).join("\n").trim();
42
+ return normalized;
43
+ }
33
44
  function pathFrom(line, prefix) {
34
45
  const path = line.slice(prefix.length).trim();
35
46
  if (path.length === 0) invalid(`${prefix.trim()} requires a file path`);
@@ -44,12 +55,17 @@ function isPotentialChangeMarker(line) {
44
55
  }
45
56
  /** Parse one complete Codex patch after normalizing CRLF input to LF. */
46
57
  function parsePatch(input) {
47
- const lines = input.replaceAll("\r\n", "\n").trim().split("\n");
58
+ const lines = unwrapHeredoc(input).split("\n");
48
59
  if (lines[0]?.trim() !== "*** Begin Patch") invalid("input must start with \"*** Begin Patch\"");
49
60
  if (lines.at(-1)?.trim() !== "*** End Patch") invalid("input must end with \"*** End Patch\"");
50
61
  const files = [];
51
62
  let index = 1;
52
63
  const end = lines.length - 1;
64
+ if (lines[index]?.trim().startsWith(ENVIRONMENT_ID_MARKER) === true) {
65
+ if (lines[index].trim().slice(19).trim().length === 0) invalid("environment id cannot be empty");
66
+ index++;
67
+ if (lines[index]?.trim().startsWith(ENVIRONMENT_ID_MARKER) === true) invalid("environment id cannot be specified more than once");
68
+ }
53
69
  while (index < end) {
54
70
  const header = lines[index++]?.trim();
55
71
  if (header === void 0) invalid("unexpected end of input");
@@ -183,6 +199,12 @@ function applyPatchHunks(original, hunks) {
183
199
  //#region lib/types/exec.js
184
200
  /** Codex `exec_command` and `write_stdin` execution over dsh capability seams. */
185
201
  const STATES = /* @__PURE__ */ new WeakMap();
202
+ const MIN_YIELD_TIME_MS = 250;
203
+ const MIN_EMPTY_YIELD_TIME_MS = 5e3;
204
+ const MAX_YIELD_TIME_MS = 3e4;
205
+ const MAX_EMPTY_YIELD_TIME_MS = 3e5;
206
+ const DEFAULT_OUTPUT_TOKENS = 1e4;
207
+ const APPROX_BYTES_PER_TOKEN = 4;
186
208
  function stateFor(agent) {
187
209
  const current = STATES.get(agent);
188
210
  if (current !== void 0) return current;
@@ -196,13 +218,13 @@ function stateFor(agent) {
196
218
  function positiveFinite(name, value) {
197
219
  if (value !== void 0 && (!Number.isFinite(value) || value < 0)) throw new Error(`${name} must be a non-negative finite number`);
198
220
  }
199
- function waitMs(value, fallback) {
200
- return Math.max(0, Math.min(3e4, Math.trunc(value ?? fallback)));
221
+ function normalizeWaitMs(value, fallback, empty = false) {
222
+ 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)));
201
223
  }
202
224
  function outputLimit(maxOutputBytes, maxOutputTokens) {
203
- if (maxOutputTokens === void 0) return maxOutputBytes;
204
- positiveFinite("max_output_tokens", maxOutputTokens);
205
- return Math.max(1, Math.min(maxOutputBytes, Math.trunc(maxOutputTokens * 4)));
225
+ const tokens = maxOutputTokens ?? DEFAULT_OUTPUT_TOKENS;
226
+ if (maxOutputTokens !== void 0) positiveFinite("max_output_tokens", maxOutputTokens);
227
+ return Math.max(1, Math.min(maxOutputBytes, Math.trunc(tokens * APPROX_BYTES_PER_TOKEN)));
206
228
  }
207
229
  function limitOutput(text, maxBytes) {
208
230
  if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
@@ -269,6 +291,12 @@ async function waitForShell(process, ms, signal) {
269
291
  if (signal.aborted) signal.throwIfAborted();
270
292
  return completed === true || process.status !== "running";
271
293
  }
294
+ /** Stop a pipe-backed session when the current Codex tool is cancelled. */
295
+ async function stopShellOnAbort(process, signal) {
296
+ if (!signal.aborted || process.status !== "running") return;
297
+ process.kill();
298
+ await process.done;
299
+ }
272
300
  function allocateSession(agent, session) {
273
301
  const state = stateFor(agent);
274
302
  const id = ++state.nextId;
@@ -304,21 +332,29 @@ async function runExecCommand(ctx, args, exec, config) {
304
332
  ...workdir === void 0 ? {} : { cwd: workdir }
305
333
  };
306
334
  const spawned = await terminals.spawn(agent, spawnRequest, exec.signal);
307
- const sendRequest = {
308
- text: commandFor(args),
309
- submit: true,
310
- waitMs: waitMs(args.yield_time_ms, config.defaultYieldTimeMs),
311
- signal: exec.signal
312
- };
313
- const result = await terminals.startSend(agent, spawned.sessionId, sendRequest).done;
314
- const output = terminalResult(result, maxBytes, startedAt);
315
- if (result.sessionStatus.kind === "running") output.session_id = allocateSession(agent, {
316
- kind: "terminal",
317
- owner: agent,
318
- id: spawned.sessionId
319
- });
320
- else await terminals.kill(agent, spawned.sessionId, "Codex command exited");
321
- return output;
335
+ let retained = false;
336
+ let closeReason = "Codex command failed";
337
+ try {
338
+ const sendRequest = {
339
+ text: commandFor(args),
340
+ submit: true,
341
+ waitMs: normalizeWaitMs(args.yield_time_ms, config.defaultYieldTimeMs),
342
+ signal: exec.signal
343
+ };
344
+ const result = await terminals.startSend(agent, spawned.sessionId, sendRequest).done;
345
+ const output = terminalResult(result, maxBytes, startedAt);
346
+ if (result.sessionStatus.kind === "running") {
347
+ output.session_id = allocateSession(agent, {
348
+ kind: "terminal",
349
+ owner: agent,
350
+ id: spawned.sessionId
351
+ });
352
+ retained = true;
353
+ } else closeReason = "Codex command exited";
354
+ return output;
355
+ } finally {
356
+ if (!retained) await terminals.kill(agent, spawned.sessionId, closeReason);
357
+ }
322
358
  }
323
359
  const policy = ctx.get("sandboxPolicy")?.resolve(exec.agent === void 0 ? {} : { session: exec.agent.session });
324
360
  const dshEnv = ctx.get("shellEnv")?.collect(exec);
@@ -328,35 +364,40 @@ async function runExecCommand(ctx, args, exec, config) {
328
364
  login: args.login ?? true,
329
365
  ...workdir === void 0 ? {} : { workdir },
330
366
  stdoutMaxBytes: maxBytes,
367
+ signal: exec.signal,
331
368
  ...dshEnv === void 0 ? {} : { dshEnv },
332
369
  ...policy === void 0 ? {} : { sandboxPolicy: policy }
333
370
  };
334
371
  const process = ctx.shell.start(ctx.shell.resolve(shellRequest));
335
- const completed = await waitForShell(process, waitMs(args.yield_time_ms, config.defaultYieldTimeMs), exec.signal);
336
- const output = readShellOutput(process.readOutput(), maxBytes);
337
- if (!completed || process.status === "running") {
338
- if (exec.agent === void 0) {
339
- process.kill();
340
- await process.done;
372
+ try {
373
+ await waitForShell(process, normalizeWaitMs(args.yield_time_ms, config.defaultYieldTimeMs), exec.signal);
374
+ const output = readShellOutput(process.readOutput(), maxBytes);
375
+ if (process.status === "running") {
376
+ if (exec.agent === void 0) {
377
+ process.kill();
378
+ await process.done;
379
+ return withChunkId({
380
+ wall_time_seconds: (performance.now() - startedAt) / 1e3,
381
+ output: readShellOutput(process.readOutput(), maxBytes)
382
+ });
383
+ }
341
384
  return withChunkId({
342
385
  wall_time_seconds: (performance.now() - startedAt) / 1e3,
343
- output: readShellOutput(process.readOutput(), maxBytes)
386
+ output,
387
+ session_id: allocateSession(exec.agent, {
388
+ kind: "shell",
389
+ process
390
+ })
344
391
  });
345
392
  }
346
393
  return withChunkId({
347
394
  wall_time_seconds: (performance.now() - startedAt) / 1e3,
348
395
  output,
349
- session_id: allocateSession(exec.agent, {
350
- kind: "shell",
351
- process
352
- })
396
+ ...process.exitCode === null ? {} : { exit_code: process.exitCode }
353
397
  });
398
+ } finally {
399
+ await stopShellOnAbort(process, exec.signal);
354
400
  }
355
- return withChunkId({
356
- wall_time_seconds: (performance.now() - startedAt) / 1e3,
357
- output,
358
- ...process.exitCode === null ? {} : { exit_code: process.exitCode }
359
- });
360
401
  }
361
402
  /** Poll or write to one session returned by {@link runExecCommand}. */
362
403
  async function runWriteStdin(ctx, args, exec, config) {
@@ -370,38 +411,57 @@ async function runWriteStdin(ctx, args, exec, config) {
370
411
  const chars = args.chars ?? "";
371
412
  if (session.kind === "shell") {
372
413
  if (chars.length > 0) throw new Error("this unified exec session uses pipes and does not accept stdin; rerun exec_command with tty=true");
373
- const completed = await waitForShell(session.process, waitMs(args.yield_time_ms, config.pollYieldTimeMs), exec.signal);
374
- const output = readShellOutput(session.process.readOutput(), maxBytes);
375
- if (completed && session.process.status !== "running") {
376
- forgetSession(agent, args.session_id);
414
+ try {
415
+ await waitForShell(session.process, normalizeWaitMs(args.yield_time_ms, config.pollYieldTimeMs, true), exec.signal);
416
+ const output = readShellOutput(session.process.readOutput(), maxBytes);
417
+ if (session.process.status !== "running") {
418
+ forgetSession(agent, args.session_id);
419
+ return withChunkId({
420
+ wall_time_seconds: (performance.now() - startedAt) / 1e3,
421
+ output,
422
+ ...session.process.exitCode === null ? {} : { exit_code: session.process.exitCode }
423
+ });
424
+ }
377
425
  return withChunkId({
378
426
  wall_time_seconds: (performance.now() - startedAt) / 1e3,
379
427
  output,
380
- ...session.process.exitCode === null ? {} : { exit_code: session.process.exitCode }
428
+ session_id: args.session_id
381
429
  });
430
+ } finally {
431
+ if (exec.signal.aborted) try {
432
+ await stopShellOnAbort(session.process, exec.signal);
433
+ } finally {
434
+ forgetSession(agent, args.session_id);
435
+ }
382
436
  }
383
- return withChunkId({
384
- wall_time_seconds: (performance.now() - startedAt) / 1e3,
385
- output,
386
- session_id: args.session_id
387
- });
388
437
  }
389
438
  const terminals = ctx.get("terminals");
390
439
  if (terminals === void 0) throw new Error("the dsh terminal capability is no longer available");
391
440
  const sendRequest = {
392
441
  text: chars,
393
442
  submit: false,
394
- waitMs: waitMs(args.yield_time_ms, chars.length > 0 ? config.writeYieldTimeMs : config.pollYieldTimeMs),
443
+ waitMs: normalizeWaitMs(args.yield_time_ms, chars.length > 0 ? config.writeYieldTimeMs : config.pollYieldTimeMs, chars.length === 0),
395
444
  signal: exec.signal
396
445
  };
397
- const result = await terminals.startSend(agent, session.id, sendRequest).done;
398
- const output = terminalResult(result, maxBytes, startedAt);
399
- if (result.sessionStatus.kind === "running") output.session_id = args.session_id;
400
- else {
401
- forgetSession(agent, args.session_id);
402
- await terminals.kill(agent, session.id, "Codex command exited");
446
+ let retained = false;
447
+ let closeReason = "Codex command failed";
448
+ try {
449
+ const result = await terminals.startSend(agent, session.id, sendRequest).done;
450
+ const output = terminalResult(result, maxBytes, startedAt);
451
+ if (result.sessionStatus.kind === "running") {
452
+ output.session_id = args.session_id;
453
+ retained = true;
454
+ } else {
455
+ closeReason = "Codex command exited";
456
+ forgetSession(agent, args.session_id);
457
+ }
458
+ return output;
459
+ } finally {
460
+ if (!retained) {
461
+ forgetSession(agent, args.session_id);
462
+ await terminals.kill(agent, session.id, closeReason);
463
+ }
403
464
  }
404
- return output;
405
465
  }
406
466
  //#endregion
407
467
  //#region lib/types/remote.js
@@ -707,6 +767,54 @@ function remoteCompactStream(ctx, options, next) {
707
767
  })();
708
768
  }
709
769
  //#endregion
770
+ //#region lib/types/activity.js
771
+ /** Host-side activity projection for the Codex compaction status line. */
772
+ /** Persisted and wire shape of the Codex activity value. */
773
+ const codexActivitySchema = z$1.union([
774
+ z$1.object({
775
+ activity: z$1.literal("compaction"),
776
+ startedAt: z$1.number().int().nonnegative()
777
+ }),
778
+ z$1.object({
779
+ activity: z$1.literal("awaiting-model"),
780
+ startedAt: z$1.number().int().nonnegative()
781
+ }),
782
+ z$1.null()
783
+ ]);
784
+ /** Pure event fold, exported so its reset and reference-stability rules can be tested directly. */
785
+ function applyCodexActivity(state, event) {
786
+ if (event.type === "compaction/start") return {
787
+ activity: "compaction",
788
+ startedAt: event.time
789
+ };
790
+ if (event.type === "step/start" || event.type === "llm/retry-started") return {
791
+ activity: "awaiting-model",
792
+ startedAt: event.time
793
+ };
794
+ if (event.type === "turn/start" || event.type === "turn/end" || event.type === "compaction/end") return null;
795
+ if (state?.activity === "awaiting-model" && (event.type === "llm/retry" || event.type === "assistant/chunk" || event.type === "assistant/message" || event.type === "step/end")) return null;
796
+ return state;
797
+ }
798
+ const codexActivityProjection = {
799
+ key: "codexActivity",
800
+ stateSchema: codexActivitySchema,
801
+ init: () => null,
802
+ apply: applyCodexActivity,
803
+ wire: {
804
+ viewSchema: codexActivitySchema,
805
+ view: (state) => state
806
+ },
807
+ schema: codexActivitySchema,
808
+ view: (state) => state,
809
+ stateVersion: 1
810
+ };
811
+ /** Register the optional activity unit without requiring the projection seam in every assembly. */
812
+ function registerCodexActivityProjection(ctx) {
813
+ ctx.inject(["sessionProjections"], (projectionCtx) => {
814
+ projectionCtx.sessionProjections.register(codexActivityProjection);
815
+ });
816
+ }
817
+ //#endregion
710
818
  //#region lib/types/index.js
711
819
  /** Codex-compatible prompt overlay and core tools for a dsh agent preset. */
712
820
  const name = "codex";
@@ -882,8 +990,7 @@ const CODEX_BASE_PROMPT = String.raw`You are Codex, based on {{model}}. You are
882
990
  `;
883
991
  const CODEX_COLLABORATION_PROMPT = String.raw`## Collaboration
884
992
 
885
- - Ask, align, and clarify whenever uncertainty, assumptions, tradeoffs, or decisions could materially affect the outcome.
886
- - Understand the user's full picture, align it with your own, and leave no hidden assumptions or gaps.
993
+ - Ask, align, and clarify first. Try your best to understand the user's full picture.
887
994
  - Keep only the essential logic and core actions. There's no need to explain or test what was removed or why something wasn't done.
888
995
  - Convey enough valuable information with as few words as possible. Stay focused on the end goal.
889
996
  - Solve problems by thinking from first principles and at a higher level.
@@ -1267,6 +1374,7 @@ function apply(ctx, config = {}) {
1267
1374
  };
1268
1375
  if (ctx.fs.sandboxMode !== void 0 && ctx.get("sandboxPolicy") === void 0) throw new Error("codex: a sandboxing filesystem requires ctx.sandboxPolicy");
1269
1376
  watchConfiguredGptModels(ctx);
1377
+ registerCodexActivityProjection(ctx);
1270
1378
  const codexSettings = installCodexSettings(ctx);
1271
1379
  ctx.on("agent/request", async (_payload, next) => {
1272
1380
  return applyCodexRequestSettings(await next(), codexSettings.current());
@@ -0,0 +1,6 @@
1
+ /** Pure client/host-shared type for the Codex activity projection value. */
2
+ export type CodexActivity = {
3
+ activity: 'compaction' | 'awaiting-model';
4
+ startedAt: number;
5
+ };
6
+ //# sourceMappingURL=activity-types.d.ts.map
@@ -0,0 +1,3 @@
1
+ /** Pure client/host-shared type for the Codex activity projection value. */
2
+ export {};
3
+ //# sourceMappingURL=activity-types.js.map
@@ -0,0 +1,32 @@
1
+ /** Host-side activity projection for the Codex compaction status line. */
2
+ import type { Context } from '@deepseek-ai/cordis';
3
+ import { z, type ZodType } from 'zod';
4
+ import type { CodexActivity } from './activity-types.ts';
5
+ export type { CodexActivity } from './activity-types.ts';
6
+ /** The only long-running Codex activity currently surfaced to the client. */
7
+ export type CodexActivityState = CodexActivity | null;
8
+ /** Minimal event shape needed by this projection; no compaction package import is required. */
9
+ export interface CodexActivityEvent {
10
+ type: string;
11
+ time: number;
12
+ }
13
+ /** Persisted and wire shape of the Codex activity value. */
14
+ export declare const codexActivitySchema: ZodType<CodexActivityState>;
15
+ /** Pure event fold, exported so its reset and reference-stability rules can be tested directly. */
16
+ export declare function applyCodexActivity(state: CodexActivityState, event: CodexActivityEvent): CodexActivityState;
17
+ export declare const codexActivityProjection: {
18
+ readonly key: "codexActivity";
19
+ readonly stateSchema: z.ZodType<CodexActivityState, unknown, z.core.$ZodTypeInternals<CodexActivityState, unknown>>;
20
+ readonly init: () => CodexActivityState;
21
+ readonly apply: typeof applyCodexActivity;
22
+ readonly wire: {
23
+ readonly viewSchema: z.ZodType<CodexActivityState, unknown, z.core.$ZodTypeInternals<CodexActivityState, unknown>>;
24
+ readonly view: (state: CodexActivityState) => CodexActivityState;
25
+ };
26
+ readonly schema: z.ZodType<CodexActivityState, unknown, z.core.$ZodTypeInternals<CodexActivityState, unknown>>;
27
+ readonly view: (state: CodexActivityState) => CodexActivityState;
28
+ readonly stateVersion: 1;
29
+ };
30
+ /** Register the optional activity unit without requiring the projection seam in every assembly. */
31
+ export declare function registerCodexActivityProjection(ctx: Context): void;
32
+ //# sourceMappingURL=activity.d.ts.map
@@ -0,0 +1,53 @@
1
+ /** Host-side activity projection for the Codex compaction status line. */
2
+ import { z } from 'zod';
3
+ /** Persisted and wire shape of the Codex activity value. */
4
+ export const codexActivitySchema = z.union([
5
+ z.object({
6
+ activity: z.literal('compaction'),
7
+ startedAt: z.number().int().nonnegative(),
8
+ }),
9
+ z.object({
10
+ activity: z.literal('awaiting-model'),
11
+ startedAt: z.number().int().nonnegative(),
12
+ }),
13
+ z.null(),
14
+ ]);
15
+ /** Pure event fold, exported so its reset and reference-stability rules can be tested directly. */
16
+ export function applyCodexActivity(state, event) {
17
+ if (event.type === 'compaction/start')
18
+ return { activity: 'compaction', startedAt: event.time };
19
+ if (event.type === 'step/start' || event.type === 'llm/retry-started') {
20
+ return { activity: 'awaiting-model', startedAt: event.time };
21
+ }
22
+ if (event.type === 'turn/start' || event.type === 'turn/end' || event.type === 'compaction/end')
23
+ return null;
24
+ if (state?.activity === 'awaiting-model'
25
+ && (event.type === 'llm/retry'
26
+ || event.type === 'assistant/chunk'
27
+ || event.type === 'assistant/message'
28
+ || event.type === 'step/end'))
29
+ return null;
30
+ return state;
31
+ }
32
+ export const codexActivityProjection = {
33
+ key: 'codexActivity',
34
+ stateSchema: codexActivitySchema,
35
+ init: () => null,
36
+ apply: applyCodexActivity,
37
+ wire: {
38
+ viewSchema: codexActivitySchema,
39
+ view: (state) => state,
40
+ },
41
+ // Keep the old projection contract working for DSH bundles that predate
42
+ // the `stateSchema`/`wire` split.
43
+ schema: codexActivitySchema,
44
+ view: (state) => state,
45
+ stateVersion: 1,
46
+ };
47
+ /** Register the optional activity unit without requiring the projection seam in every assembly. */
48
+ export function registerCodexActivityProjection(ctx) {
49
+ ctx.inject(['sessionProjections'], (projectionCtx) => {
50
+ projectionCtx.sessionProjections.register(codexActivityProjection);
51
+ });
52
+ }
53
+ //# sourceMappingURL=activity.js.map
@@ -1,4 +1,5 @@
1
1
  /** Browser controls for the Codex request settings mounted by the Host plugin. */
2
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
2
3
  import type { Context } from '@deepseek-ai/cordis';
3
4
  declare const en: {
4
5
  readonly fast: "Fast mode";
@@ -9,6 +10,8 @@ declare const en: {
9
10
  readonly contextSize: "Context size";
10
11
  readonly contextSizeDescription: "Next request capacity in K tokens. The meter above is current.";
11
12
  readonly contextRestore: "Restore model default";
13
+ readonly activityCompacting: "Compacting context...";
14
+ readonly activityAwaitingModel: "Waiting for model response...";
12
15
  };
13
16
  type CodexKey = keyof typeof en;
14
17
  declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -29,6 +32,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
29
32
  };
30
33
  }
31
34
  }
35
+ type ActivityProps = PropsRuntime<'conversation.input.dock'> & PropsLocale<'codex'>;
36
+ export declare function ActivityLine({ sessionId, useSessions, useProjection, t }: ActivityProps): import("react").JSX.Element | null;
32
37
  export declare const inject: string[];
33
38
  /** Mount Fast inside the model selector and context size inside the meter panel. */
34
39
  export declare function apply(ctx: Context): void;
@@ -1,4 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /** Browser controls for the Codex request settings mounted by the Host plugin. */
3
+ import { useEffect, useState } from 'react';
2
4
  import { CODEX_CONTEXT_MAX, CODEX_CONTEXT_UNIT, CODEX_PRESET_ID } from "../context.js";
3
5
  const NS = 'codex';
4
6
  const SETTINGS_NAMESPACE = 'codex';
@@ -11,6 +13,8 @@ const en = {
11
13
  contextSize: 'Context size',
12
14
  contextSizeDescription: 'Next request capacity in K tokens. The meter above is current.',
13
15
  contextRestore: 'Restore model default',
16
+ activityCompacting: 'Compacting context...',
17
+ activityAwaitingModel: 'Waiting for model response...',
14
18
  };
15
19
  const zh = {
16
20
  fast: 'Fast',
@@ -21,6 +25,8 @@ const zh = {
21
25
  contextSize: '上下文大小',
22
26
  contextSizeDescription: '设置下次请求的上下文容量,单位为 K tokens;上方是当前请求。',
23
27
  contextRestore: '恢复模型默认值',
28
+ activityCompacting: '正在压缩上下文...',
29
+ activityAwaitingModel: '正在等待模型响应...',
24
30
  };
25
31
  function FastModeButton({ sessionId, useSessions, useSettings, setSetting, t }) {
26
32
  const agentPreset = useSessions(state => state.byId[sessionId]?.agentPreset);
@@ -83,6 +89,44 @@ function ContextSizeControl({ contextWindow, useSettings, setSetting, unsetSetti
83
89
  fontSize: 11,
84
90
  }, children: t('contextRestore') }))] }));
85
91
  }
92
+ function elapsedSeconds(startedAt, now) {
93
+ return Math.max(0, Math.floor((now - startedAt) / 1_000));
94
+ }
95
+ export function ActivityLine({ sessionId, useSessions, useProjection, t }) {
96
+ const agentPreset = useSessions(state => state.byId[sessionId]?.agentPreset);
97
+ // Keep the projection seam optional: this package must still load when the
98
+ // host has no session-projection registry or has not carried this key.
99
+ const activity = useProjection('codexActivity');
100
+ const [now, setNow] = useState(() => Date.now());
101
+ useEffect(() => {
102
+ if (agentPreset !== CODEX_PRESET_ID || activity === undefined || activity === null)
103
+ return undefined;
104
+ setNow(Date.now());
105
+ const timer = setInterval(() => { setNow(Date.now()); }, 1_000);
106
+ return () => clearInterval(timer);
107
+ }, [agentPreset, activity?.startedAt]);
108
+ if (agentPreset !== CODEX_PRESET_ID || activity === undefined || activity === null)
109
+ return null;
110
+ const label = activity.activity === 'awaiting-model'
111
+ ? t('activityAwaitingModel')
112
+ : t('activityCompacting');
113
+ return (_jsxs("div", { id: "codex-activity", role: "status", "aria-live": "polite", style: {
114
+ display: 'flex',
115
+ alignItems: 'center',
116
+ gap: 7,
117
+ minHeight: 28,
118
+ padding: '4px 10px',
119
+ color: 'var(--dsw-alias-label-secondary)',
120
+ fontSize: 12,
121
+ lineHeight: '20px',
122
+ }, children: [_jsx("span", { "aria-hidden": "true", style: {
123
+ width: 7,
124
+ height: 7,
125
+ flex: '0 0 auto',
126
+ borderRadius: '50%',
127
+ background: 'var(--dsw-static-blue-500)',
128
+ } }), _jsx("span", { children: label }), _jsxs("span", { style: { color: 'var(--dsw-alias-label-tertiary)' }, children: ["\u00B7 ", elapsedSeconds(activity.startedAt, now), "s"] })] }));
129
+ }
86
130
  export const inject = ['slots', 'locale', 'settingsScope'];
87
131
  /** Mount Fast inside the model selector and context size inside the meter panel. */
88
132
  export function apply(ctx) {
@@ -107,6 +151,12 @@ export function apply(ctx) {
107
151
  locale: NS,
108
152
  inject: injected,
109
153
  }, ContextSizeControl));
154
+ ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
155
+ name: 'conversation.input.dock',
156
+ id: 'codex-activity',
157
+ order: 5,
158
+ locale: NS,
159
+ }, ActivityLine));
110
160
  }
111
161
  export default { inject, apply };
112
162
  //# sourceMappingURL=index.js.map
@@ -27,6 +27,7 @@ export interface ExecResult {
27
27
  exit_code?: number;
28
28
  original_token_count?: number;
29
29
  }
30
+ export declare function normalizeWaitMs(value: number | undefined, fallback: number, empty?: boolean): number;
30
31
  /** Render the ordinary Responses tool result text used by Codex's unified exec tools. */
31
32
  export declare function renderExecResult(result: ExecResult): string;
32
33
  /** Execute one Codex command through the configured pipe or PTY capability. */
package/lib/types/exec.js CHANGED
@@ -2,6 +2,12 @@
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import { resolve as resolvePath } from 'node:path';
4
4
  const STATES = new WeakMap();
5
+ const MIN_YIELD_TIME_MS = 250;
6
+ const MIN_EMPTY_YIELD_TIME_MS = 5_000;
7
+ const MAX_YIELD_TIME_MS = 30_000;
8
+ const MAX_EMPTY_YIELD_TIME_MS = 300_000;
9
+ const DEFAULT_OUTPUT_TOKENS = 10_000;
10
+ const APPROX_BYTES_PER_TOKEN = 4;
5
11
  function stateFor(agent) {
6
12
  const current = STATES.get(agent);
7
13
  if (current !== undefined)
@@ -15,14 +21,16 @@ function positiveFinite(name, value) {
15
21
  throw new Error(`${name} must be a non-negative finite number`);
16
22
  }
17
23
  }
18
- function waitMs(value, fallback) {
19
- return Math.max(0, Math.min(30_000, Math.trunc(value ?? fallback)));
24
+ export function normalizeWaitMs(value, fallback, empty = false) {
25
+ const lower = empty ? MIN_EMPTY_YIELD_TIME_MS : MIN_YIELD_TIME_MS;
26
+ const upper = empty ? MAX_EMPTY_YIELD_TIME_MS : MAX_YIELD_TIME_MS;
27
+ return Math.max(lower, Math.min(upper, Math.trunc(value ?? fallback)));
20
28
  }
21
29
  function outputLimit(maxOutputBytes, maxOutputTokens) {
22
- if (maxOutputTokens === undefined)
23
- return maxOutputBytes;
24
- positiveFinite('max_output_tokens', maxOutputTokens);
25
- return Math.max(1, Math.min(maxOutputBytes, Math.trunc(maxOutputTokens * 4)));
30
+ const tokens = maxOutputTokens ?? DEFAULT_OUTPUT_TOKENS;
31
+ if (maxOutputTokens !== undefined)
32
+ positiveFinite('max_output_tokens', maxOutputTokens);
33
+ return Math.max(1, Math.min(maxOutputBytes, Math.trunc(tokens * APPROX_BYTES_PER_TOKEN)));
26
34
  }
27
35
  function limitOutput(text, maxBytes) {
28
36
  if (Buffer.byteLength(text, 'utf8') <= maxBytes)
@@ -100,6 +108,13 @@ async function waitForShell(process, ms, signal) {
100
108
  signal.throwIfAborted();
101
109
  return completed === true || process.status !== 'running';
102
110
  }
111
+ /** Stop a pipe-backed session when the current Codex tool is cancelled. */
112
+ async function stopShellOnAbort(process, signal) {
113
+ if (!signal.aborted || process.status !== 'running')
114
+ return;
115
+ process.kill();
116
+ await process.done;
117
+ }
103
118
  function allocateSession(agent, session) {
104
119
  const state = stateFor(agent);
105
120
  const id = ++state.nextId;
@@ -142,22 +157,33 @@ export async function runExecCommand(ctx, args, exec, config) {
142
157
  ...workdir === undefined ? {} : { cwd: workdir },
143
158
  };
144
159
  const spawned = await terminals.spawn(agent, spawnRequest, exec.signal);
145
- const sendRequest = {
146
- text: commandFor(args),
147
- submit: true,
148
- waitMs: waitMs(args.yield_time_ms, config.defaultYieldTimeMs),
149
- signal: exec.signal,
150
- };
151
- const operation = terminals.startSend(agent, spawned.sessionId, sendRequest);
152
- const result = await operation.done;
153
- const output = terminalResult(result, maxBytes, startedAt);
154
- if (result.sessionStatus.kind === 'running') {
155
- output.session_id = allocateSession(agent, { kind: 'terminal', owner: agent, id: spawned.sessionId });
160
+ let retained = false;
161
+ let closeReason = 'Codex command failed';
162
+ try {
163
+ const sendRequest = {
164
+ text: commandFor(args),
165
+ submit: true,
166
+ waitMs: normalizeWaitMs(args.yield_time_ms, config.defaultYieldTimeMs),
167
+ signal: exec.signal,
168
+ };
169
+ const operation = terminals.startSend(agent, spawned.sessionId, sendRequest);
170
+ const result = await operation.done;
171
+ const output = terminalResult(result, maxBytes, startedAt);
172
+ if (result.sessionStatus.kind === 'running') {
173
+ output.session_id = allocateSession(agent, { kind: 'terminal', owner: agent, id: spawned.sessionId });
174
+ retained = true;
175
+ }
176
+ else {
177
+ closeReason = 'Codex command exited';
178
+ }
179
+ return output;
156
180
  }
157
- else {
158
- await terminals.kill(agent, spawned.sessionId, 'Codex command exited');
181
+ finally {
182
+ // A PTY is published by spawn before the first send starts. Do not leave
183
+ // that session behind when send setup or its foreground operation fails.
184
+ if (!retained)
185
+ await terminals.kill(agent, spawned.sessionId, closeReason);
159
186
  }
160
- return output;
161
187
  }
162
188
  const policy = ctx.get('sandboxPolicy')?.resolve(exec.agent === undefined ? {} : { session: exec.agent.session });
163
189
  const dshEnv = ctx.get('shellEnv')?.collect(exec);
@@ -167,32 +193,38 @@ export async function runExecCommand(ctx, args, exec, config) {
167
193
  login: args.login ?? true,
168
194
  ...workdir === undefined ? {} : { workdir },
169
195
  stdoutMaxBytes: maxBytes,
196
+ signal: exec.signal,
170
197
  ...dshEnv === undefined ? {} : { dshEnv },
171
198
  ...policy === undefined ? {} : { sandboxPolicy: policy },
172
199
  };
173
200
  const process = ctx.shell.start(ctx.shell.resolve(shellRequest));
174
- const completed = await waitForShell(process, waitMs(args.yield_time_ms, config.defaultYieldTimeMs), exec.signal);
175
- const output = readShellOutput(process.readOutput(), maxBytes);
176
- if (!completed || process.status === 'running') {
177
- if (exec.agent === undefined) {
178
- process.kill();
179
- await process.done;
201
+ try {
202
+ await waitForShell(process, normalizeWaitMs(args.yield_time_ms, config.defaultYieldTimeMs), exec.signal);
203
+ const output = readShellOutput(process.readOutput(), maxBytes);
204
+ if (process.status === 'running') {
205
+ if (exec.agent === undefined) {
206
+ process.kill();
207
+ await process.done;
208
+ return withChunkId({
209
+ wall_time_seconds: (performance.now() - startedAt) / 1000,
210
+ output: readShellOutput(process.readOutput(), maxBytes),
211
+ });
212
+ }
180
213
  return withChunkId({
181
214
  wall_time_seconds: (performance.now() - startedAt) / 1000,
182
- output: readShellOutput(process.readOutput(), maxBytes),
215
+ output,
216
+ session_id: allocateSession(exec.agent, { kind: 'shell', process }),
183
217
  });
184
218
  }
185
219
  return withChunkId({
186
220
  wall_time_seconds: (performance.now() - startedAt) / 1000,
187
221
  output,
188
- session_id: allocateSession(exec.agent, { kind: 'shell', process }),
222
+ ...process.exitCode === null ? {} : { exit_code: process.exitCode },
189
223
  });
190
224
  }
191
- return withChunkId({
192
- wall_time_seconds: (performance.now() - startedAt) / 1000,
193
- output,
194
- ...process.exitCode === null ? {} : { exit_code: process.exitCode },
195
- });
225
+ finally {
226
+ await stopShellOnAbort(process, exec.signal);
227
+ }
196
228
  }
197
229
  /** Poll or write to one session returned by {@link runExecCommand}. */
198
230
  export async function runWriteStdin(ctx, args, exec, config) {
@@ -211,21 +243,33 @@ export async function runWriteStdin(ctx, args, exec, config) {
211
243
  if (chars.length > 0) {
212
244
  throw new Error('this unified exec session uses pipes and does not accept stdin; rerun exec_command with tty=true');
213
245
  }
214
- const completed = await waitForShell(session.process, waitMs(args.yield_time_ms, config.pollYieldTimeMs), exec.signal);
215
- const output = readShellOutput(session.process.readOutput(), maxBytes);
216
- if (completed && session.process.status !== 'running') {
217
- forgetSession(agent, args.session_id);
246
+ try {
247
+ await waitForShell(session.process, normalizeWaitMs(args.yield_time_ms, config.pollYieldTimeMs, true), exec.signal);
248
+ const output = readShellOutput(session.process.readOutput(), maxBytes);
249
+ if (session.process.status !== 'running') {
250
+ forgetSession(agent, args.session_id);
251
+ return withChunkId({
252
+ wall_time_seconds: (performance.now() - startedAt) / 1000,
253
+ output,
254
+ ...session.process.exitCode === null ? {} : { exit_code: session.process.exitCode },
255
+ });
256
+ }
218
257
  return withChunkId({
219
258
  wall_time_seconds: (performance.now() - startedAt) / 1000,
220
259
  output,
221
- ...session.process.exitCode === null ? {} : { exit_code: session.process.exitCode },
260
+ session_id: args.session_id,
222
261
  });
223
262
  }
224
- return withChunkId({
225
- wall_time_seconds: (performance.now() - startedAt) / 1000,
226
- output,
227
- session_id: args.session_id,
228
- });
263
+ finally {
264
+ if (exec.signal.aborted) {
265
+ try {
266
+ await stopShellOnAbort(session.process, exec.signal);
267
+ }
268
+ finally {
269
+ forgetSession(agent, args.session_id);
270
+ }
271
+ }
272
+ }
229
273
  }
230
274
  const terminals = ctx.get('terminals');
231
275
  if (terminals === undefined)
@@ -233,19 +277,33 @@ export async function runWriteStdin(ctx, args, exec, config) {
233
277
  const sendRequest = {
234
278
  text: chars,
235
279
  submit: false,
236
- waitMs: waitMs(args.yield_time_ms, chars.length > 0 ? config.writeYieldTimeMs : config.pollYieldTimeMs),
280
+ waitMs: normalizeWaitMs(args.yield_time_ms, chars.length > 0 ? config.writeYieldTimeMs : config.pollYieldTimeMs, chars.length === 0),
237
281
  signal: exec.signal,
238
282
  };
239
- const operation = terminals.startSend(agent, session.id, sendRequest);
240
- const result = await operation.done;
241
- const output = terminalResult(result, maxBytes, startedAt);
242
- if (result.sessionStatus.kind === 'running') {
243
- output.session_id = args.session_id;
283
+ let retained = false;
284
+ let closeReason = 'Codex command failed';
285
+ try {
286
+ const operation = terminals.startSend(agent, session.id, sendRequest);
287
+ const result = await operation.done;
288
+ const output = terminalResult(result, maxBytes, startedAt);
289
+ if (result.sessionStatus.kind === 'running') {
290
+ output.session_id = args.session_id;
291
+ retained = true;
292
+ }
293
+ else {
294
+ closeReason = 'Codex command exited';
295
+ forgetSession(agent, args.session_id);
296
+ }
297
+ return output;
244
298
  }
245
- else {
246
- forgetSession(agent, args.session_id);
247
- await terminals.kill(agent, session.id, 'Codex command exited');
299
+ finally {
300
+ // A failed send must not strand either side of the two-level session
301
+ // registry: the local numeric id and the underlying PTY must be retired
302
+ // together. A successful running result is the only retained case.
303
+ if (!retained) {
304
+ forgetSession(agent, args.session_id);
305
+ await terminals.kill(agent, session.id, closeReason);
306
+ }
248
307
  }
249
- return output;
250
308
  }
251
309
  //# sourceMappingURL=exec.js.map
@@ -6,6 +6,7 @@ import { CODEX_CONTEXT_MAX } from "./context.js";
6
6
  import { applyPatchHunks, parsePatch } from "./patch.js";
7
7
  import { renderExecResult, runExecCommand, runWriteStdin } from "./exec.js";
8
8
  import { hostedWebSearchStream, installHostedWebSearch, remoteCompactStream } from "./remote.js";
9
+ import { registerCodexActivityProjection } from "./activity.js";
9
10
  export const name = 'codex';
10
11
  export const inject = ['tools', 'systemPrompt', 'shell', 'fs', 'llm', 'credentials', 'settings'];
11
12
  /** Runtime configuration schema for the Codex tool bridge. */
@@ -178,8 +179,7 @@ const CODEX_BASE_PROMPT = String.raw `You are Codex, based on {{model}}. You are
178
179
  `;
179
180
  const CODEX_COLLABORATION_PROMPT = String.raw `## Collaboration
180
181
 
181
- - Ask, align, and clarify whenever uncertainty, assumptions, tradeoffs, or decisions could materially affect the outcome.
182
- - Understand the user's full picture, align it with your own, and leave no hidden assumptions or gaps.
182
+ - Ask, align, and clarify first. Try your best to understand the user's full picture.
183
183
  - Keep only the essential logic and core actions. There's no need to explain or test what was removed or why something wasn't done.
184
184
  - Convey enough valuable information with as few words as possible. Stay focused on the end goal.
185
185
  - Solve problems by thinking from first principles and at a higher level.
@@ -459,6 +459,7 @@ export function apply(ctx, config = {}) {
459
459
  // provider routes. Codex adds only scoped transport behavior and capability
460
460
  // metadata; failed remote operations continue through the generic path.
461
461
  watchConfiguredGptModels(ctx);
462
+ registerCodexActivityProjection(ctx);
462
463
  const codexSettings = installCodexSettings(ctx);
463
464
  // Keep these controls in the request config rather than mutating provider
464
465
  // settings. That makes a change apply to the next step without rebuilding
@@ -1,20 +1,21 @@
1
1
  /** Parser and line-oriented applicator for Codex's `apply_patch` language. */
2
2
  /** The grammar sent to providers that support OpenAI custom grammar tools. */
3
- export const APPLY_PATCH_GRAMMAR = String.raw `start: begin_patch hunk+ end_patch
4
- begin_patch: "*** Begin Patch" LF
5
- end_patch: "*** End Patch" LF?
6
- hunk: add_hunk | delete_hunk | update_hunk
7
- add_hunk: "*** Add File: " filename LF add_line+
8
- delete_hunk: "*** Delete File: " filename LF
9
- update_hunk: "*** Update File: " filename LF change_move? change?
10
- filename: /(.+)/
11
- add_line: "+" /(.*)/ LF -> line
12
- change_move: "*** Move to: " filename LF
13
- change: (change_context | change_line)+ eof_line?
14
- change_context: ("@@" | "@@ " /(.+)/) LF
15
- change_line: ("+" | "-" | " ") /(.*)/ LF
16
- eof_line: "*** End of File" LF
17
- %import common.LF
3
+ export const APPLY_PATCH_GRAMMAR = String.raw `start: begin_patch environment_id? hunk+ end_patch
4
+ begin_patch: "*** Begin Patch" LF
5
+ environment_id: "*** Environment ID: " filename LF
6
+ end_patch: "*** End Patch" LF?
7
+ hunk: add_hunk | delete_hunk | update_hunk
8
+ add_hunk: "*** Add File: " filename LF add_line+
9
+ delete_hunk: "*** Delete File: " filename LF
10
+ update_hunk: "*** Update File: " filename LF change_move? change?
11
+ filename: /(.+)/
12
+ add_line: "+" /(.*)/ LF -> line
13
+ change_move: "*** Move to: " filename LF
14
+ change: (change_context | change_line)+ eof_line?
15
+ change_context: ("@@" | "@@ " /(.+)/) LF
16
+ change_line: ("+" | "-" | " ") /(.*)/ LF
17
+ eof_line: "*** End of File" LF
18
+ %import common.LF
18
19
  `;
19
20
  function invalid(message) {
20
21
  throw new Error(`apply_patch: ${message}`);
@@ -25,6 +26,19 @@ function isFileHeader(line) {
25
26
  || trimmed.startsWith('*** Delete File: ')
26
27
  || trimmed.startsWith('*** Update File: ');
27
28
  }
29
+ const ENVIRONMENT_ID_MARKER = '*** Environment ID:';
30
+ function unwrapHeredoc(input) {
31
+ const normalized = input.replaceAll('\r\n', '\n').trim();
32
+ const lines = normalized.split('\n');
33
+ const first = lines[0]?.trim();
34
+ const last = lines.at(-1)?.trim();
35
+ if (lines.length >= 4
36
+ && (first === '<<EOF' || first === "<<'EOF'" || first === '<<"EOF"')
37
+ && last === 'EOF') {
38
+ return lines.slice(1, -1).join('\n').trim();
39
+ }
40
+ return normalized;
41
+ }
28
42
  function pathFrom(line, prefix) {
29
43
  const path = line.slice(prefix.length).trim();
30
44
  if (path.length === 0)
@@ -40,7 +54,7 @@ function isPotentialChangeMarker(line) {
40
54
  }
41
55
  /** Parse one complete Codex patch after normalizing CRLF input to LF. */
42
56
  export function parsePatch(input) {
43
- const lines = input.replaceAll('\r\n', '\n').trim().split('\n');
57
+ const lines = unwrapHeredoc(input).split('\n');
44
58
  if (lines[0]?.trim() !== '*** Begin Patch')
45
59
  invalid('input must start with "*** Begin Patch"');
46
60
  if (lines.at(-1)?.trim() !== '*** End Patch')
@@ -48,6 +62,15 @@ export function parsePatch(input) {
48
62
  const files = [];
49
63
  let index = 1;
50
64
  const end = lines.length - 1;
65
+ if (lines[index]?.trim().startsWith(ENVIRONMENT_ID_MARKER) === true) {
66
+ const environmentId = lines[index].trim().slice(ENVIRONMENT_ID_MARKER.length).trim();
67
+ if (environmentId.length === 0)
68
+ invalid('environment id cannot be empty');
69
+ index++;
70
+ if (lines[index]?.trim().startsWith(ENVIRONMENT_ID_MARKER) === true) {
71
+ invalid('environment id cannot be specified more than once');
72
+ }
73
+ }
51
74
  while (index < end) {
52
75
  const header = lines[index++]?.trim();
53
76
  if (header === undefined)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shuind/dsh-codex-harness",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "A minimal Codex harness 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",