@shuind/dsh-codex-harness 0.1.20 → 0.1.22

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
@@ -5,9 +5,11 @@
5
5
  ## 功能
6
6
 
7
7
  - **精简 Codex coding-agent 提示词**:使用精简版系统提示词;身份、模型和工作目录由 `dsh-persona` 提供,避免重复的 Harness 身份说明,同时保留必要的工具契约和编码工作流。
8
- - **Codex 工具**:`exec_command`、`write_stdin`、`apply_patch`、`update_plan`。
8
+ - **Codex 工具**:`exec_command`、`write_stdin`、`apply_patch`、`update_plan`;`apply_patch` 匹配失败时会显示文件、hunk、行号、可见空白和附近文件内容,便于修正上下文。
9
+ - **后台任务**:`exec_command` 支持 `run_in_background: true`,立即返回 DSH `job_id`;使用 `job_output` 读取输出、`job_kill` 停止任务。任务完成时,忙碌中的 agent 会在下一步收到 inbox 注入;空闲 agent 默认保持安静,等下一次唤醒时再处理完成消息。
9
10
  - **GPT 能力补全**:为 GPT 系列模型补充图片输入和思考强度选项;不会覆盖用户已有的显式配置。
10
11
  - **Fast**:仅在 Codex preset 的模型选择菜单中显示,开启后向 Responses 请求发送 `service_tier: "priority"`。
12
+ - **旧版 Web 兼容**:在未提供新版模型设置/上下文设置 slot 的 DSH Web 中,Fast 和上下文容量会自动回退到旧版 composer slot;client 注入不依赖纯类型 slot 包。
11
13
  - **远程搜索与压缩**:OpenAI Responses 请求默认优先使用 hosted `web_search` 和 `/responses/compact`;失败时回退到 DSH 的本地实现。
12
14
  - **上下文容量**:在 Web 中设置 `1K`–`1000K` tokens,设置值作用于下一次请求。
13
15
  - **活动状态**:Codex 等待模型响应或进行上下文压缩时,在输入框上方显示对应状态和耗时,让长时间 Deep Diving 不再像卡住。
@@ -15,7 +17,7 @@
15
17
  ## 安装
16
18
 
17
19
  ```sh
18
- dsh plugin --profile web add @shuind/dsh-codex-harness@0.1.20
20
+ dsh plugin --profile web add @shuind/dsh-codex-harness@0.1.22
19
21
  ```
20
22
 
21
23
  重启 Web,创建新会话,在模式菜单中选择 **Codex 模式**。
@@ -82,6 +84,11 @@ Codex 等待模型首个输出时,Web 输入框上方会显示 `正在等待
82
84
  name: '@shuind/dsh-codex-harness'
83
85
  config:
84
86
  collaborationPrompt: true
87
+
88
+ - id: codex-jobs
89
+ name: '@deepseek-ai/dsh-tool-jobs'
90
+ config:
91
+ completionDelivery: quiet
85
92
  ```
86
93
 
87
94
  再写入 `preset.yml`,让 Web 中显示更清晰的名称:
@@ -92,7 +99,7 @@ description: 使用 Codex 工具并开启可选协作提示词。
92
99
  order: 10
