dsh-packycode-api 1.1.2 → 1.1.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.4
4
+
5
+ - **Carry the reviewed plan into the next CLI turn.** The plan previously existed only in `exit_plan_mode` arguments, which the delegated transcript dropped; a fresh CLI then saw only “Plan approved”. The latest complete plan and its matching review result are now recovered from durable native or adapter-generated PTC tool calls, so approval, revision, resume and fork keep their constraints. They are reserved before trimming older chat; an oversized plan/request fails explicitly rather than silently losing constraints. Unrelated tool arguments and arbitrary PTC programs are not forwarded.
6
+ - **Use the actual CLI permission protocol.** Plan runs now request `--permission-prompt-tool stdio` as well as `--permission-mode plan`, making `ExitPlanMode` available to the official print-mode CLI and routing its permission request to the host. A targeted ask rule prevents an existing `ExitPlanMode` allow rule from skipping the handoff. Repeated permission refusals are bounded, including native plan-mode tool-result denials, without counting ordinary execution errors or counting one refusal twice.
7
+ - **Plans belong to one invocation.** Each plan run creates one private temporary planning-artifact directory inside its working directory (the official CLI rejects custom plan directories outside the project root), and supplies it through additive `plansDirectory` settings while preserving the credential/config home and other permission/MCP settings. It never changes project code or project settings; an unwritable workspace fails explicitly rather than using a shared fallback. File fallback and submitted paths are restricted to that directory; shared/global plans and a prior run's recent file are never candidates. Owned resources are cleaned on completion, cancellation, startup failure and unload.
8
+ - **Review follows the host's tool presentation.** Native catalogs receive `exit_plan_mode`; PTC catalogs receive the real `run_code` transport calling `tools.exit_plan_mode`. Missing review transports fail closed instead of inventing an unavailable direct tool call.
9
+ - **Leave plan mode at the correct boundary.** Model preparation can follow a selected pending mode, but `claude_code` execution reads the currently committed mode. Approval's pending exit no longer gives later tools in the same batch edit/full permission before the next accepted step.
10
+ - **Secret-free release preflight.** The OIDC diagnostic now prints only identity claims and the registry exchange status, never the returned credential body, and fails when the publisher is rejected. Truncating a body before regex redaction could previously expose a token prefix. Added regressions for long credential bodies and the registry's successful `201` response. Tag publication now packs and publishes the verified versioned tarball, matching the locally inspected artifact rather than independently repacking the workspace.
11
+ - Added `scripts/cli-plan-check.mjs`, a real-official-CLI/local-fake-gateway check for stdio handoff, private plan files, concurrent ownership, native denials, MCP search and cleanup. It uses no real credentials or quota and does not start DSH.
12
+ - Validation: full offline suite, both bundles and the versioned tarball; official CLI 2.1.295 against local fixtures passed stdio plan handoff, existing-allow-rule interception, concurrent/private plan ownership, native denial limits, MCP and cleanup, plus the existing read-only/edit/full search checks. Actual bundled DSH 0.2.0-rc.2 plan/tool controllers and its Node PTC subprocess passed 50 offline assertions. The built HTTP route answered through `deepseek-flash`, `gpt-6.1-sol` and `gpt-6-astra`, and key rotation and secret-free RPC projections passed the gateway check. The available key does not cover Claude: actual Claude upstream and a running desktop DSH UI integration are not claimed.
13
+
14
+ ## 1.1.3
15
+
16
+ - **Plan review when the CLI has no ExitPlanMode.** Print mode often does not offer that tool, so a plan-mode turn wrote `~/.dsh/packycode-claude-cli/plans/*.md` and came back as ordinary text. A plan file written during the run is now submitted through DSH's `exit_plan_mode` when the CLI returns. An older file in that directory is ignored.
17
+
3
18
  ## 1.1.2
4
19
 
5
20
  - **Claude CLI follows DSH plan mode.** While the current session's plan mode is on (including a selection that applies on this step but is not logged yet), a Claude model turn and the `claude_code` tool run the local CLI with `--permission-mode plan`. That wins over the session permission preset and over an explicit `claudeCliMode` pin. The CLI may explore read-only; other permission prompts are denied, and after eight denials the run stops instead of hanging. `ExitPlanMode` is never allowed inside the CLI process. A submitted plan becomes the assistant's `exit_plan_mode` tool call, so DSH's existing review approves it and the next step implements. The delegation tool returns the plan text for the parent model to submit. Session titles stay read-only. A host without the plan-mode service behaves as before.
package/README.en.md CHANGED
@@ -44,7 +44,7 @@ Open the desktop application's **Plugins** page, click **Add plugin**, and enter
44
44
  dsh-packycode-api
45
45
  ```
46
46
 
47
- Choose the **official** install source, then click **Install**. To pin a version, enter `dsh-packycode-api@1.1.2`.
47
+ Choose the **official** install source, then click **Install**. To pin a version, enter `dsh-packycode-api@1.1.4`.
48
48
 
49
49
  Then **manually restart the desktop application**. Refreshing the page does not reload the plugin inside the Host process.
50
50
 
@@ -63,7 +63,7 @@ Choose a model from the `PackyCode` group. `deepseek-flash`, `gpt-6.1-sol`, and
63
63
 
64
64
  The **Plugins** page shows the installed state and version, and is also where you enable, disable, or uninstall.
65
65
 
66
- **For non-desktop profiles only:** `dsh plugin --profile <name> add dsh-packycode-api@1.1.2`. Do not use it for `desktop` — the CLI refuses it (`profile "desktop" is managed exclusively by the Electron application`).
66
+ **For non-desktop profiles only:** `dsh plugin --profile <name> add dsh-packycode-api@1.1.4`. Do not use it for `desktop` — the CLI refuses it (`profile "desktop" is managed exclusively by the Electron application`).
67
67
 
68
68
  A local checkout can be linked with `dsh plugin --profile <name> add link:/path/to/dsh-packycode-api`.
69
69
 
@@ -153,7 +153,7 @@ A model in the list does not mean the current token group allows it. When changi
153
153
  - The `cc` group accepts only the official Claude CLI. A normal API client gets `403 This API endpoint is only accessible via the official Claude CLI`. This plugin runs the local `claude` binary for that group instead of switching the token to `cc-sale` or pretending to be the official client. If the token has no group the official CLI may use, the three Claude models still will not run.
154
154
  - Selecting a Claude model when the CLI is missing fails that turn with an install hint. It does not surface as a missing binary name alone.
155
155
  - The CLI receives this plugin's endpoint and current key (`ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN`) and an isolated config directory, `~/.dsh/packycode-claude-cli`. Without that isolation, `env` in `~/.claude/settings.json` would override the plugin key.
156
- - CLI permission follows the session by default: `read-only` stays read-only, `workspace-write` may edit, and `danger-full-access` with approval disabled may use `full`. The CLI cannot ask the DSH user for approval, so a session that still asks never gets `full`. `claudeCliMode` can pin `read-only`, `edit`, or `full`. DSH plan mode wins over that mapping and over the pin: the CLI runs with `--permission-mode plan`, and `ExitPlanMode` submits the plan to DSH's review instead of editing in the same process. The previous mode returns after approval or `/plan off`. Session titles stay out of plan mode. An empty `claudeCliPath` is looked up automatically; an empty `claudeCliCwd` uses the session workspace.
156
+ - CLI permission follows the session by default: `read-only` stays read-only, `workspace-write` may edit, and `danger-full-access` with approval disabled may use `full`. The CLI cannot ask the DSH user for approval, so a session that still asks never gets `full`. `claudeCliMode` can pin `read-only`, `edit`, or `full`. DSH plan mode wins over that mapping and over the pin: the CLI runs read-only with `--permission-mode plan --permission-prompt-tool stdio`, using one private hidden temporary plan directory inside the working directory per invocation (the CLI rejects custom plan directories outside the project root). This stores only planning artifacts, changes no project code or settings, and fails explicitly for an unwritable workspace. `ExitPlanMode` hands the plan to DSH for review; it never starts implementation in that CLI process. File fallback reads only this invocation's directory, which is cleaned afterwards. Review works in native and PTC tool modes, and the complete plan and review feedback reach the next turn; exceeding the conversation budget fails explicitly instead of silently truncating constraints. Approval or `/plan off` takes effect from the next step; delegations in the same tool batch keep the current plan state. Session titles stay out of plan mode. An empty `claudeCliPath` is looked up automatically; an empty `claudeCliCwd` uses the session workspace.
157
157
  - Thinking and tool activity (`▸ Bash`, `▸ Read`, and `✗` on failure) appear in that turn's reasoning block. They are progress, not DSH tool calls. The CLI's own tools are outside the DSH sandbox.
158
158
  - The reasoning block opens with `▸ 启动 Claude Code CLI…` and then shows the CLI's version and model once it is up. When the gateway throttles or is busy (429, 503 `账号并发排队已满` / per-account wait queue full, and similar), the CLI retries up to 10 times on its own; each retry now shows as `↻ 网关返回 HTTP 503,CLI 自动重试 n/10` instead of looking hung. When the retries run out, the turn says PackyCode's Claude upstream is busy, not that the key is bad. A CLI that prints nothing for 2 minutes after starting is stopped and reported.
159
159
  - The conversation and its images reach the CLI over stdin (`--input-format stream-json`), not argv: a Windows command line is length-limited and cannot carry an image. Images in the conversation are sent to Claude as images, at the HTTP route's request size (at most 2048×2048, about 1 MiB each; at most 20 images and 20 MiB per turn, dropping the oldest first). An image left out is named with its read-only local copy, which the CLI can open with Read.
@@ -214,6 +214,8 @@ pnpm run check
214
214
 
215
215
  `node scripts/cli-search-check.mjs` runs the real CLI against a local test gateway and MCP bridge; Claude responses and search data are mocked by default, with no quota consumed. It checks all three permission modes, native-search exclusion and temporary-resource cleanup. `--live --backend deepseek` or `--live --backend gpt` sends the read-only mode's one search to the real backend (edit/full remain mocked). This does not verify actual Claude upstream turns or a running DSH integration.
216
216
 
217
+ `node scripts/cli-plan-check.mjs` checks plan mode with the real CLI and a local fake gateway: stdio handoff, concurrent plan ownership, invocation-only file fallback, stale-plan exclusion, actual denial limits and cleanup. It reads no real credentials, spends no quota and starts no DSH instance.
218
+
217
219
  Read [AGENTS.md](AGENTS.md) before agent-assisted changes or a release. Publishing is the [Publish workflow](.github/workflows/publish.yml), triggered by pushing a `v*` tag. Do not run `npm publish` locally.
218
220
 
219
221
  ## License
package/README.md CHANGED
@@ -48,7 +48,7 @@
48
48
  dsh-packycode-api
49
49
  ```
50
50
 
51
- 在**安装源**中选择**官方源**,然后点击**安装**。需要固定版本时填入 `dsh-packycode-api@1.1.2`。
51
+ 在**安装源**中选择**官方源**,然后点击**安装**。需要固定版本时填入 `dsh-packycode-api@1.1.4`。
52
52
 
53
53
  完成后**手动重启桌面应用**。仅刷新页面不会重新加载 Host 里的插件。
54
54
 
@@ -67,7 +67,7 @@ dsh-packycode-api
67
67
 
68
68
  桌面应用的**插件**页可以看到已安装状态与版本,也可以在那里启用、停用或卸载。
69
69
 
70
- **仅用于非 desktop profile:** `dsh plugin --profile <name> add dsh-packycode-api@1.1.2`。不要对 `desktop` 使用它 —— CLI 会直接拒绝(`profile "desktop" is managed exclusively by the Electron application`)。
70
+ **仅用于非 desktop profile:** `dsh plugin --profile <name> add dsh-packycode-api@1.1.4`。不要对 `desktop` 使用它 —— CLI 会直接拒绝(`profile "desktop" is managed exclusively by the Electron application`)。
71
71
 
72
72
  本地开发可以用绝对路径:`dsh plugin --profile <name> add link:/path/to/dsh-packycode-api`。
73
73
 
@@ -157,7 +157,7 @@ Claude Code CLI → 本机临时 MCP web_search → 插件选定的 DeepSeek/GPT
157
157
  - `cc`(Claude Code 专用)只接受官方 Claude CLI。用普通 API 客户端调 Claude 会 403:`This API endpoint is only accessible via the official Claude CLI`。本插件因此调用本机 `claude`,而不是改去 `cc-sale`,也不是伪造客户端身份。令牌如果没有开放给官方 CLI 的分组,这三个模型仍然不可用。
158
158
  - 选了 Claude 模型但本机没有 CLI 时,这一回合会直接说明未安装,并指向设置页的安装按钮。
159
159
  - CLI 使用插件配置的地址和当前 Key(`ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN`),以及独立配置目录 `~/.dsh/packycode-claude-cli`。不隔离的话,`~/.claude/settings.json` 里的 `env` 会盖掉插件 Key。
160
- - CLI 权限默认跟随当前会话:`read-only` → 只读,`workspace-write` → 可编辑,`danger-full-access` 且不再审批 → `full`。CLI 不能向 DSH 弹审批,所以只要会话还要审批,就不会给它 `full`。也可把设置项 `claudeCliMode` 固定成 `read-only` / `edit` / `full`。DSH 计划模式优先于该映射和这个固定档:CLI 以 `--permission-mode plan` 只读探索,调用 `ExitPlanMode` 后把计划交给 DSH 审阅,批准或 `/plan off` 之后才恢复原来的档位。会话标题不进入计划模式。`claudeCliPath` 留空则自动查找;`claudeCliCwd` 留空则用当前会话工作区。
160
+ - CLI 权限默认跟随当前会话:`read-only` → 只读,`workspace-write` → 可编辑,`danger-full-access` 且不再审批 → `full`。CLI 不能向 DSH 弹审批,所以只要会话还要审批,就不会给它 `full`。也可把设置项 `claudeCliMode` 固定成 `read-only` / `edit` / `full`。DSH 计划模式优先于该映射和这个固定档:CLI 以 `--permission-mode plan --permission-prompt-tool stdio` 只读探索,每轮在工作目录内创建一个独占的隐藏临时计划目录(CLI 不接受工作区外的自定义计划目录);只存计划工件,不改项目代码或设置,工作区不可写时明确失败。`ExitPlanMode` 交给 DSH 审阅,不允许 CLI 在同一进程开始实施;只在本轮目录中查找未显式提交的计划,结束时清理。原生和 PTC 工具模式都支持审阅,完整计划及审阅反馈会带到下一轮;超过对话预算会明确报错,不静默截断约束。批准或 `/plan off` 在下一步生效,同一工具批次内的委派仍遵守当前计划状态。会话标题不进入计划模式。`claudeCliPath` 留空则自动查找;`claudeCliCwd` 留空则用当前会话工作区。
161
161
  - 思考过程和工具调用(`▸ Bash`、`▸ Read`,失败时带 `✗`)会出现在这一回合的思考区。它们是进度,不是 DSH 的工具调用。CLI 自己的工具不受 DSH 沙箱约束。
162
162
  - 思考区第一行是「▸ 启动 Claude Code CLI…」,CLI 起来后显示版本和模型。网关限流或繁忙(429、503 `账号并发排队已满` 等)时,CLI 会自己重试最多 10 次,每次重试都会显示成 `↻ 网关返回 HTTP 503,CLI 自动重试 n/10`,不再像卡住。重试用完仍失败时,会说明是 PackyCode 的 Claude 上游繁忙,不是 Key 的问题。CLI 启动后 2 分钟内没有任何输出会被停止并报告原因。
163
163
  - 对话记录和图片通过 stdin 交给 CLI(`--input-format stream-json`),不走命令行参数:Windows 命令行长度有上限,而且命令行带不了图片。对话里的图片会以图片形式发给 Claude(与 HTTP 路由相同的压缩规格:每张不超过 2048×2048、约 1 MiB;一回合最多 20 张、合计 20 MiB,超出时先舍弃最早的)。没随消息发出的图片会写明本机只读副本的路径,CLI 需要时可以用 Read 读取。
@@ -218,6 +218,8 @@ pnpm run check
218
218
 
219
219
  Claude CLI 的 MCP 接口验证用 `node scripts/cli-search-check.mjs`:运行真实 CLI,普通 Claude 响应和搜索数据默认由本地测试服务模拟,不消费额度;会检查三种权限模式、原生搜索禁用和临时资源清理。加 `--live --backend deepseek` 或 `--live --backend gpt` 会让只读模式的那一次搜索走真实后端(其它两种模式仍模拟)。这不等于验证了真实 Claude 上游对话或运行中的 DSH 集成。
220
220
 
221
+ 计划模式另用 `node scripts/cli-plan-check.mjs` 验证:真实 CLI 对接本地模拟网关,检查 stdio 计划提交、并发计划归属、本轮文件回退、旧计划隔离、实际拒绝次数上限和清理;不读取真实凭据、不消费额度、不启动 DSH。
222
+
221
223
  使用 Agent 修改或发布时,先读 [AGENTS.md](AGENTS.md)。发布由推送 `v*` 标签触发 [Publish workflow](.github/workflows/publish.yml),不要在本机执行 `npm publish`。
222
224
 
223
225
  ## License
package/lib/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import { homedir, tmpdir } from "node:os";
3
- import { isAbsolute, join, posix, sep, win32 } from "node:path";
3
+ import { dirname, isAbsolute, join, posix, resolve, sep, win32 } from "node:path";
4
4
  import z from "@deepseek-ai/schemastery";