93
100
  ```
94
101
 
95
- 然后在 Web 的 **Agent 预设** 中选择 `My Codex`,再新建会话。这个 preset 会加载 Codex 工具,并开启协作提示词;网页搜索、远程压缩和 Skills 等其他功能需要在同一个 `agent.cordis.yml` 中按需添加。
102
+ 然后在 Web 的 **Agent 预设** 中选择 `My Codex`,再新建会话。这个 preset 会加载 Codex 工具,并开启协作提示词;网页搜索、远程压缩、后台任务控制和 Skills 等其他功能需要在同一个 `agent.cordis.yml` 中按需添加。
96
103
 
97
104
  ### 可选协作提示词(私货)
98
105
 
package/lib/client.js CHANGED
@@ -28,6 +28,11 @@ window.__ModuleLoader__.load({
28
28
  /** Browser controls for the Codex request settings mounted by the Host plugin. */
29
29
  const NS = "codex";
30
30
  const SETTINGS_NAMESPACE = "codex";
31
+ const MODEL_SETTINGS_SLOT = "conversation.input.model.settings";
32
+ const CONTEXT_SETTINGS_SLOT = "conversation.input.context.settings";
33
+ const FAST_FALLBACK_SLOT = "conversation.input.right";
34
+ const CONTEXT_FALLBACK_SLOT = "conversation.input.dock";
35
+ const DEFAULT_CONTEXT_WINDOW = 262144;
31
36
  const en = {
32
37
  fast: "Fast mode",
33
38
  fastOn: "Fast mode on (priority tier)",
@@ -95,6 +100,50 @@ window.__ModuleLoader__.load({
95
100
  })]
96
101
  });
97
102
  }
103
+ /** The legacy composer seat is a compact inline row, not a menu panel. */
104
+ function FastModeFallback(props) {
105
+ const { sessionId, useSessions, useSettings, setSetting, t } = props;
106
+ const agentPreset = useSessions((state) => state.byId[sessionId]?.agentPreset);
107
+ const snapshot = useSettings((state) => state);
108
+ const fast = snapshot.value?.fast ?? false;
109
+ if (agentPreset !== "codex") return null;
110
+ return (0, react_jsx_runtime.jsxs)("button", {
111
+ type: "button",
112
+ role: "switch",
113
+ disabled: snapshot.writable === false,
114
+ "aria-checked": fast,
115
+ "aria-label": fast ? t("fastOn") : t("fastOff"),
116
+ title: fast ? t("fastOn") : t("fastOff"),
117
+ onClick: () => {
118
+ setSetting("fast", !fast);
119
+ },
120
+ style: {
121
+ boxSizing: "border-box",
122
+ display: "inline-flex",
123
+ alignItems: "center",
124
+ gap: 5,
125
+ height: 28,
126
+ border: "1px solid var(--dsw-alias-border-primary)",
127
+ borderRadius: 7,
128
+ padding: "0 8px",
129
+ color: fast ? "var(--dsw-static-blue-500)" : "var(--dsw-alias-label-secondary)",
130
+ background: fast ? "var(--dsw-alias-interactive-bg-selected)" : "transparent",
131
+ cursor: snapshot.writable === false ? "default" : "pointer",
132
+ font: "inherit",
133
+ fontSize: 12,
134
+ lineHeight: "20px",
135
+ fontWeight: fast ? 600 : 400,
136
+ whiteSpace: "nowrap"
137
+ },
138
+ children: [(0, react_jsx_runtime.jsx)("span", { children: t("fast") }), (0, react_jsx_runtime.jsx)("span", {
139
+ style: {
140
+ color: "var(--dsw-alias-label-tertiary)",
141
+ fontSize: 11
142
+ },
143
+ children: fast ? t("fastStateOn") : t("fastStateOff")
144
+ })]
145
+ });
146
+ }
98
147
  function ContextSizeControl({ contextWindow, useSettings, setSetting, unsetSetting, t }) {
99
148
  const snapshot = useSettings((state) => state);
100
149
  const configured = snapshot.value?.contextWindow;
@@ -211,6 +260,16 @@ window.__ModuleLoader__.load({
211
260
  ]
212
261
  });
213
262
  }
263
+ function contextWindowFromProjection(useProjection) {
264
+ const pressure = useProjection("contextPressure");
265
+ return pressure?.contextWindow !== void 0 && pressure.contextWindow > 0 ? pressure.contextWindow : DEFAULT_CONTEXT_WINDOW;
266
+ }
267
+ function LegacyContextSizeControl({ useProjection, ...props }) {
268
+ return (0, react_jsx_runtime.jsx)(ContextSizeControl, {
269
+ ...props,
270
+ contextWindow: contextWindowFromProjection(useProjection)
271
+ });
272
+ }
214
273
  function elapsedSeconds(startedAt, now) {
215
274
  return Math.max(0, Math.floor((now - startedAt) / 1e3));
216
275
  }
@@ -275,7 +334,15 @@ window.__ModuleLoader__.load({
275
334
  "locale",
276
335
  "settingsScope"
277
336
  ];
278
- /** Mount Fast inside the model selector and context size inside the meter panel. */
337
+ /** Select a preferred slot when the host declares it, otherwise use a stable legacy composer seat. */
338
+ function chooseDeclaredSlot(preferred, fallback, isDeclared) {
339
+ return isDeclared(preferred) ? preferred : fallback;
340
+ }
341
+ function slotIsDeclared(ctx, slot) {
342
+ const registry = ctx.slots;
343
+ return typeof registry.spec === "function" && registry.spec.call(ctx.slots, slot) !== void 0;
344
+ }
345
+ /** Mount Codex controls in the newest named seats, with legacy composer fallbacks. */
279
346
  function apply(ctx) {
280
347
  ctx.effect(() => ctx.locale.register(NS, {
281
348
  en,
@@ -287,20 +354,34 @@ window.__ModuleLoader__.load({
287
354
  setSetting: (field, value) => settings.set(field, value),
288
355
  unsetSetting: (field) => settings.unset(field)
289
356
  });
290
- ctx.slots.inject("conversation.input.model.settings", () => ctx.slots.register({
291
- name: "conversation.input.model.settings",
357
+ if (chooseDeclaredSlot(MODEL_SETTINGS_SLOT, FAST_FALLBACK_SLOT, (slot) => slotIsDeclared(ctx, slot)) === MODEL_SETTINGS_SLOT) ctx.slots.inject(MODEL_SETTINGS_SLOT, () => ctx.slots.register({
358
+ name: MODEL_SETTINGS_SLOT,
292
359
  id: "codex-fast",
293
360
  order: 0,
294
361
  locale: NS,
295
362
  inject: injected
296
363
  }, FastModeButton));
297
- ctx.slots.inject("conversation.input.context.settings", () => ctx.slots.register({
298
- name: "conversation.input.context.settings",
364
+ else ctx.slots.inject(FAST_FALLBACK_SLOT, () => ctx.slots.register({
365
+ name: FAST_FALLBACK_SLOT,
366
+ id: "codex-fast-fallback",
367
+ order: 0,
368
+ locale: NS,
369
+ inject: injected
370
+ }, FastModeFallback));
371
+ if (chooseDeclaredSlot(CONTEXT_SETTINGS_SLOT, CONTEXT_FALLBACK_SLOT, (slot) => slotIsDeclared(ctx, slot)) === CONTEXT_SETTINGS_SLOT) ctx.slots.inject(CONTEXT_SETTINGS_SLOT, () => ctx.slots.register({
372
+ name: CONTEXT_SETTINGS_SLOT,
299
373
  id: "codex-context-size",
300
374
  order: 0,
301
375
  locale: NS,
302
376
  inject: injected
303
377
  }, ContextSizeControl));
378
+ else ctx.slots.inject(CONTEXT_FALLBACK_SLOT, () => ctx.slots.register({
379
+ name: CONTEXT_FALLBACK_SLOT,
380
+ id: "codex-context-size-fallback",
381
+ order: 0,
382
+ locale: NS,
383
+ inject: injected
384
+ }, LegacyContextSizeControl));
304
385
  ctx.slots.inject("conversation.composer.dock", () => ctx.slots.register({
305
386
  name: "conversation.composer.dock",
306
387
  id: "codex-activity",
@@ -315,6 +396,7 @@ window.__ModuleLoader__.load({
315
396
  //#endregion
316
397
  exports.ActivityLine = ActivityLine;
317
398
  exports.apply = apply;
399
+ exports.chooseDeclaredSlot = chooseDeclaredSlot;
318
400
  exports.default = client_default;
319
401
  exports.inject = inject;
320
402
  return module.exports;
package/lib/index.js CHANGED
@@ -177,17 +177,30 @@ function findSequence(lines, expected, from, endOfFile) {
177
177
  }
178
178
  return -1;
179
179
  }
180
+ function displayPatchLine(line) {
181
+ if (line.length === 0) return "<empty>";
182
+ return line.replaceAll(" ", "[space]").replaceAll(" ", "[tab]");
183
+ }
184
+ function formatPatchExcerpt(lines, start, count) {
185
+ if (lines.length === 0) return " <empty file>";
186
+ const first = Math.max(0, Math.min(start, lines.length - 1));
187
+ return lines.slice(first, first + count).map((line, offset) => ` ${first + offset + 1}: ${displayPatchLine(line)}`).join("\n");
188
+ }
189
+ function mismatchMessage(label, hunkIndex, expected, actual, from, endOfFile) {
190
+ const expectedPreview = expected.slice(0, 12).map((line, index) => ` ${index + 1}: ${displayPatchLine(line)}`).join("\n");
191
+ const expectedSuffix = expected.length > 12 ? "\n ..." : "";
192
+ const excerptStart = endOfFile ? Math.max(0, actual.length - 6) : Math.max(0, from - 2);
193
+ const markerHint = expected.some((wanted) => wanted.length > 0 && actual.includes(`-${wanted}`)) ? "\nHint: if a source line starts with a patch marker, repeat that marker after the operation marker; for example, `-- item` deletes the source line `- item`." : "";
194
+ return `could not find expected lines in ${label} hunk ${hunkIndex + 1} (search starts at line ${from + 1})\nExpected:\n${expectedPreview}${expectedSuffix}\nFile excerpt:\n${formatPatchExcerpt(actual, excerptStart, 10)}${markerHint}`;
195
+ }
180
196
  /** Apply parsed update hunks and return LF-normalized text. */
181
- function applyPatchHunks(original, hunks) {
197
+ function applyPatchHunks(original, hunks, label = "file") {
182
198
  const value = splitText(original.replaceAll("\r\n", "\n"));
183
199
  let cursor = 0;
184
- for (const hunk of hunks) {
200
+ for (const [hunkIndex, hunk] of hunks.entries()) {
185
201
  const expected = hunk.lines.filter((line) => line.kind !== "add").map((line) => line.text);
186
202
  const start = findSequence(value.lines, expected, cursor, hunk.endOfFile);
187
- if (start < 0) {
188
- const detail = expected.join("\n");
189
- invalid(`could not find expected lines${detail.length === 0 ? "" : `:\n${detail}`}`);
190
- }
203
+ if (start < 0) invalid(mismatchMessage(label, hunkIndex, expected, value.lines, cursor, hunk.endOfFile));
191
204
  const replacement = hunk.lines.filter((line) => line.kind !== "delete").map((line) => line.text);
192
205
  value.lines.splice(start, expected.length, ...replacement);
193
206
  cursor = start + replacement.length;
@@ -248,6 +261,7 @@ function renderExecResult(result) {
248
261
  sections.push(`Wall time: ${result.wall_time_seconds.toFixed(4)} seconds`);
249
262
  if (result.exit_code !== void 0) sections.push(`Process exited with code ${result.exit_code}`);
250
263
  if (result.session_id !== void 0) sections.push(`Process running with session ID ${result.session_id}`);
264
+ if (result.job_id !== void 0) sections.push(`Background job ID: ${result.job_id}`);
251
265
  if (result.original_token_count !== void 0) sections.push(`Original token count: ${result.original_token_count}`);
252
266
  sections.push("Output:", result.output);
253
267
  return sections.join("\n");
@@ -268,6 +282,17 @@ function terminalResult(result, maxBytes, startedAt) {
268
282
  ...result.sessionStatus.kind === "exited" ? { ...result.sessionStatus.exitCode === null ? {} : { exit_code: result.sessionStatus.exitCode } } : {}
269
283
  });
270
284
  }
285
+ /** Map a detached shell process onto the generic DSH job outcome contract. */
286
+ function backgroundOutcome(process) {
287
+ if (process.status === "killed") return {
288
+ status: "killed",
289
+ detail: process.signal !== null ? `signal: ${process.signal}` : "killed before exit"
290
+ };
291
+ return {
292
+ status: "completed",
293
+ detail: `exit code: ${process.exitCode ?? 0}`
294
+ };
295
+ }
271
296
  function sleep(ms, signal) {
272
297
  return new Promise((resolve) => {
273
298
  let timer;
@@ -321,6 +346,7 @@ async function runExecCommand(ctx, args, exec, config) {
321
346
  const maxBytes = outputLimit(config.maxOutputBytes, args.max_output_tokens);
322
347
  const workdir = sessionCwd$1(exec, args.workdir);
323
348
  const startedAt = performance.now();
349
+ if (args.run_in_background === true && args.tty === true) throw new Error("run_in_background is only supported for pipe-backed commands; omit tty");
324
350
  if (args.tty === true) {
325
351
  const agent = exec.agent;
326
352
  const terminals = ctx.get("terminals");
@@ -364,11 +390,42 @@ async function runExecCommand(ctx, args, exec, config) {
364
390
  login: args.login ?? true,
365
391
  ...workdir === void 0 ? {} : { workdir },
366
392
  stdoutMaxBytes: maxBytes,
367
- signal: exec.signal,
368
393
  ...dshEnv === void 0 ? {} : { dshEnv },
369
394
  ...policy === void 0 ? {} : { sandboxPolicy: policy }
370
395
  };
371
- const process = ctx.shell.start(ctx.shell.resolve(shellRequest));
396
+ if (args.run_in_background === true) {
397
+ const owner = exec.agent;
398
+ if (owner === void 0) throw new Error("run_in_background requires an owning agent session");
399
+ const jobs = ctx.get("jobs");
400
+ if (jobs === void 0) throw new Error("background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs");
401
+ if (exec.signal.aborted) {
402
+ exec.signal.throwIfAborted();
403
+ throw new Error("tool call aborted");
404
+ }
405
+ const id = jobs.start({
406
+ kind: "bash",
407
+ label: args.cmd,
408
+ owner,
409
+ outputLimitBytes: maxBytes,
410
+ run: () => {
411
+ const process = ctx.shell.start(ctx.shell.resolve(shellRequest));
412
+ return {
413
+ cancel: () => void process.kill(),
414
+ done: process.done.then(() => backgroundOutcome(process)),
415
+ readOutput: () => readShellOutput(process.readOutput(), maxBytes)
416
+ };
417
+ }
418
+ });
419
+ return withChunkId({
420
+ wall_time_seconds: (performance.now() - startedAt) / 1e3,
421
+ job_id: id,
422
+ output: `Started background job ${id}. Use job_output with job_id "${id}" to read output.`
423
+ });
424
+ }
425
+ const process = ctx.shell.start(ctx.shell.resolve({
426
+ ...shellRequest,
427
+ signal: exec.signal
428
+ }));
372
429
  try {
373
430
  await waitForShell(process, normalizeWaitMs(args.yield_time_ms, config.defaultYieldTimeMs), exec.signal);
374
431
  const output = readShellOutput(process.readOutput(), maxBytes);
@@ -1004,9 +1061,9 @@ function normalizeCodexPromptAssembly(assembly) {
1004
1061
  function buildCodexSystemPrompt(config = {}) {
1005
1062
  return config.collaborationPrompt === true ? `${CODEX_BASE_PROMPT}\n\n${CODEX_COLLABORATION_PROMPT}` : CODEX_BASE_PROMPT;
1006
1063
  }
1007
- const EXEC_COMMAND_DESCRIPTION = "Runs a command in a PTY, returning output or a session ID for ongoing interaction.";
1064
+ const EXEC_COMMAND_DESCRIPTION = "Runs a command in a PTY, returning output, a session ID for ongoing interaction, or a background job ID when requested.";
1008
1065
  const WRITE_STDIN_DESCRIPTION = "Writes characters to an existing unified exec session and returns recent output.";
1009
- const APPLY_PATCH_DESCRIPTION = "Edits files using a patch.";
1066
+ 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.";
1010
1067
  const UPDATE_PLAN_DESCRIPTION = "Updates the task plan.\nProvide an optional explanation and a list of plan items, each with a step and status.\nAt most one step can be in_progress at a time.";
1011
1068
  const PLAN_STATUSES = [
1012
1069
  "pending",
@@ -1064,7 +1121,7 @@ async function applyOnePatch(ctx, file, exec, policy) {
1064
1121
  }
1065
1122
  const sourceInfo = await observedTarget(ctx, target, exec);
1066
1123
  const original = await ctx.fs.readText(target, exec.signal);
1067
- const updated = file.kind === "delete" ? void 0 : applyPatchHunks(original, file.hunks);
1124
+ const updated = file.kind === "delete" ? void 0 : applyPatchHunks(original, file.hunks, file.path);
1068
1125
  if (file.kind === "delete") {
1069
1126
  await deletePatchedFile(ctx, target, sourceInfo.version, exec, policy);
1070
1127
  return {
@@ -1152,6 +1209,10 @@ function registerExecTools(ctx, config) {
1152
1209
  type: "boolean",
1153
1210
  description: "True allocates a PTY for the command; false or omitted uses plain pipes."
1154
1211
  },
1212
+ run_in_background: {
1213
+ type: "boolean",
1214
+ description: "Run as a DSH background job and return its job id immediately; collect output with job_output and stop it with job_kill. Only supported for pipe-backed commands."
1215
+ },
1155
1216
  yield_time_ms: {
1156
1217
  type: "number",
1157
1218
  description: "Wait before yielding output. Defaults to 10000 ms; effective range is 250-30000 ms."
@@ -1181,6 +1242,7 @@ function registerExecTools(ctx, config) {
1181
1242
  },
1182
1243
  exit_code: { type: "number" },
1183
1244
  session_id: { type: "number" },
1245
+ job_id: { type: "string" },
1184
1246
  original_token_count: { type: "number" },
1185
1247
  output: {
1186
1248
  type: "string",
@@ -35,7 +35,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
35
35
  type ActivityProps = PropsRuntime<'conversation.composer.dock'> & PropsLocale<'codex'>;
36
36
  export declare function ActivityLine({ sessionId, useSession, useSessions, useProjection, t }: ActivityProps): import("react").JSX.Element | null;
37
37
  export declare const inject: string[];
38
- /** Mount Fast inside the model selector and context size inside the meter panel. */
38
+ /** Select a preferred slot when the host declares it, otherwise use a stable legacy composer seat. */
39
+ export declare function chooseDeclaredSlot(preferred: string, fallback: string, isDeclared: (slot: string) => boolean): string;
40
+ /** Mount Codex controls in the newest named seats, with legacy composer fallbacks. */
39
41
  export declare function apply(ctx: Context): void;
40
42
  declare const _default: {
41
43
  inject: string[];
@@ -5,6 +5,11 @@ import { CODEX_CONTEXT_MAX, CODEX_CONTEXT_UNIT, CODEX_PRESET_ID } from "../conte
5
5
  import { awaitingModelStartedAt } from "./activity.js";
6
6
  const NS = 'codex';
7
7
  const SETTINGS_NAMESPACE = 'codex';
8
+ const MODEL_SETTINGS_SLOT = 'conversation.input.model.settings';
9
+ const CONTEXT_SETTINGS_SLOT = 'conversation.input.context.settings';
10
+ const FAST_FALLBACK_SLOT = 'conversation.input.right';
11
+ const CONTEXT_FALLBACK_SLOT = 'conversation.input.dock';
12
+ const DEFAULT_CONTEXT_WINDOW = 262_144;
8
13
  const en = {
9
14
  fast: 'Fast mode',
10
15
  fastOn: 'Fast mode on (priority tier)',
@@ -55,6 +60,33 @@ function FastModeButton({ sessionId, useSessions, useSettings, setSetting, t })
55
60
  textAlign: 'left',
56
61
  }, children: [_jsx("span", { children: t('fast') }), _jsx("span", { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 12 }, children: fast ? t('fastStateOn') : t('fastStateOff') })] }));
57
62
  }
63
+ /** The legacy composer seat is a compact inline row, not a menu panel. */
64
+ function FastModeFallback(props) {
65
+ const { sessionId, useSessions, useSettings, setSetting, t } = props;
66
+ const agentPreset = useSessions(state => state.byId[sessionId]?.agentPreset);
67
+ const snapshot = useSettings(state => state);
68
+ const fast = snapshot.value?.fast ?? false;
69
+ if (agentPreset !== CODEX_PRESET_ID)
70
+ return null;
71
+ 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: {
72
+ boxSizing: 'border-box',
73
+ display: 'inline-flex',
74
+ alignItems: 'center',
75
+ gap: 5,
76
+ height: 28,
77
+ border: '1px solid var(--dsw-alias-border-primary)',
78
+ borderRadius: 7,
79
+ padding: '0 8px',
80
+ color: fast ? 'var(--dsw-static-blue-500)' : 'var(--dsw-alias-label-secondary)',
81
+ background: fast ? 'var(--dsw-alias-interactive-bg-selected)' : 'transparent',
82
+ cursor: snapshot.writable === false ? 'default' : 'pointer',
83
+ font: 'inherit',
84
+ fontSize: 12,
85
+ lineHeight: '20px',
86
+ fontWeight: fast ? 600 : 400,
87
+ whiteSpace: 'nowrap',
88
+ }, children: [_jsx("span", { children: t('fast') }), _jsx("span", { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 11 }, children: fast ? t('fastStateOn') : t('fastStateOff') })] }));
89
+ }
58
90
  function ContextSizeControl({ contextWindow, useSettings, setSetting, unsetSetting, t }) {
59
91
  const snapshot = useSettings(state => state);
60
92
  const configured = snapshot.value?.contextWindow;
@@ -90,6 +122,15 @@ function ContextSizeControl({ contextWindow, useSettings, setSetting, unsetSetti
90
122
  fontSize: 11,
91
123
  }, children: t('contextRestore') }))] }));
92
124
  }
125
+ function contextWindowFromProjection(useProjection) {
126
+ const pressure = useProjection('contextPressure');
127
+ return pressure?.contextWindow !== undefined && pressure.contextWindow > 0
128
+ ? pressure.contextWindow
129
+ : DEFAULT_CONTEXT_WINDOW;
130
+ }
131
+ function LegacyContextSizeControl({ useProjection, ...props }) {
132
+ return _jsx(ContextSizeControl, { ...props, contextWindow: contextWindowFromProjection(useProjection) });
133
+ }
93
134
  function elapsedSeconds(startedAt, now) {
94
135
  return Math.max(0, Math.floor((now - startedAt) / 1_000));
95
136
  }
@@ -138,7 +179,15 @@ export function ActivityLine({ sessionId, useSession, useSessions, useProjection
138
179
  } }), _jsx("span", { children: label }), _jsxs("span", { style: { color: 'var(--dsw-alias-label-tertiary)' }, children: ["\u00B7 ", elapsedSeconds(activity.startedAt, now), "s"] })] }));
139
180
  }
140
181
  export const inject = ['slots', 'locale', 'settingsScope'];
141
- /** Mount Fast inside the model selector and context size inside the meter panel. */
182
+ /** Select a preferred slot when the host declares it, otherwise use a stable legacy composer seat. */
183
+ export function chooseDeclaredSlot(preferred, fallback, isDeclared) {
184
+ return isDeclared(preferred) ? preferred : fallback;
185
+ }
186
+ function slotIsDeclared(ctx, slot) {
187
+ const registry = ctx.slots;
188
+ return typeof registry.spec === 'function' && registry.spec.call(ctx.slots, slot) !== undefined;
189
+ }
190
+ /** Mount Codex controls in the newest named seats, with legacy composer fallbacks. */
142
191
  export function apply(ctx) {
143
192
  ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'codex client: dictionaries');
144
193
  const settings = ctx.settingsScope.bind({ namespace: SETTINGS_NAMESPACE });
@@ -147,20 +196,44 @@ export function apply(ctx) {
147
196
  setSetting: (field, value) => settings.set(field, value),
148
197
  unsetSetting: (field) => settings.unset(field),
149
198
  });
150
- ctx.slots.inject('conversation.input.model.settings', () => ctx.slots.register({
151
- name: 'conversation.input.model.settings',
152
- id: 'codex-fast',
153
- order: 0,
154
- locale: NS,
155
- inject: injected,
156
- }, FastModeButton));
157
- ctx.slots.inject('conversation.input.context.settings', () => ctx.slots.register({
158
- name: 'conversation.input.context.settings',
159
- id: 'codex-context-size',
160
- order: 0,
161
- locale: NS,
162
- inject: injected,
163
- }, ContextSizeControl));
199
+ const fastSlot = chooseDeclaredSlot(MODEL_SETTINGS_SLOT, FAST_FALLBACK_SLOT, slot => slotIsDeclared(ctx, slot));
200
+ if (fastSlot === MODEL_SETTINGS_SLOT) {
201
+ ctx.slots.inject(MODEL_SETTINGS_SLOT, () => ctx.slots.register({
202
+ name: MODEL_SETTINGS_SLOT,
203
+ id: 'codex-fast',
204
+ order: 0,
205
+ locale: NS,
206
+ inject: injected,
207
+ }, FastModeButton));
208
+ }
209
+ else {
210
+ ctx.slots.inject(FAST_FALLBACK_SLOT, () => ctx.slots.register({
211
+ name: FAST_FALLBACK_SLOT,
212
+ id: 'codex-fast-fallback',
213
+ order: 0,
214
+ locale: NS,
215
+ inject: injected,
216
+ }, FastModeFallback));
217
+ }
218
+ const contextSlot = chooseDeclaredSlot(CONTEXT_SETTINGS_SLOT, CONTEXT_FALLBACK_SLOT, slot => slotIsDeclared(ctx, slot));
219
+ if (contextSlot === CONTEXT_SETTINGS_SLOT) {
220
+ ctx.slots.inject(CONTEXT_SETTINGS_SLOT, () => ctx.slots.register({
221
+ name: CONTEXT_SETTINGS_SLOT,
222
+ id: 'codex-context-size',
223
+ order: 0,
224
+ locale: NS,
225
+ inject: injected,
226
+ }, ContextSizeControl));
227
+ }
228
+ else {
229
+ ctx.slots.inject(CONTEXT_FALLBACK_SLOT, () => ctx.slots.register({
230
+ name: CONTEXT_FALLBACK_SLOT,
231
+ id: 'codex-context-size-fallback',
232
+ order: 0,
233
+ locale: NS,
234
+ inject: injected,
235
+ }, LegacyContextSizeControl));
236
+ }
164
237
  ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
165
238
  name: 'conversation.composer.dock',
166
239
  id: 'codex-activity',
@@ -6,6 +6,7 @@ export interface ExecCommandArgs {
6
6
  cmd: string;
7
7
  workdir?: string;
8
8
  tty?: boolean;
9
+ run_in_background?: boolean;
9
10
  yield_time_ms?: number;
10
11
  max_output_tokens?: number;
11
12
  shell?: string;
@@ -24,6 +25,7 @@ export interface ExecResult {
24
25
  wall_time_seconds: number;
25
26
  output: string;
26
27
  session_id?: number;
28
+ job_id?: string;
27
29
  exit_code?: number;
28
30
  original_token_count?: number;
29
31
  }
package/lib/types/exec.js CHANGED
@@ -56,6 +56,8 @@ export function renderExecResult(result) {
56
56
  sections.push(`Process exited with code ${result.exit_code}`);
57
57
  if (result.session_id !== undefined)
58
58
  sections.push(`Process running with session ID ${result.session_id}`);
59
+ if (result.job_id !== undefined)
60
+ sections.push(`Background job ID: ${result.job_id}`);
59
61
  if (result.original_token_count !== undefined)
60
62
  sections.push(`Original token count: ${result.original_token_count}`);
61
63
  sections.push('Output:', result.output);
@@ -80,6 +82,16 @@ function terminalResult(result, maxBytes, startedAt) {
80
82
  } : {},
81
83
  });
82
84
  }
85
+ /** Map a detached shell process onto the generic DSH job outcome contract. */
86
+ function backgroundOutcome(process) {
87
+ if (process.status === 'killed') {
88
+ return {
89
+ status: 'killed',
90
+ detail: process.signal !== null ? `signal: ${process.signal}` : 'killed before exit',
91
+ };
92
+ }
93
+ return { status: 'completed', detail: `exit code: ${process.exitCode ?? 0}` };
94
+ }
83
95
  function sleep(ms, signal) {
84
96
  return new Promise(resolve => {
85
97
  let timer;
@@ -144,6 +156,9 @@ export async function runExecCommand(ctx, args, exec, config) {
144
156
  const maxBytes = outputLimit(config.maxOutputBytes, args.max_output_tokens);
145
157
  const workdir = sessionCwd(exec, args.workdir);
146
158
  const startedAt = performance.now();
159
+ if (args.run_in_background === true && args.tty === true) {
160
+ throw new Error('run_in_background is only supported for pipe-backed commands; omit tty');
161
+ }
147
162
  if (args.tty === true) {
148
163
  const agent = exec.agent;
149
164
  const terminals = ctx.get('terminals');
@@ -187,17 +202,53 @@ export async function runExecCommand(ctx, args, exec, config) {
187
202
  }
188
203
  const policy = ctx.get('sandboxPolicy')?.resolve(exec.agent === undefined ? {} : { session: exec.agent.session });
189
204
  const dshEnv = ctx.get('shellEnv')?.collect(exec);
205
+ // Background jobs intentionally omit the tool-call signal. Once this call
206
+ // returns, the job lifetime belongs to the jobs registry, not the request.
190
207
  const shellRequest = {
191
208
  command: commandFor(args),
192
209
  ...args.shell === undefined ? {} : { shell: args.shell },
193
210
  login: args.login ?? true,
194
211
  ...workdir === undefined ? {} : { workdir },
195
212
  stdoutMaxBytes: maxBytes,
196
- signal: exec.signal,
197
213
  ...dshEnv === undefined ? {} : { dshEnv },
198
214
  ...policy === undefined ? {} : { sandboxPolicy: policy },
199
215
  };
200
- const process = ctx.shell.start(ctx.shell.resolve(shellRequest));
216
+ if (args.run_in_background === true) {
217
+ const owner = exec.agent;
218
+ if (owner === undefined)
219
+ throw new Error('run_in_background requires an owning agent session');
220
+ const jobs = ctx.get('jobs');
221
+ if (jobs === undefined) {
222
+ throw new Error('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs');
223
+ }
224
+ if (exec.signal.aborted) {
225
+ exec.signal.throwIfAborted();
226
+ throw new Error('tool call aborted');
227
+ }
228
+ const id = jobs.start({
229
+ kind: 'bash',
230
+ label: args.cmd,
231
+ owner,
232
+ outputLimitBytes: maxBytes,
233
+ run: () => {
234
+ const process = ctx.shell.start(ctx.shell.resolve(shellRequest));
235
+ return {
236
+ cancel: () => void process.kill(),
237
+ done: process.done.then(() => backgroundOutcome(process)),
238
+ readOutput: () => readShellOutput(process.readOutput(), maxBytes),
239
+ };
240
+ },
241
+ });
242
+ return withChunkId({
243
+ wall_time_seconds: (performance.now() - startedAt) / 1000,
244
+ job_id: id,
245
+ output: `Started background job ${id}. Use job_output with job_id "${id}" to read output.`,
246
+ });
247
+ }
248
+ const process = ctx.shell.start(ctx.shell.resolve({
249
+ ...shellRequest,
250
+ signal: exec.signal,
251
+ }));
201
252
  try {
202
253
  await waitForShell(process, normalizeWaitMs(args.yield_time_ms, config.defaultYieldTimeMs), exec.signal);
203
254
  const output = readShellOutput(process.readOutput(), maxBytes);
@@ -194,9 +194,9 @@ export function buildCodexSystemPrompt(config = {}) {
194
194
  ? `${CODEX_BASE_PROMPT}\n\n${CODEX_COLLABORATION_PROMPT}`
195
195
  : CODEX_BASE_PROMPT;
196
196
  }
197
- const EXEC_COMMAND_DESCRIPTION = 'Runs a command in a PTY, returning output or a session ID for ongoing interaction.';
197
+ const EXEC_COMMAND_DESCRIPTION = 'Runs a command in a PTY, returning output, a session ID for ongoing interaction, or a background job ID when requested.';
198
198
  const WRITE_STDIN_DESCRIPTION = 'Writes characters to an existing unified exec session and returns recent output.';
199
- const APPLY_PATCH_DESCRIPTION = 'Edits files using a patch.';
199
+ 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.';
200
200
  const UPDATE_PLAN_DESCRIPTION = 'Updates the task plan.\nProvide an optional explanation and a list of plan items, each with a step and status.\nAt most one step can be in_progress at a time.';
201
201
  const PLAN_STATUSES = ['pending', 'in_progress', 'completed'];
202
202
  function sessionCwd(exec) {
@@ -245,7 +245,7 @@ async function applyOnePatch(ctx, file, exec, policy) {
245
245
  }
246
246
  const sourceInfo = await observedTarget(ctx, target, exec);
247
247
  const original = await ctx.fs.readText(target, exec.signal);
248
- const updated = file.kind === 'delete' ? undefined : applyPatchHunks(original, file.hunks);
248
+ const updated = file.kind === 'delete' ? undefined : applyPatchHunks(original, file.hunks, file.path);
249
249
  if (file.kind === 'delete') {
250
250
  await deletePatchedFile(ctx, target, sourceInfo.version, exec, policy);
251
251
  return { path: file.path, operation: 'deleted' };
@@ -306,6 +306,7 @@ function registerExecTools(ctx, config) {
306
306
  cmd: { type: 'string', required: true, description: 'Shell command to execute.' },
307
307
  workdir: { type: 'string', description: 'Working directory for the command. Defaults to the turn cwd.' },
308
308
  tty: { type: 'boolean', description: 'True allocates a PTY for the command; false or omitted uses plain pipes.' },
309
+ run_in_background: { type: 'boolean', description: 'Run as a DSH background job and return its job id immediately; collect output with job_output and stop it with job_kill. Only supported for pipe-backed commands.' },
309
310
  yield_time_ms: { type: 'number', description: 'Wait before yielding output. Defaults to 10000 ms; effective range is 250-30000 ms.' },
310
311
  max_output_tokens: { type: 'number', description: 'Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.' },
311
312
  shell: { type: 'string', description: "Shell binary to launch. Defaults to the user's default shell." },
@@ -320,6 +321,7 @@ function registerExecTools(ctx, config) {
320
321
  wall_time_seconds: { type: 'number', required: true },
321
322
  exit_code: { type: 'number' },
322
323
  session_id: { type: 'number' },
324
+ job_id: { type: 'string' },
323
325
  original_token_count: { type: 'number' },
324
326
  output: { type: 'string', required: true },
325
327
  },
@@ -32,5 +32,5 @@ export type PatchFile = {
32
32
  /** Parse one complete Codex patch after normalizing CRLF input to LF. */
33
33
  export declare function parsePatch(input: string): PatchFile[];
34
34
  /** Apply parsed update hunks and return LF-normalized text. */
35
- export declare function applyPatchHunks(original: string, hunks: readonly PatchHunk[]): string;
35
+ export declare function applyPatchHunks(original: string, hunks: readonly PatchHunk[], label?: string): string;
36
36
  //# sourceMappingURL=patch.d.ts.map
@@ -188,16 +188,39 @@ function findSequence(lines, expected, from, endOfFile) {
188
188
  }
189
189
  return -1;
190
190
  }
191
+ function displayPatchLine(line) {
192
+ if (line.length === 0)
193
+ return '<empty>';
194
+ return line.replaceAll(' ', '[space]').replaceAll('\t', '[tab]');
195
+ }
196
+ function formatPatchExcerpt(lines, start, count) {
197
+ if (lines.length === 0)
198
+ return ' <empty file>';
199
+ const first = Math.max(0, Math.min(start, lines.length - 1));
200
+ return lines.slice(first, first + count)
201
+ .map((line, offset) => ` ${first + offset + 1}: ${displayPatchLine(line)}`)
202
+ .join('\n');
203
+ }
204
+ function mismatchMessage(label, hunkIndex, expected, actual, from, endOfFile) {
205
+ const expectedPreview = expected.slice(0, 12)
206
+ .map((line, index) => ` ${index + 1}: ${displayPatchLine(line)}`)
207
+ .join('\n');
208
+ const expectedSuffix = expected.length > 12 ? '\n ...' : '';
209
+ const excerptStart = endOfFile ? Math.max(0, actual.length - 6) : Math.max(0, from - 2);
210
+ const markerHint = expected.some(wanted => wanted.length > 0 && actual.includes(`-${wanted}`))
211
+ ? '\nHint: if a source line starts with a patch marker, repeat that marker after the operation marker; for example, `-- item` deletes the source line `- item`.'
212
+ : '';
213
+ return `could not find expected lines in ${label} hunk ${hunkIndex + 1} (search starts at line ${from + 1})\nExpected:\n${expectedPreview}${expectedSuffix}\nFile excerpt:\n${formatPatchExcerpt(actual, excerptStart, 10)}${markerHint}`;
214
+ }
191
215
  /** Apply parsed update hunks and return LF-normalized text. */
192
- export function applyPatchHunks(original, hunks) {
216
+ export function applyPatchHunks(original, hunks, label = 'file') {
193
217
  const value = splitText(original.replaceAll('\r\n', '\n'));
194
218
  let cursor = 0;
195
- for (const hunk of hunks) {
219
+ for (const [hunkIndex, hunk] of hunks.entries()) {
196
220
  const expected = hunk.lines.filter(line => line.kind !== 'add').map(line => line.text);
197
221
  const start = findSequence(value.lines, expected, cursor, hunk.endOfFile);
198
222
  if (start < 0) {
199
- const detail = expected.join('\n');
200
- invalid(`could not find expected lines${detail.length === 0 ? '' : `:\n${detail}`}`);
223
+ invalid(mismatchMessage(label, hunkIndex, expected, value.lines, cursor, hunk.endOfFile));
201
224
  }
202
225
  const replacement = hunk.lines.filter(line => line.kind !== 'delete').map(line => line.text);
203
226
  value.lines.splice(start, expected.length, ...replacement);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shuind/dsh-codex-harness",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
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",
@@ -69,8 +69,7 @@
69
69
  "@deepseek-ai/dsh-client-locale",
70
70
  "@deepseek-ai/dsh-client-runtime",
71
71
  "@deepseek-ai/dsh-client-ui-conversation",
72
- "@deepseek-ai/dsh-client-ui-settings",
73
- "@deepseek-ai/dsh-client-ui-slots"
72
+ "@deepseek-ai/dsh-client-ui-settings"
74
73
  ],
75
74
  "platform": "web"
76
75
  }
@@ -90,6 +89,7 @@
90
89
  "@deepseek-ai/dsh-credentials": ">=0.1.0-rc.8",
91
90
  "@deepseek-ai/dsh-fs": ">=0.0.1-rc.1",
92
91
  "@deepseek-ai/dsh-invariants": ">=0.0.1-rc.1",
92
+ "@deepseek-ai/dsh-jobs": ">=0.1.0-rc.8",
93
93
  "@deepseek-ai/dsh-llm": ">=0.1.0-rc.8",
94
94
  "@deepseek-ai/dsh-settings": ">=0.1.0-rc.8",
95
95
  "@deepseek-ai/dsh-sandbox": ">=0.0.1-rc.1",
@@ -100,6 +100,7 @@
100
100
  "@deepseek-ai/dsh-tool-web": ">=0.1.0-rc.8",
101
101
  "@deepseek-ai/dsh-tool-todo": ">=0.0.1-rc.1",
102
102
  "@deepseek-ai/dsh-terminal": ">=0.0.1-rc.3",
103
+ "@deepseek-ai/dsh-tool-jobs": ">=0.1.0-rc.8",
103
104
  "@deepseek-ai/dsh-tools": ">=0.0.1-rc.1"
104
105
  },
105
106
  "peerDependenciesMeta": {
@@ -133,6 +134,9 @@
133
134
  "@deepseek-ai/dsh-invariants": {
134
135
  "optional": true
135
136
  },
137
+ "@deepseek-ai/dsh-jobs": {
138
+ "optional": true
139
+ },
136
140
  "@deepseek-ai/dsh-llm": {
137
141
  "optional": true
138
142
  },
@@ -163,6 +167,9 @@
163
167
  "@deepseek-ai/dsh-terminal": {
164
168
  "optional": true
165
169
  },
170
+ "@deepseek-ai/dsh-tool-jobs": {
171
+ "optional": true
172
+ },
166
173
  "@deepseek-ai/dsh-tools": {
167
174
  "optional": true
168
175
  }
@@ -178,6 +185,7 @@
178
185
  "@deepseek-ai/dsh-credentials": "0.1.0-rc.8",
179
186
  "@deepseek-ai/dsh-fs": "0.1.0-rc.6",
180
187
  "@deepseek-ai/dsh-invariants": "0.1.0-rc.6",
188
+ "@deepseek-ai/dsh-jobs": "0.1.0-rc.8",
181
189
  "@deepseek-ai/dsh-llm": "0.1.0-rc.8",
182
190
  "@deepseek-ai/dsh-settings": "0.1.0-rc.8",
183
191
  "@deepseek-ai/dsh-sandbox": "0.1.0-rc.6",
@@ -188,6 +196,7 @@
188
196
  "@deepseek-ai/dsh-tool-web": "0.1.0-rc.8",
189
197
  "@deepseek-ai/dsh-tool-todo": "0.1.0-rc.6",
190
198
  "@deepseek-ai/dsh-terminal": "0.1.0-rc.6",
199
+ "@deepseek-ai/dsh-tool-jobs": "0.1.0-rc.8",
191
200
  "@deepseek-ai/dsh-tools": "0.1.0-rc.6",
192
201
  "@types/node": "^22.20.0",
193
202
  "@types/react": "~18.3.1",
@@ -21,6 +21,14 @@
21
21
  config:
22
22
  collaborationPrompt: false
23
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
+
24
32
  # The generic DSH pi-ai adapter still owns the user's configured provider,
25
33
  # endpoint, API key, and model. Codex's remote-first transport wrapper is
26
34
  # installed only in this Codex scope and falls back to the generic DSH path.
@@ -1,3 +1,3 @@
1
1
  name: Codex 模式
2
- description: 使用精简版 Codex 提示词、exec_command、write_stdin、apply_patch 和 update_plan,并保留 dsh Skills 与可插拔运行时。
2
+ description: 使用精简版 Codex 提示词、统一执行工具、后台任务控制和 dsh Skills
3
3
  order: 5