5
5
  import { HarnessError, LlmAdapter, LlmError, QUOTA_EXCEEDED_CODE, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
6
6
  import { spawn } from "node:child_process";
7
- import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
7
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
8
8
  import { mkdtemp, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
9
9
  import { createServer } from "node:http";
10
10
  import { defineTool } from "@deepseek-ai/dsh-tools";
@@ -1275,12 +1275,104 @@ function hostPath(lookup) {
1275
1275
  return;
1276
1276
  }
1277
1277
  }
1278
+ /** The only PTC program this adapter emits; its JSON literal also works in Python. */
1279
+ const PLAN_REVIEW_PROGRAM_PREFIX = "return await tools.exit_plan_mode(";
1280
+ /** Parse durable arguments without evaluating model-authored code. */
1281
+ function parsedArguments(value) {
1282
+ try {
1283
+ return typeof value === "string" ? JSON.parse(value) : value;
1284
+ } catch {
1285
+ return;
1286
+ }
1287
+ }
1288
+ /**
1289
+ * Recover only the public plan, never arbitrary tool arguments or PTC code.
1290
+ * The PTC allowlist is deliberately the exact, single-call program we emit.
1291
+ */
1292
+ function planOfCall(block) {
1293
+ if (block?.type !== "tool-call") return void 0;
1294
+ const args = parsedArguments(block.arguments);
1295
+ let plan;
1296
+ if (block.name === "exit_plan_mode") plan = args?.plan;
1297
+ else if (block.name === "run_code" && typeof args?.code === "string") {
1298
+ const code = args.code;
1299
+ if (!code.startsWith(PLAN_REVIEW_PROGRAM_PREFIX) || !code.endsWith(")")) return void 0;
1300
+ const review = parsedArguments(code.slice(34, -1));
1301
+ if (review === null || typeof review !== "object" || Object.keys(review).length !== 1) return void 0;
1302
+ plan = review.plan;
1303
+ }
1304
+ return typeof plan === "string" && plan.trim() !== "" ? plan : void 0;
1305
+ }
1306
+ /** Keep the latest complete plan and its review from the persisted model transcript. */
1307
+ function planReviewContext(messages) {
1308
+ let latest;
1309
+ for (const message of messages) {
1310
+ if (message?.role !== "assistant") continue;
1311
+ for (const block of Array.isArray(message.content) ? message.content : []) {
1312
+ const plan = planOfCall(block);
1313
+ if (plan !== void 0) latest = {
1314
+ plan,
1315
+ id: block.id
1316
+ };
1317
+ }
1318
+ }
1319
+ if (latest === void 0) return void 0;
1320
+ let review;
1321
+ for (const message of messages) {
1322
+ if (message?.role !== "user") continue;
1323
+ for (const block of Array.isArray(message.content) ? message.content : []) {
1324
+ if (block?.type !== "tool-result" || latest.id === void 0 || block.toolCallId !== latest.id) continue;
1325
+ const text = typeof block.content === "string" ? block.content : (Array.isArray(block.content) ? block.content : []).filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n");
1326
+ review = `[计划审阅${block.isError === true ? "未批准/失败" : "结果"}]\n${text}`;
1327
+ }
1328
+ }
1329
+ return {
1330
+ id: latest.id,
1331
+ turn: {
1332
+ label: "计划审阅上下文",
1333
+ parts: [{
1334
+ kind: "text",
1335
+ text: `[最近提交的完整计划;提交本身不代表已批准]\n${latest.plan}\n\n${review ?? "[尚无计划审阅结果;不要把提交视为实施许可]"}`
1336
+ }]
1337
+ }
1338
+ };
1339
+ }
1340
+ /**
1341
+ * Select the review transport from this request's actual direct-tool catalog.
1342
+ * `run_code` binds `tools` in both supported host language flavors.
1343
+ */
1344
+ function planReviewCall(options, plan) {
1345
+ if (plan.trim() === "") throw new Error("Claude Code submitted an empty plan; no review or implementation was authorized.");
1346
+ const context = planReviewContext([{
1347
+ role: "assistant",
1348
+ content: [{
1349
+ type: "tool-call",
1350
+ name: "exit_plan_mode",
1351
+ arguments: { plan }
1352
+ }]
1353
+ }]).turn;
1354
+ if (textLength(context.parts) + context.label.length + 3 >= MAX_PROMPT_CHARS$1) throw new Error("The submitted plan exceeds the CLI delegation budget (48,000 characters). Shorten or split it before review; no plan constraints were truncated and no implementation was authorized.");
1355
+ const tools = Array.isArray(options?.tools) ? options.tools : [];
1356
+ if (tools.some((tool) => tool?.name === "exit_plan_mode")) return {
1357
+ name: "exit_plan_mode",
1358
+ args: JSON.stringify({ plan })
1359
+ };
1360
+ if (tools.some((tool) => tool?.name === "run_code")) return {
1361
+ name: "run_code",
1362
+ args: JSON.stringify({
1363
+ code: `${PLAN_REVIEW_PROGRAM_PREFIX}${JSON.stringify({ plan })})`,
1364
+ description: "Present completed plan for user review"
1365
+ })
1366
+ };
1367
+ throw new Error("Claude Code submitted a plan, but this request has neither exit_plan_mode nor run_code available for host review; no implementation was authorized.");
1368
+ }
1278
1369
  /**
1279
1370
  * Flatten one message's content into ordered parts: text, image, and file.
1280
1371
  * Tool calls and results become short text so a turn that started on another
1281
- * model still reads coherently; reasoning is not carried.
1372
+ * model still reads coherently; reasoning is not carried. The latest review
1373
+ * result is carried in full separately, not through the general 8k tool cap.
1282
1374
  */
1283
- function collectParts(blocks, parts) {
1375
+ function collectParts(blocks, parts, reviewId) {
1284
1376
  for (const block of Array.isArray(blocks) ? blocks : []) {
1285
1377
  if (block === null || typeof block !== "object") continue;
1286
1378
  switch (block.type) {
@@ -1314,6 +1406,7 @@ function collectParts(blocks, parts) {
1314
1406
  });
1315
1407
  break;
1316
1408
  case "tool-result": {
1409
+ if (reviewId !== void 0 && block.toolCallId === reviewId) break;
1317
1410
  if (typeof block.content === "string") {
1318
1411
  parts.push({
1319
1412
  kind: "text",
@@ -1355,10 +1448,12 @@ const textLength = (parts) => parts.reduce((sum, part) => sum + (part.kind === "
1355
1448
  */
1356
1449
  function delegatedTurns(options) {
1357
1450
  const messages = Array.isArray(options?.messages) ? options.messages : [];
1451
+ const review = planReviewContext(messages);
1452
+ const reserved = review === void 0 ? 0 : textLength(review.turn.parts) + review.turn.label.length + 3;
1358
1453
  const turns = [];
1359
1454
  for (const message of messages) {
1360
1455
  if (message === null || typeof message !== "object" || message.role === "system") continue;
1361
- const parts = collectParts(message.content, []);
1456
+ const parts = collectParts(message.content, [], review?.id);
1362
1457
  if (parts.length === 0 || textLength(parts) === 0 && !parts.some((part) => part.kind !== "text")) continue;
1363
1458
  const label = message.role === "user" ? "用户" : message.role === "assistant" ? "助手" : String(message.role);
1364
1459
  turns.push({
@@ -1366,8 +1461,11 @@ function delegatedTurns(options) {
1366
1461
  parts
1367
1462
  });
1368
1463
  }
1464
+ const newest = turns.at(-1);
1465
+ const newestSize = newest === void 0 ? 0 : textLength(newest.parts) + newest.label.length + 3;
1466
+ if (review !== void 0 && reserved + newestSize > MAX_PROMPT_CHARS$1) throw new Error("The complete plan, its review, and the latest request exceed the CLI delegation budget (48,000 characters). Shorten or split the plan/request before continuing; plan constraints were not truncated.");
1369
1467
  const kept = [];
1370
- let used = 0;
1468
+ let used = reserved;
1371
1469
  for (let index = turns.length - 1; index >= 0; index -= 1) {
1372
1470
  const turn = turns[index];
1373
1471
  const size = textLength(turn.parts) + turn.label.length + 3;
@@ -1383,9 +1481,11 @@ function delegatedTurns(options) {
1383
1481
  });
1384
1482
  break;
1385
1483
  }
1484
+ const truncated = kept.length < turns.length || kept[0]?.clipped === true;
1485
+ if (review !== void 0) kept.unshift(review.turn);
1386
1486
  return {
1387
1487
  turns: kept,
1388
- truncated: kept.length < turns.length || kept[0]?.clipped === true
1488
+ truncated
1389
1489
  };
1390
1490
  }
1391
1491
  /** Keep the last `budget` characters of a turn's text, and every part after them. */
@@ -1430,7 +1530,7 @@ const plainDescription = (part) => part.kind === "image" ? `[图片 ${attachment
1430
1530
  function delegationInstructions(options, cwd, { plan = false } = {}) {
1431
1531
  const system = typeof options?.system === "string" && options.system.trim() !== "" ? options.system.trim() : "";
1432
1532
  return [
1433
- plan ? "本回合 DeepSeek Harness 处于计划模式。只做只读探索,把完整实施计划写成以一级标题开头的 Markdown,写入 CLI 指定的计划文件,然后调用 ExitPlanMode 提交审阅。不要修改项目、不要开始实施。用户消息是 DeepSeek Harness 会话的近期对话记录,最后一条是当前请求;其中的图片已作为图片随消息附上,「只读副本」路径也可以用 Read 工具读取。" : "本回合由 DeepSeek Harness 委派给 Claude Code 执行。用户消息是 DeepSeek Harness 会话的近期对话记录,最后一条是当前请求;其中的图片已作为图片随消息附上,「只读副本」路径也可以用 Read 工具读取。请用你自己的工具完成任务,直接输出最终结果;不要反问、不要复述流程。",
1533
+ plan ? "本回合 DeepSeek Harness 处于计划模式。只做只读探索,把完整实施计划写成以一级标题开头的 Markdown,写入 CLI 指定的计划文件。若有 ExitPlanMode 就调用它;没有也在写完后停下来。不要修改项目、不要开始实施。DeepSeek Harness 会用这份计划打开审阅。用户消息是 DeepSeek Harness 会话的近期对话记录,最后一条是当前请求;其中的图片已作为图片随消息附上,「只读副本」路径也可以用 Read 工具读取。" : "本回合由 DeepSeek Harness 委派给 Claude Code 执行。用户消息是 DeepSeek Harness 会话的近期对话记录,最后一条是当前请求;其中的图片已作为图片随消息附上,「只读副本」路径也可以用 Read 工具读取。请用你自己的工具完成任务,直接输出最终结果;不要反问、不要复述流程。",
1434
1534
  cwd === void 0 ? void 0 : `工作目录:${cwd}`,
1435
1535
  system === "" ? void 0 : `补充说明(来自 DeepSeek Harness,其中提到的工具你未必拥有):${system.slice(0, 2e3)}`
1436
1536
  ].filter((part) => part !== void 0).join("\n");
@@ -1898,7 +1998,8 @@ function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS
1898
1998
  default: break;
1899
1999
  }
1900
2000
  if (submittedPlan !== void 0) {
1901
- yield* blocks.toolCall(`plan-${randomUUID()}`, "exit_plan_mode", JSON.stringify({ plan: submittedPlan }));
2001
+ const review = planReviewCall(options, submittedPlan);
2002
+ yield* blocks.toolCall(`plan-${randomUUID()}`, review.name, review.args);
1902
2003
  yield {
1903
2004
  type: "usage",
1904
2005
  usage: turnUsage(lastRequest, finished)
@@ -2304,10 +2405,11 @@ function resolveSpawnCommand(binary, { platform = process.platform, exists = exi
2304
2405
  * consumes the streaming protocol, and instructions appended to the CLI's
2305
2406
  * own system prompt ({@link DEFAULT_APPEND_SYSTEM_PROMPT} when blank). The
2306
2407
  * CLI's system prompt and tool set are never replaced: they are part of
2307
- * what the official client sends.
2408
+ * what the official client sends. Plan runs supply an absolute private
2409
+ * planDirectory below the child's working directory.
2308
2410
  * @returns argv after the binary name.
2309
2411
  */
2310
- function buildClaudeArgs({ mode = DEFAULT_CLAUDE_MODE, model, stream = false, appendSystemPrompt, searchBridge, disableNativeSearch = false } = {}) {
2412
+ function buildClaudeArgs({ mode = DEFAULT_CLAUDE_MODE, model, stream = false, appendSystemPrompt, searchBridge, disableNativeSearch = false, planDirectory } = {}) {
2311
2413
  const spec = CLAUDE_CLI_MODES[mode];
2312
2414
  if (spec === void 0) throw new Error(`claude_code mode must be one of ${Object.keys(CLAUDE_CLI_MODES).join(", ")}`);
2313
2415
  const args = stream ? [
@@ -2334,16 +2436,31 @@ function buildClaudeArgs({ mode = DEFAULT_CLAUDE_MODE, model, stream = false, ap
2334
2436
  args.push("--permission-mode", spec.permissionMode);
2335
2437
  if (searchBridge !== void 0) args.push("--allowedTools", searchBridge.toolName);
2336
2438
  } else args.push("--allowedTools", ...spec.allowedTools, ...searchBridge === void 0 ? [] : [searchBridge.toolName]);
2439
+ if (mode === "plan") {
2440
+ args.push("--permission-prompt-tool", "stdio");
2441
+ if (planDirectory !== void 0 && (typeof planDirectory !== "string" || !isAbsolute(planDirectory))) throw new Error("claude_code plan directory must be an absolute path");
2442
+ args.push("--settings", JSON.stringify({
2443
+ ...planDirectory === void 0 ? {} : { plansDirectory: planDirectory },
2444
+ permissions: { ask: ["ExitPlanMode"] }
2445
+ }));
2446
+ }
2337
2447
  if (disableNativeSearch || searchBridge !== void 0) args.push("--disallowedTools", "WebSearch");
2338
2448
  if (searchBridge !== void 0) args.push("--mcp-config", searchBridge.configPath);
2339
2449
  return args;
2340
2450
  }
2341
- /** How many permission prompts one plan-mode run will refuse before stopping. */
2451
+ /** How many permission denials one plan-mode run may recover from. */
2342
2452
  const MAX_PLAN_DENIALS = 8;
2343
- /** A plan file larger than this is not read into the turn. */
2453
+ /** A plan file or submitted plan larger than this is not returned into the turn. */
2344
2454
  const PLAN_FILE_MAX_BYTES = 2e5;
2345
- const PLAN_FILE_CLOCK_SKEW_MS = 2e3;
2455
+ const PLAN_DIRECTORY_PREFIX = ".dsh-packycode-plan-";
2456
+ const PLAN_FS = Object.freeze({
2457
+ mkdtempSync,
2458
+ realpathSync,
2459
+ lstatSync,
2460
+ rmSync
2461
+ });
2346
2462
  const PLAN_EXIT_DENY = "Write the plan to the plan file first, starting with a # heading, then call ExitPlanMode again.";
2463
+ const PLAN_TOO_LARGE_DENY = "The plan exceeds the 200000-byte review limit. Write a complete, shorter plan without dropping constraints, then call ExitPlanMode again.";
2347
2464
  const PLAN_ACTION_DENY = "Plan mode is active. Do not edit files or run commands that change the system. Continue read-only exploration, write the plan file, and call ExitPlanMode.";
2348
2465
  const PLAN_DIALOG_ERROR = "Plan mode cannot ask the user from the CLI. Decide and continue, or call ExitPlanMode.";
2349
2466
  const PLAN_DENY_LIMIT_MESSAGE = "claude_code plan mode stopped after too many denied permission prompts";
@@ -2357,11 +2474,58 @@ function planMarkdown(value) {
2357
2474
  if (typeof value !== "string") return void 0;
2358
2475
  const trimmed = value.trim();
2359
2476
  if (trimmed === "") return void 0;
2360
- const bounded = trimmed.length > PLAN_FILE_MAX_BYTES ? trimmed.slice(0, PLAN_FILE_MAX_BYTES) : trimmed;
2361
- return /^#\s+\S/u.test(bounded) ? bounded : `# Plan\n\n${bounded}`;
2477
+ const markdown = /^#\s+\S/u.test(trimmed) ? trimmed : `# Plan\n\n${trimmed}`;
2478
+ return Buffer.byteLength(markdown) > PLAN_FILE_MAX_BYTES ? void 0 : markdown;
2479
+ }
2480
+ /** Own exactly one newly created, owner-only directory, never a shared CLI home. */
2481
+ function createOwnedPlanDirectory(fs, cwd) {
2482
+ const parent = fs.realpathSync(cwd ?? process.cwd());
2483
+ const directory = fs.mkdtempSync(join(parent, PLAN_DIRECTORY_PREFIX));
2484
+ if (!isAbsolute(directory) || dirname(directory) !== parent || !directory.startsWith(join(parent, PLAN_DIRECTORY_PREFIX))) throw new Error("PackyCode plan directory ownership could not be established");
2485
+ const identity = fs.lstatSync(directory);
2486
+ if (!identity.isDirectory() || identity.isSymbolicLink()) throw new Error("PackyCode plan directory ownership could not be established");
2487
+ const canonical = directory;
2488
+ let removed = false;
2489
+ const isOwned = () => {
2490
+ try {
2491
+ const current = fs.lstatSync(directory);
2492
+ return current.isDirectory() && !current.isSymbolicLink() && current.dev === identity.dev && current.ino === identity.ino && fs.realpathSync(directory) === canonical;
2493
+ } catch {
2494
+ return false;
2495
+ }
2496
+ };
2497
+ const owned = {
2498
+ directory: canonical,
2499
+ isOwned,
2500
+ close() {
2501
+ if (removed) return;
2502
+ let current;
2503
+ try {
2504
+ current = fs.lstatSync(directory);
2505
+ } catch (error) {
2506
+ if (error?.code === "ENOENT") {
2507
+ removed = true;
2508
+ return;
2509
+ }
2510
+ throw error;
2511
+ }
2512
+ if (!current.isDirectory() || current.isSymbolicLink() || !isOwned()) throw new Error("PackyCode plan directory ownership changed; refusing to remove it");
2513
+ fs.rmSync(canonical, {
2514
+ recursive: true,
2515
+ force: true
2516
+ });
2517
+ removed = true;
2518
+ }
2519
+ };
2520
+ if (!isOwned()) {
2521
+ owned.close();
2522
+ throw new Error("PackyCode plan directory ownership could not be established");
2523
+ }
2524
+ return owned;
2362
2525
  }
2363
- /** Whether `file` is the root itself or a path inside it, after resolving links. */
2526
+ /** Whether a file stays inside the immutable canonical directory bound to the run. */
2364
2527
  function pathInside(root, file, realpath) {
2528
+ if (typeof root !== "string" || !isAbsolute(root) || typeof file !== "string" || !isAbsolute(file)) return false;
2365
2529
  let rootReal;
2366
2530
  let fileReal;
2367
2531
  try {
@@ -2370,42 +2534,104 @@ function pathInside(root, file, realpath) {
2370
2534
  } catch {
2371
2535
  return false;
2372
2536
  }
2537
+ if (rootReal !== resolve(root)) return false;
2373
2538
  const prefix = rootReal.endsWith(sep) ? rootReal : `${rootReal}${sep}`;
2374
- return fileReal === rootReal || fileReal.startsWith(prefix);
2539
+ return fileReal.startsWith(prefix);
2540
+ }
2541
+ /** A descriptor-based bounded read also rejects a replaced leaf before reading it. */
2542
+ function readBoundedPlanFile(file, expected) {
2543
+ const fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
2544
+ try {
2545
+ const info = fstatSync(fd);
2546
+ if (!info.isFile() || expected !== void 0 && (info.dev !== expected.dev || info.ino !== expected.ino)) return void 0;
2547
+ if (info.size > PLAN_FILE_MAX_BYTES) throw Object.assign(/* @__PURE__ */ new Error(PLAN_TOO_LARGE_DENY), { code: "CLAUDE_PLAN_TOO_LARGE" });
2548
+ const bytes = Buffer.allocUnsafe(200001);
2549
+ let length = 0;
2550
+ while (length < bytes.length) {
2551
+ const count = readSync(fd, bytes, length, bytes.length - length, null);
2552
+ if (count === 0) break;
2553
+ length += count;
2554
+ }
2555
+ if (length > PLAN_FILE_MAX_BYTES) throw Object.assign(/* @__PURE__ */ new Error(PLAN_TOO_LARGE_DENY), { code: "CLAUDE_PLAN_TOO_LARGE" });
2556
+ return bytes.subarray(0, length).toString("utf8");
2557
+ } finally {
2558
+ closeSync(fd);
2559
+ }
2375
2560
  }
2376
2561
  /**
2377
- * The plan a CLI `ExitPlanMode` request is submitting.
2378
- *
2379
- * The tool's own schema does not carry the plan; the CLI injects `plan` and
2380
- * `planFilePath` from the file it told the model to write. Text on the request
2381
- * wins. A path is read only when it resolves inside this run's config home or
2382
- * `~/.claude/plans`, is a markdown file written for this run, and is bounded.
2383
- * Anything else is ignored — a model-supplied path is not a licence to read
2384
- * the disk.
2385
- * @param input - the control request's `input`.
2386
- * @param options - config home, when the run started, and file-system seams.
2387
- * @returns plan markdown, or undefined when the request has none yet.
2388
- */
2389
- function readSubmittedPlan(input, { configDir, startedAt = Date.now(), home = homedir(), readFile = (file) => readFileSync(file, "utf8"), stat = statSync, realpath = realpathSync } = {}) {
2390
- const inline = planMarkdown(input?.plan);
2391
- if (inline !== void 0) return inline;
2562
+ * The plan a CLI ExitPlanMode request submits. Inline text remains valid; a
2563
+ * file must resolve inside this invocation's explicitly bound canonical
2564
+ * planDirectory. Shared config homes and ~/.claude/plans are never searched.
2565
+ * The caller supplies scrub so secrets are removed before the size check.
2566
+ * @param input - the control request's input.
2567
+ * @param options - the bound private plan directory, redactor, and filesystem seams.
2568
+ * @returns bounded plan markdown, or undefined when the request has none yet.
2569
+ */
2570
+ function readSubmittedPlan(input, { planDirectory, scrub = (value) => value, onRejected = () => void 0, readFile = readBoundedPlanFile, stat = statSync, realpath = realpathSync } = {}) {
2571
+ if (typeof input?.plan === "string") {
2572
+ const text = scrub(input.plan);
2573
+ const inline = planMarkdown(text);
2574
+ if (inline !== void 0) return inline;
2575
+ if (text.trim() !== "") {
2576
+ onRejected("too-large");
2577
+ return;
2578
+ }
2579
+ }
2392
2580
  const requested = typeof input?.planFilePath === "string" ? input.planFilePath : "";
2393
- if (requested === "") return void 0;
2394
- if (![configDir, join(home, ".claude", "plans")].filter((root) => typeof root === "string" && root !== "").some((root) => pathInside(root, requested, realpath))) return void 0;
2395
- let file;
2581
+ if (!pathInside(planDirectory, requested, realpath)) return void 0;
2396
2582
  try {
2397
- file = realpath(requested);
2583
+ const file = realpath(requested);
2584
+ if (!file.toLowerCase().endsWith(".md")) return void 0;
2398
2585
  const info = stat(file);
2399
- if (!info.isFile() || info.size > PLAN_FILE_MAX_BYTES || info.mtimeMs < startedAt - PLAN_FILE_CLOCK_SKEW_MS) return void 0;
2400
- } catch {
2586
+ if (!info.isFile()) return void 0;
2587
+ if (info.size > PLAN_FILE_MAX_BYTES) {
2588
+ onRejected("too-large");
2589
+ return;
2590
+ }
2591
+ const text = readFile(file, info);
2592
+ if (typeof text !== "string") return void 0;
2593
+ const safe = scrub(text);
2594
+ const plan = planMarkdown(safe);
2595
+ if (plan === void 0 && safe.trim() !== "") onRejected("too-large");
2596
+ return plan;
2597
+ } catch (error) {
2598
+ if (error?.code === "CLAUDE_PLAN_TOO_LARGE") onRejected("too-large");
2401
2599
  return;
2402
2600
  }
2403
- if (!file.toLowerCase().endsWith(".md")) return void 0;
2601
+ }
2602
+ /**
2603
+ * A file-only completion from the CLI is accepted only from this run's new
2604
+ * private plans directory. There is no shared-directory/mtime fallback. If
2605
+ * more than one eligible file exists, do not guess which session submitted it.
2606
+ * @param options - the bound private plan directory, redactor, and filesystem seams.
2607
+ * @returns plan markdown, or undefined when absent or ambiguous.
2608
+ */
2609
+ function findPlanWrittenDuringRun({ planDirectory, scrub = (value) => value, onRejected = () => void 0, readdir = readdirSync, readFile = readBoundedPlanFile, stat = statSync, realpath = realpathSync } = {}) {
2610
+ if (typeof planDirectory !== "string" || !isAbsolute(planDirectory)) return void 0;
2611
+ let names;
2404
2612
  try {
2405
- return planMarkdown(readFile(file));
2613
+ names = readdir(planDirectory);
2406
2614
  } catch {
2407
2615
  return;
2408
2616
  }
2617
+ let found;
2618
+ for (const name of names) {
2619
+ if (typeof name !== "string" || !name.toLowerCase().endsWith(".md")) continue;
2620
+ const candidate = join(planDirectory, name);
2621
+ if (dirname(candidate) !== planDirectory) continue;
2622
+ const text = readSubmittedPlan({ planFilePath: candidate }, {
2623
+ planDirectory,
2624
+ scrub,
2625
+ onRejected,
2626
+ readFile,
2627
+ stat,
2628
+ realpath
2629
+ });
2630
+ if (text === void 0) continue;
2631
+ if (found !== void 0) return void 0;
2632
+ found = text;
2633
+ }
2634
+ return found;
2409
2635
  }
2410
2636
  /**
2411
2637
  * Build what the CLI reads from stdin for one run.
@@ -2534,6 +2760,22 @@ function toolResultText(content) {
2534
2760
  return content.map((part) => part?.type === "text" && typeof part.text === "string" ? part.text : "").join(" ");
2535
2761
  }
2536
2762
  /**
2763
+ * Native plan/permission gates sometimes produce no control_request at all.
2764
+ * Recognize their specific messages, never generic is_error, ENOENT, EACCES,
2765
+ * a failed search, or a command's nonzero exit. An associated assistant tool
2766
+ * use is required, so arbitrary user text cannot spend the denial budget.
2767
+ */
2768
+ function nativePermissionDenial(name, text) {
2769
+ if (typeof name !== "string") return false;
2770
+ if ([
2771
+ "Write",
2772
+ "Edit",
2773
+ "NotebookEdit"
2774
+ ].includes(name) && /^Cannot (?:write to|edit|modify) [\s\S]+ while in plan mode\.(?:\s|$)/u.test(text)) return true;
2775
+ if (["Bash", "PowerShell"].includes(name) && text.startsWith("Auto mode could not evaluate this action and is blocking it for safety")) return true;
2776
+ return text.startsWith(PLAN_ACTION_DENY) || text.startsWith(PLAN_EXIT_DENY) || /^Permission to use [^\n]+ (?:has been|was) denied\b/u.test(text) || /^Claude requested permissions to use [^\n]+, but you haven['’]t granted it yet\b/u.test(text) || /^The user doesn['’]t want to proceed with this tool use\b/u.test(text);
2777
+ }
2778
+ /**
2537
2779
  * Read one `--output-format stream-json` line.
2538
2780
  *
2539
2781
  * The CLI prints both partial deltas (`stream_event`) and each finished
@@ -2656,7 +2898,7 @@ function describeRunKey(credential) {
2656
2898
  * seams.
2657
2899
  * @returns `{ run(options), stream(options) }`.
2658
2900
  */
2659
- function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_CLAUDE_CONFIG_DIR, resolveCredential, spawnImpl = spawn, timeoutMs = DEFAULT_CLAUDE_TIMEOUT_MS, startupTimeoutMs = DEFAULT_CLAUDE_STARTUP_TIMEOUT_MS, platform = process.platform, createSearchBridge } = {}) {
2901
+ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_CLAUDE_CONFIG_DIR, resolveCredential, spawnImpl = spawn, timeoutMs = DEFAULT_CLAUDE_TIMEOUT_MS, startupTimeoutMs = DEFAULT_CLAUDE_STARTUP_TIMEOUT_MS, platform = process.platform, createSearchBridge, planFs = PLAN_FS } = {}) {
2660
2902
  if (typeof resolveCredential !== "function") throw new Error("the Claude Code CLI runner needs resolveCredential");
2661
2903
  const owners = /* @__PURE__ */ new Set();
2662
2904
  let disposed = false;
@@ -2689,8 +2931,8 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
2689
2931
  settled: false,
2690
2932
  denies: 0
2691
2933
  };
2692
- const startedAt = Date.now();
2693
2934
  let child;
2935
+ let plans;
2694
2936
  let bridge;
2695
2937
  let bridgeClosing;
2696
2938
  let pendingBridge;
@@ -2743,13 +2985,18 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
2743
2985
  owners.delete(owner);
2744
2986
  done();
2745
2987
  });
2988
+ plans?.close();
2746
2989
  return;
2747
2990
  }
2748
2991
  try {
2749
2992
  await closeBridge();
2750
2993
  } finally {
2751
- owners.delete(owner);
2752
- done();
2994
+ try {
2995
+ plans?.close();
2996
+ } finally {
2997
+ owners.delete(owner);
2998
+ done();
2999
+ }
2753
3000
  }
2754
3001
  };
2755
3002
  const checkAborted = () => {
@@ -2766,6 +3013,10 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
2766
3013
  }
2767
3014
  };
2768
3015
  try {
3016
+ if (mode === "plan") {
3017
+ plans = createOwnedPlanDirectory(planFs, cwd);
3018
+ state.planDirectory = plans.directory;
3019
+ }
2769
3020
  const credential = await setup(resolveCredential({
2770
3021
  model,
2771
3022
  signal: runSignal
@@ -2800,7 +3051,8 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
2800
3051
  stream,
2801
3052
  appendSystemPrompt,
2802
3053
  searchBridge: bridge,
2803
- disableNativeSearch: typeof createSearchBridge === "function"
3054
+ disableNativeSearch: typeof createSearchBridge === "function",
3055
+ planDirectory: plans?.directory
2804
3056
  });
2805
3057
  child = spawnImpl(command.command, [...command.args, ...args], {
2806
3058
  cwd,
@@ -2831,17 +3083,26 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
2831
3083
  child.stderr?.setEncoding?.("utf8");
2832
3084
  let controlBuffer = "";
2833
3085
  let stdinEnded = false;
3086
+ const onStdinError = () => {
3087
+ if (state.settled || runSignal.aborted || typeof state.plan === "string") return;
3088
+ state.stdinFailed = true;
3089
+ kill();
3090
+ };
2834
3091
  const endStdin = () => {
2835
3092
  if (stdinEnded) return;
2836
3093
  stdinEnded = true;
2837
3094
  try {
2838
3095
  child.stdin?.end?.();
2839
- } catch {}
3096
+ } catch {
3097
+ onStdinError();
3098
+ }
2840
3099
  };
2841
3100
  const writeStdin = (frame) => {
2842
3101
  try {
2843
3102
  child.stdin?.write?.(frame);
2844
- } catch {}
3103
+ } catch {
3104
+ onStdinError();
3105
+ }
2845
3106
  };
2846
3107
  const controlResponse = (requestId, response) => {
2847
3108
  writeStdin(`${JSON.stringify({
@@ -2858,12 +3119,22 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
2858
3119
  endStdin();
2859
3120
  kill();
2860
3121
  };
2861
- const refuseControl = (requestId, kind) => {
2862
- state.denies += 1;
3122
+ const toolUses = /* @__PURE__ */ new Map();
3123
+ const deniedTools = /* @__PURE__ */ new Set();
3124
+ const deniedRequests = /* @__PURE__ */ new Set();
3125
+ const countDenial = (requestId, toolId) => {
3126
+ const duplicate = requestId !== void 0 && deniedRequests.has(requestId) || toolId !== void 0 && deniedTools.has(toolId);
3127
+ if (requestId !== void 0) deniedRequests.add(requestId);
3128
+ if (toolId !== void 0) deniedTools.add(toolId);
3129
+ if (!duplicate) state.denies += 1;
2863
3130
  if (state.denies > MAX_PLAN_DENIALS) {
2864
3131
  stopForDenials();
2865
- return;
3132
+ return false;
2866
3133
  }
3134
+ return true;
3135
+ };
3136
+ const refuseControl = (requestId, kind, toolId) => {
3137
+ if (!countDenial(requestId, toolId)) return;
2867
3138
  if (kind === "dialog") {
2868
3139
  writeStdin(`${JSON.stringify({
2869
3140
  type: "control_response",
@@ -2882,10 +3153,12 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
2882
3153
  };
2883
3154
  /**
2884
3155
  * Plan mode keeps stdin open so a permission prompt can be answered.
2885
- * `ExitPlanMode` with a plan stops the child; it is never allowed,
2886
- * because allowing it would make the CLI start editing in this process.
3156
+ * ExitPlanMode is never allowed: stop with the submitted plan instead.
3157
+ * Native hard denials share the same budget, keyed by tool_use_id so the
3158
+ * tool_result following a control denial cannot charge it a second time.
2887
3159
  */
2888
3160
  const handlePlanLine = (line) => {
3161
+ if (state.denyExhausted === true || typeof state.plan === "string") return;
2889
3162
  const trimmed = line.trim();
2890
3163
  if (!trimmed.startsWith("{")) return;
2891
3164
  let message;
@@ -2895,44 +3168,64 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
2895
3168
  return;
2896
3169
  }
2897
3170
  if (message?.type === "result") {
3171
+ state.resultError = message.is_error === true;
2898
3172
  endStdin();
2899
3173
  return;
2900
3174
  }
3175
+ if (state.resultError !== void 0) return;
3176
+ if (message?.type === "assistant") {
3177
+ for (const block of Array.isArray(message.message?.content) ? message.message.content : []) {
3178
+ if (block?.type !== "tool_use" || typeof block.id !== "string") continue;
3179
+ toolUses.set(block.id, {
3180
+ name: block.name,
3181
+ input: block.input
3182
+ });
3183
+ if (toolUses.size > 512) toolUses.delete(toolUses.keys().next().value);
3184
+ }
3185
+ return;
3186
+ }
3187
+ if (message?.type === "user") {
3188
+ for (const block of Array.isArray(message.message?.content) ? message.message.content : []) {
3189
+ if (block?.type !== "tool_result" || block.is_error !== true || typeof block.tool_use_id !== "string") continue;
3190
+ if (nativePermissionDenial(toolUses.get(block.tool_use_id)?.name, toolResultText(block.content).trim()) && !countDenial(void 0, block.tool_use_id)) return;
3191
+ }
3192
+ return;
3193
+ }
2901
3194
  if (message?.type !== "control_request") return;
2902
3195
  const requestId = message.request_id;
2903
3196
  const request = message.request;
2904
3197
  if (typeof requestId !== "string" || request === null || typeof request !== "object") {
2905
- state.denies += 1;
2906
- if (state.denies > MAX_PLAN_DENIALS) stopForDenials();
3198
+ countDenial(typeof requestId === "string" ? requestId : void 0, void 0);
2907
3199
  return;
2908
3200
  }
3201
+ const toolId = typeof request.tool_use_id === "string" ? request.tool_use_id : [...toolUses].reverse().find(([, tool]) => tool.name === request.tool_name && JSON.stringify(tool.input) === JSON.stringify(request.input))?.[0];
2909
3202
  if (request.subtype !== "can_use_tool") {
2910
- refuseControl(requestId, "dialog");
3203
+ refuseControl(requestId, "dialog", toolId);
2911
3204
  return;
2912
3205
  }
2913
3206
  if (request.tool_name === "ExitPlanMode") {
2914
- const plan = readSubmittedPlan(request.input, {
2915
- configDir,
2916
- startedAt
2917
- });
3207
+ let tooLarge = false;
3208
+ const plan = typeof request.input?.plan === "string" || plans?.isOwned() ? readSubmittedPlan(request.input, {
3209
+ planDirectory: plans?.directory,
3210
+ scrub,
3211
+ onRejected: (reason) => {
3212
+ tooLarge = reason === "too-large";
3213
+ }
3214
+ }) : void 0;
2918
3215
  if (plan !== void 0) {
2919
- state.plan = scrub(plan);
3216
+ state.plan = plan;
2920
3217
  endStdin();
2921
3218
  kill();
2922
3219
  return;
2923
3220
  }
2924
- state.denies += 1;
2925
- if (state.denies > MAX_PLAN_DENIALS) {
2926
- stopForDenials();
2927
- return;
2928
- }
3221
+ if (!countDenial(requestId, toolId)) return;
2929
3222
  controlResponse(requestId, {
2930
3223
  behavior: "deny",
2931
- message: PLAN_EXIT_DENY
3224
+ message: tooLarge ? PLAN_TOO_LARGE_DENY : PLAN_EXIT_DENY
2932
3225
  });
2933
3226
  return;
2934
3227
  }
2935
- refuseControl(requestId, "tool");
3228
+ refuseControl(requestId, "tool", toolId);
2936
3229
  };
2937
3230
  child.stdout?.on("data", (chunk) => {
2938
3231
  state.sawOutput = true;
@@ -2962,18 +3255,32 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
2962
3255
  child.on("close", (code, killSignal) => {
2963
3256
  if (state.settled) return;
2964
3257
  state.settled = true;
3258
+ if (mode === "plan" && controlBuffer.trim() !== "") handlePlanLine(controlBuffer);
2965
3259
  if (state.timedOut) return reject(/* @__PURE__ */ new Error(`claude_code timed out after ${Math.round(timeoutMs / 1e3)}s`));
2966
3260
  if (state.silent) {
2967
3261
  const detail = state.stderr.trim().split("\n").slice(-4).join(" ").slice(0, 400);
2968
3262
  return reject(/* @__PURE__ */ new Error(`Claude Code CLI printed nothing within ${Math.round(startupTimeoutMs / 1e3)}s of starting and was stopped${detail === "" ? "" : `: ${detail}`}`));
2969
3263
  }
2970
3264
  if (signal?.aborted) return reject(/* @__PURE__ */ new Error("claude_code was aborted"));
3265
+ if (state.stdinFailed === true) return reject(/* @__PURE__ */ new Error("claude_code could not write to CLI input"));
2971
3266
  if (state.denyExhausted === true) return reject(/* @__PURE__ */ new Error(PLAN_DENY_LIMIT_MESSAGE));
2972
3267
  if (typeof state.plan === "string") return resolve({
2973
3268
  code,
2974
3269
  killSignal
2975
3270
  });
2976
3271
  if (runSignal.aborted) return reject(/* @__PURE__ */ new Error("claude_code was aborted"));
3272
+ if (mode === "plan" && code === 0 && state.resultError === false && plans?.isOwned()) {
3273
+ let tooLarge = false;
3274
+ const written = findPlanWrittenDuringRun({
3275
+ planDirectory: plans.directory,
3276
+ scrub,
3277
+ onRejected: (reason) => {
3278
+ tooLarge ||= reason === "too-large";
3279
+ }
3280
+ });
3281
+ if (tooLarge) return reject(/* @__PURE__ */ new Error(PLAN_TOO_LARGE_DENY));
3282
+ if (written !== void 0) state.plan = written;
3283
+ }
2977
3284
  resolve({
2978
3285
  code,
2979
3286
  killSignal
@@ -2987,7 +3294,7 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
2987
3294
  throw error;
2988
3295
  });
2989
3296
  closed.catch(() => void 0);
2990
- child.stdin?.on?.("error", () => void 0);
3297
+ child.stdin?.on?.("error", onStdinError);
2991
3298
  if (mode === "plan") writeStdin(input);
2992
3299
  else child.stdin?.end?.(input);
2993
3300
  if (runSignal.aborted) kill();
@@ -3167,6 +3474,10 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
3167
3474
  yield* drain();
3168
3475
  }
3169
3476
  await completion;
3477
+ if (buffer.trim().startsWith("{")) try {
3478
+ const event = readClaudeStreamEvents(JSON.parse(scrub(buffer.trim()))).find((item) => item.type === "result");
3479
+ if (event !== void 0) sawResult = event.result;
3480
+ } catch {}
3170
3481
  if (typeof state.plan === "string") {
3171
3482
  if (deltaKind !== void 0) {
3172
3483
  const tail = deltaRedactor.flush({ boundary: true });
@@ -3196,10 +3507,6 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
3196
3507
  };
3197
3508
  return;
3198
3509
  }
3199
- if (buffer.trim().startsWith("{")) try {
3200
- const event = readClaudeStreamEvents(JSON.parse(scrub(buffer.trim()))).find((item) => item.type === "result");
3201
- if (event !== void 0) sawResult = event.result;
3202
- } catch {}
3203
3510
  if (sawResult === void 0) throw new Error(failureMessage(void 0, void 0, state.stderr));
3204
3511
  if (sawResult.is_error === true || exitStatus?.code !== 0) throw new Error(failureMessage(sawResult, exitStatus?.code ?? exitStatus?.killSignal, state.stderr));
3205
3512
  const usage = sawResult.usage ?? {};
@@ -3219,7 +3526,11 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
3219
3526
  };
3220
3527
  } finally {
3221
3528
  stopIfAbandoned();
3222
- await closeBridge();
3529
+ try {
3530
+ await closed.catch(() => void 0);
3531
+ } finally {
3532
+ await closeBridge();
3533
+ }
3223
3534
  }
3224
3535
  },
3225
3536
  /** Revoke bridges and stop every invocation when the plugin unloads. */
@@ -4031,13 +4342,15 @@ function claudeModeFor(sandbox, approval) {
4031
4342
  }
4032
4343
  }
4033
4344
  /**
4034
- * Whether this session should run the CLI in plan mode.
4345
+ * Whether a prepared model request should run the CLI in plan mode.
4035
4346
  *
4036
- * The model request runs inside `agent/pre-step`, before the plan-mode service
4037
- * appends `plan/mode`. `pending` is that not-yet-logged selection and wins over
4038
- * the logged `active` bit, matching the `plan:policy` section. Without the
4039
- * service, the logged projection is the only signal. A missing session, a
4040
- * missing service, or a throw means plan mode is off — never a failed turn.
4347
+ * This selection is for request preparation only, not an executing tool batch.
4348
+ * `pending` is the selection used for the proposed request assembly and wins
4349
+ * over logged `active`, matching `plan:policy`. An accepted pre-step commits
4350
+ * it; reading after that boundary sees the same selection as logged `active`.
4351
+ * Without the service, the logged projection is the only signal. A missing
4352
+ * session, a missing service, or a throw means plan mode is off — never a
4353
+ * failed turn.
4041
4354
  * @param ctx - the plugin context; services are read without an inject requirement.
4042
4355
  * @param session - the session whose plan state is read.
4043
4356
  * @returns true when the CLI must plan instead of acting.
@@ -4055,7 +4368,30 @@ function planModeForSession(ctx, session) {
4055
4368
  return false;
4056
4369
  }
4057
4370
  }
4058
- /** `pending` is the selection the in-flight request was assembled with. */
4371
+ /**
4372
+ * Whether a tool in the current batch must plan, using logged active state.
4373
+ *
4374
+ * Approval queues `pending: false`; it does not authorize remaining tools in
4375
+ * that batch to implement. Likewise, pending entry must not change a tool's
4376
+ * current mode before the next accepted pre-step commits it.
4377
+ * @param ctx - the plugin context.
4378
+ * @param session - the executing tool's session.
4379
+ * @param agent - the trusted executing agent, when provided by the tool runtime.
4380
+ */
4381
+ function planModeForToolExecution(ctx, session, agent) {
4382
+ if (session === void 0 || session === null) return false;
4383
+ try {
4384
+ const caller = agent ?? ctx.get?.("agents")?.currentInitiator?.();
4385
+ if (caller?.session === session) {
4386
+ const state = ctx.get?.("planMode")?.get?.(caller);
4387
+ if (state !== void 0 && state !== null) return state.active === true;
4388
+ }
4389
+ return ctx.get?.("sessionProjections")?.stateOf?.(session, "plan")?.active === true;
4390
+ } catch {
4391
+ return false;
4392
+ }
4393
+ }
4394
+ /** `pending` is the selection the proposed request was assembled with. */
4059
4395
  function planSelected(state) {
4060
4396
  if (typeof state?.pending === "boolean") return state.pending;
4061
4397
  return state?.active === true;
@@ -4108,7 +4444,8 @@ const CLAUDE_TOOL_INSTRUCTIONS = "This task was delegated to Claude Code by anot
4108
4444
  const CLAUDE_TOOL_PLAN_INSTRUCTIONS = "This task was delegated to Claude Code while DeepSeek Harness is in plan mode. Explore read-only, write a complete markdown plan that starts with a # heading to the CLI plan file, then call ExitPlanMode. Do not edit the project or start implementing. Reply with the plan text only.";
4109
4445
  /**
4110
4446
  * @param options - the CLI runner, the most a run may do for a session, and
4111
- * whether that session is in DSH plan mode.
4447
+ * whether the executing batch's logged state is DSH plan mode (not a
4448
+ * pending selection for the next model request).
4112
4449
  * @returns the tool definition to register.
4113
4450
  */
4114
4451
  function createClaudeCodeTool({ runner, modeCeiling, planActive }) {
@@ -4172,7 +4509,7 @@ function createClaudeCodeTool({ runner, modeCeiling, planActive }) {
4172
4509
  const requested = typeof args.cwd === "string" ? args.cwd.trim() : "";
4173
4510
  const cwd = requested === "" ? sessionCwd : isAbsolute(requested) ? requested : sessionCwd === void 0 ? requested : join(sessionCwd, requested);
4174
4511
  const session = exec?.agent?.session;
4175
- const planning = planActive?.(session) === true;
4512
+ const planning = planActive?.(session, exec?.agent) === true;
4176
4513
  return await runner.run({
4177
4514
  prompt: args.prompt,
4178
4515
  mode: planning ? "plan" : capClaudeMode(args.mode, modeCeiling(session)) ?? "edit",
@@ -6478,9 +6815,9 @@ function apply(ctx, config) {
6478
6815
  ctx.effect(() => toolsCtx.tools.register(createClaudeCodeTool({
6479
6816
  runner: claudeRunner,
6480
6817
  modeCeiling: cliModeCeiling,
6481
- planActive: (session) => planModeForSession(ctx, session)
6818
+ planActive: (session, agent) => planModeForToolExecution(ctx, session, agent)
6482
6819
  })), "packycode-api: claude_code tool");
6483
6820
  });
6484
6821
  }
6485
6822
  //#endregion
6486
- export { AUTO_CLAUDE_MODE, CHANNEL, CLAUDE_CLI_MODES, CLAUDE_INSTALL_COMMAND, CLAUDE_MISSING_MESSAGE, CLAUDE_NPM_PACKAGE, CLAUDE_TOOL_INSTRUCTIONS, CLI_BACKED_MODEL_IDS, CLI_IMAGE_LIMITS, Config, DEFAULT_API_HOST, DEFAULT_APPEND_SYSTEM_PROMPT, DEFAULT_SEARCH_BACKEND, DEFAULT_STATUS_HOST, DISPLAY_NAME, LEGACY_STATUS_HOSTS, PACKYCODE_AUTO_SEARCH_PROVIDER_ID, PACKYCODE_MODELS, PACKYCODE_MODEL_IDS, PACKYCODE_SEARCH_PROVIDER_ID, PLUGIN_ID, PROVIDER, PackyCodeKeyRing, SEARCH_BACKENDS, SEARCH_BACKEND_MODELS, SEARCH_MODES, SETTINGS_NAMESPACE, TITLE_MODEL_ID, TITLE_MODEL_ROUTES, accountHostOrder, apply, assertApiKey, buildClaudeArgs, buildClaudeEnvironment, buildClaudeInput, buildDelegatedContent, buildSearchRequest, capClaudeMode, catalogCoverage, classifyClientPolicy, classifyGatewayFailure, claudeBinaryCandidates, claudeModeFor, claudeModeForSession, cliSearchInstructions, createBalanceReader, createClaudeCliAdapter, createClaudeCliManager, createClaudeCodeTool, createClaudeRunner, createClaudeSearchBridge, createGroupReader, createKeyChooser, createPackyCodeAutoSearchProvider, createPackyCodeProfile, createPackyCodeProvider, createPackyCodeRpc, createPackyCodeSearchProvider, createProfileSearchComposition, createRouteAdapter, createSearchComposition, createSearchController, delegationInstructions, describeRunKey, explainCliClientPolicyFailure, explainCliGroupFailure, explainClientPolicyFailure, explainNpmFailure, findClaudeBinary, guardKeyRotation, inject, keyNote, locateClaudeBinary, locateNpm, name, normalizeFailureMessage, normalizeHost, npmCandidates, parseBillingSubscription, parseBillingUsage, parseClaudeResult, parseClaudeVersion, parseMessagesSearchResponse, parsePricing, parseResponsesSearchResponse, parseSelectedGroups, parseTokenUsage, pickKeyForModel, planModeForSession, readClaudeStreamEvents, readSearchChoice, renderAuxiliaryPrompt, renderDelegatedPrompt, resolveSpawnCommand, statusHostOrder, summarizeToolUse };
6823
+ export { AUTO_CLAUDE_MODE, CHANNEL, CLAUDE_CLI_MODES, CLAUDE_INSTALL_COMMAND, CLAUDE_MISSING_MESSAGE, CLAUDE_NPM_PACKAGE, CLAUDE_TOOL_INSTRUCTIONS, CLI_BACKED_MODEL_IDS, CLI_IMAGE_LIMITS, Config, DEFAULT_API_HOST, DEFAULT_APPEND_SYSTEM_PROMPT, DEFAULT_SEARCH_BACKEND, DEFAULT_STATUS_HOST, DISPLAY_NAME, LEGACY_STATUS_HOSTS, PACKYCODE_AUTO_SEARCH_PROVIDER_ID, PACKYCODE_MODELS, PACKYCODE_MODEL_IDS, PACKYCODE_SEARCH_PROVIDER_ID, PLUGIN_ID, PROVIDER, PackyCodeKeyRing, SEARCH_BACKENDS, SEARCH_BACKEND_MODELS, SEARCH_MODES, SETTINGS_NAMESPACE, TITLE_MODEL_ID, TITLE_MODEL_ROUTES, accountHostOrder, apply, assertApiKey, buildClaudeArgs, buildClaudeEnvironment, buildClaudeInput, buildDelegatedContent, buildSearchRequest, capClaudeMode, catalogCoverage, classifyClientPolicy, classifyGatewayFailure, claudeBinaryCandidates, claudeModeFor, claudeModeForSession, cliSearchInstructions, createBalanceReader, createClaudeCliAdapter, createClaudeCliManager, createClaudeCodeTool, createClaudeRunner, createClaudeSearchBridge, createGroupReader, createKeyChooser, createPackyCodeAutoSearchProvider, createPackyCodeProfile, createPackyCodeProvider, createPackyCodeRpc, createPackyCodeSearchProvider, createProfileSearchComposition, createRouteAdapter, createSearchComposition, createSearchController, delegationInstructions, describeRunKey, explainCliClientPolicyFailure, explainCliGroupFailure, explainClientPolicyFailure, explainNpmFailure, findClaudeBinary, guardKeyRotation, inject, keyNote, locateClaudeBinary, locateNpm, name, normalizeFailureMessage, normalizeHost, npmCandidates, parseBillingSubscription, parseBillingUsage, parseClaudeResult, parseClaudeVersion, parseMessagesSearchResponse, parsePricing, parseResponsesSearchResponse, parseSelectedGroups, parseTokenUsage, pickKeyForModel, planModeForSession, planModeForToolExecution, readClaudeStreamEvents, readSearchChoice, renderAuxiliaryPrompt, renderDelegatedPrompt, resolveSpawnCommand, statusHostOrder, summarizeToolUse };
package/package.json CHANGED
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "name": "dsh-packycode-api",
3
- "version": "1.1.2",
4
- "packageManager": "pnpm@10.32.1",
3
+ "version": "1.1.4",
5
4
  "description": "Use a PackyCode (PackyAPI) API key in DeepSeek Harness through a built-in multi-protocol model route with key rotation and balance display",
6
5
  "type": "module",
7
6
  "main": "./lib/index.js",
@@ -40,11 +39,6 @@
40
39
  "platform": "web"
41
40
  }
42
41
  },
43
- "scripts": {
44
- "build": "tsdown --config tsdown.config.mjs",
45
- "test": "node --test tests/*.test.mjs",
46
- "check": "pnpm run test && pnpm run build && pnpm pack --pack-destination .artifacts"
47
- },
48
42
  "keywords": [
49
43
  "dsh-plugin",
50
44
  "deepseek-harness",
@@ -97,5 +91,10 @@
97
91
  "@earendil-works/pi-ai": "0.85.1",
98
92
  "react": "18.3.1",
99
93
  "tsdown": "0.22.2"
94
+ },
95
+ "scripts": {
96
+ "build": "tsdown --config tsdown.config.mjs",
97
+ "test": "node --test tests/*.test.mjs",
98
+ "check": "pnpm run test && pnpm run build && pnpm pack --pack-destination .artifacts"
100
99
  }
101
- }
100
+ }