dsh-github-flow 0.7.0 → 0.7.1

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/lib/executor.d.ts CHANGED
@@ -80,6 +80,13 @@ export declare class GhExecutor {
80
80
  *
81
81
  * 解析顺序:显式 cwd(原样透传,含不存在的情况,与旧行为一致)→ session 反查 →
82
82
  * process.cwd()。三级都拿不到时才回到进程目录,与 s5 之前的兜底完全一致。
83
+ *
84
+ * ⚠️ `agent.id` 必须参与反查:DSH 真实的 ToolRunContext(宿主 tools 服务的
85
+ * `ToolExecutionInput`)只有 `agent: { id: SessionId }`,**既没有 cwd 也没有 sessionId**。
86
+ * 早先只认 `sessionId` / `session.id` / 环境变量,于是这一级在桌面端永远不触发,
87
+ * 所有工具退化到宿主进程的 process.cwd()(实测是 profile 目录)——表现为 `repo`
88
+ * 留空时无法自动识别仓库、`body_file` 这类相对路径参数被解析到错误目录。
89
+ * 这里改成「候选逐个试到命中为止」而非短路:任一来源能反查到工作区就返回。
83
90
  */
84
91
  resolveExecScope(execContext?: {
85
92
  cwd?: string;
@@ -87,6 +94,9 @@ export declare class GhExecutor {
87
94
  session?: {
88
95
  id?: string;
89
96
  };
97
+ agent?: {
98
+ id?: string;
99
+ };
90
100
  }): {
91
101
  cwd: string;
92
102
  sessionId?: string;
@@ -204,6 +214,44 @@ export declare class GhExecutor {
204
214
  * 语法都不可能通过校验)。输出为 git show --stat 原文(含文件变更统计)。
205
215
  */
206
216
  getGitCommitDetail(cwd: string | undefined, sha: string): Promise<string | null>;
217
+ /**
218
+ * 某次提交的完整补丁(提交详情弹窗里点单个文件时,前端按 `diff --git` 切分后取对应段)。
219
+ *
220
+ * 为什么整包取一次,而不是按文件逐个 `git show <sha> -- <path>`:
221
+ * 用户点哪个文件是未知的,逐个文件来一次就是 N 次进程启动(Windows 上每次几十毫秒),
222
+ * 同一提交内多个文件还要重复解析。一次拿全量交给前端切分,把延迟从「每次点击」
223
+ * 摊到「打开弹窗一次」。
224
+ *
225
+ * 白名单纪律延续 getGitCommitDetail:sha 先过 `/^[0-9a-f]{4,40}$/i` 才拼接,
226
+ * 值域锁死为纯十六进制,注入面为零;命令形态整体常量硬编码。各选项为什么存在:
227
+ * - `--format=`:清空提交头。默认输出前半截是 commit/Author/Date/message,
228
+ * 前端按 `diff --git` 切片时会把这段脏头当成第一个文件的内容;
229
+ * - `--no-ext-diff`:用户一旦配置 `diff.external`,git 会去执行任意外部程序。
230
+ * 这是本地服务,不能因为一条读请求就把用户的外部工具跑起来;
231
+ * - `--no-color`:用户若设了 `color.ui=always`,patch 里会混进 ANSI 转义序列,
232
+ * 前端按行首 `+`/`-` 判定增删行的逻辑会直接判错;
233
+ * - `core.quotepath=false`:quotepath 为 true(git 内建默认)时非 ASCII 路径被转义成
234
+ * `\344\270\255` 并包上双引号(实测),前端按 `a/` 前缀切出的文件名与真实路径对不上;
235
+ * - `--src-prefix=a/ --dst-prefix=b/`:用户全局开 `diff.noprefix=true` 时路径没有
236
+ * `a/`/`b/` 前缀,切分与还原文件名的逻辑全部错位,这里强制钉死前缀;
237
+ * - 末尾 `--`:关死 sha 之后的参数位,避免可被解释成 pathspec 的输入混进命令。
238
+ *
239
+ * 体积护栏:大提交的 patch 可达数十 MB,整包透传给 WebView 会卡死渲染。超过
240
+ * 400_000 字符时在**最后一个 `\ndiff --git ` 边界**处截断——绝不切在 hunk 中间,
241
+ * 否则前端拿到半截 hunk 会渲染出错行;只有找不到边界(极端单文件巨型 diff)
242
+ * 才硬切到上限,此时 truncated 标记让前端给出「已截断」提示。
243
+ *
244
+ * 空 patch 是正常结果而非错误:合并提交默认不展开 diff、空提交也没有内容,
245
+ * 两者都返回 ok:true + 空字符串,由 UI 决定怎么展示。
246
+ */
247
+ getCommitDiff(cwd: string | undefined, sha: string): Promise<{
248
+ ok: boolean;
249
+ sha?: string;
250
+ patch?: string;
251
+ chars?: number;
252
+ truncated?: boolean;
253
+ error?: string;
254
+ }>;
207
255
  /**
208
256
  * 纯本地读取工作区的所有 Git 分支(按提交时间倒序)。
209
257
  * 包含当前 HEAD 分支标记、关联 upstream、最新提交简述与相对时间。
package/lib/executor.js CHANGED
@@ -236,20 +236,32 @@ export class GhExecutor {
236
236
  *
237
237
  * 解析顺序:显式 cwd(原样透传,含不存在的情况,与旧行为一致)→ session 反查 →
238
238
  * process.cwd()。三级都拿不到时才回到进程目录,与 s5 之前的兜底完全一致。
239
+ *
240
+ * ⚠️ `agent.id` 必须参与反查:DSH 真实的 ToolRunContext(宿主 tools 服务的
241
+ * `ToolExecutionInput`)只有 `agent: { id: SessionId }`,**既没有 cwd 也没有 sessionId**。
242
+ * 早先只认 `sessionId` / `session.id` / 环境变量,于是这一级在桌面端永远不触发,
243
+ * 所有工具退化到宿主进程的 process.cwd()(实测是 profile 目录)——表现为 `repo`
244
+ * 留空时无法自动识别仓库、`body_file` 这类相对路径参数被解析到错误目录。
245
+ * 这里改成「候选逐个试到命中为止」而非短路:任一来源能反查到工作区就返回。
239
246
  */
240
247
  resolveExecScope(execContext) {
241
- const sessionId = execContext?.sessionId || execContext?.session?.id || process.env.DSH_SESSION_ID;
248
+ const candidates = [
249
+ execContext?.sessionId,
250
+ execContext?.session?.id,
251
+ execContext?.agent?.id,
252
+ process.env.DSH_SESSION_ID,
253
+ ].filter((id) => typeof id === 'string' && id.length > 0);
242
254
  // 1) 显式 cwd 原样透传:这是唯一事实源,不做存在性替换,也不许被会话反查覆盖
243
255
  if (execContext?.cwd)
244
- return { cwd: execContext.cwd, sessionId };
245
- // 2) 按会话反查真实工作区(旧实现被 process.cwd() 短路掉的那一级)
246
- if (sessionId) {
256
+ return { cwd: execContext.cwd, sessionId: candidates[0] };
257
+ // 2) 按会话反查真实工作区:候选之间是或关系,任一击即返回(查出空就试下一个)
258
+ for (const sessionId of candidates) {
247
259
  const fromSession = findWorkspacePathBySession(sessionId);
248
260
  if (fromSession)
249
261
  return { cwd: fromSession, sessionId };
250
262
  }
251
263
  // 3) 兜底到进程目录。**不**调用 findMostRecentWorkspacePath()
252
- return { cwd: process.cwd(), sessionId };
264
+ return { cwd: process.cwd(), sessionId: candidates[0] };
253
265
  }
254
266
  /**
255
267
  * 安全执行 gh 命令
@@ -810,6 +822,70 @@ export class GhExecutor {
810
822
  return null;
811
823
  }
812
824
  }
825
+ /**
826
+ * 某次提交的完整补丁(提交详情弹窗里点单个文件时,前端按 `diff --git` 切分后取对应段)。
827
+ *
828
+ * 为什么整包取一次,而不是按文件逐个 `git show <sha> -- <path>`:
829
+ * 用户点哪个文件是未知的,逐个文件来一次就是 N 次进程启动(Windows 上每次几十毫秒),
830
+ * 同一提交内多个文件还要重复解析。一次拿全量交给前端切分,把延迟从「每次点击」
831
+ * 摊到「打开弹窗一次」。
832
+ *
833
+ * 白名单纪律延续 getGitCommitDetail:sha 先过 `/^[0-9a-f]{4,40}$/i` 才拼接,
834
+ * 值域锁死为纯十六进制,注入面为零;命令形态整体常量硬编码。各选项为什么存在:
835
+ * - `--format=`:清空提交头。默认输出前半截是 commit/Author/Date/message,
836
+ * 前端按 `diff --git` 切片时会把这段脏头当成第一个文件的内容;
837
+ * - `--no-ext-diff`:用户一旦配置 `diff.external`,git 会去执行任意外部程序。
838
+ * 这是本地服务,不能因为一条读请求就把用户的外部工具跑起来;
839
+ * - `--no-color`:用户若设了 `color.ui=always`,patch 里会混进 ANSI 转义序列,
840
+ * 前端按行首 `+`/`-` 判定增删行的逻辑会直接判错;
841
+ * - `core.quotepath=false`:quotepath 为 true(git 内建默认)时非 ASCII 路径被转义成
842
+ * `\344\270\255` 并包上双引号(实测),前端按 `a/` 前缀切出的文件名与真实路径对不上;
843
+ * - `--src-prefix=a/ --dst-prefix=b/`:用户全局开 `diff.noprefix=true` 时路径没有
844
+ * `a/`/`b/` 前缀,切分与还原文件名的逻辑全部错位,这里强制钉死前缀;
845
+ * - 末尾 `--`:关死 sha 之后的参数位,避免可被解释成 pathspec 的输入混进命令。
846
+ *
847
+ * 体积护栏:大提交的 patch 可达数十 MB,整包透传给 WebView 会卡死渲染。超过
848
+ * 400_000 字符时在**最后一个 `\ndiff --git ` 边界**处截断——绝不切在 hunk 中间,
849
+ * 否则前端拿到半截 hunk 会渲染出错行;只有找不到边界(极端单文件巨型 diff)
850
+ * 才硬切到上限,此时 truncated 标记让前端给出「已截断」提示。
851
+ *
852
+ * 空 patch 是正常结果而非错误:合并提交默认不展开 diff、空提交也没有内容,
853
+ * 两者都返回 ok:true + 空字符串,由 UI 决定怎么展示。
854
+ */
855
+ async getCommitDiff(cwd, sha) {
856
+ if (typeof sha !== 'string' || !/^[0-9a-f]{4,40}$/i.test(sha)) {
857
+ return { ok: false, error: `非法的提交 SHA: "${sha}"(只允许十六进制短/全 SHA)` };
858
+ }
859
+ try {
860
+ const { stdout } = await execFileAsync('git', [
861
+ '-c', 'core.quotepath=false',
862
+ 'show',
863
+ '--format=',
864
+ '--no-color',
865
+ '--no-ext-diff',
866
+ '--src-prefix=a/',
867
+ '--dst-prefix=b/',
868
+ sha,
869
+ '--',
870
+ ], { cwd: cwd || undefined, timeout: 8_000, windowsHide: true, maxBuffer: 16 * 1024 * 1024 });
871
+ const raw = String(stdout);
872
+ const MAX = 400_000;
873
+ let patch = raw;
874
+ let truncated = false;
875
+ if (patch.length > MAX) {
876
+ truncated = true;
877
+ const boundary = patch.lastIndexOf('\ndiff --git ', MAX);
878
+ patch = boundary > 0 ? patch.slice(0, boundary) : patch.slice(0, MAX);
879
+ }
880
+ return { ok: true, sha, patch, chars: patch.length, truncated };
881
+ }
882
+ catch (err) {
883
+ // 优先回 stderr:sha 不存在(bad object)、不是本仓库等真实原因只在 stderr 里,
884
+ // execFile 的 message 只是 "Command failed: git show …" 的包装
885
+ const stderr = typeof err?.stderr === 'string' ? err.stderr.trim() : '';
886
+ return { ok: false, error: stderr || err?.message || '获取提交补丁失败' };
887
+ }
888
+ }
813
889
  /**
814
890
  * 纯本地读取工作区的所有 Git 分支(按提交时间倒序)。
815
891
  * 包含当前 HEAD 分支标记、关联 upstream、最新提交简述与相对时间。
@@ -22,6 +22,10 @@ export declare function createAgentTaskTool(executor: GhExecutor): {
22
22
  type: string;
23
23
  description: string;
24
24
  };
25
+ prompt_file: {
26
+ type: string;
27
+ description: string;
28
+ };
25
29
  base_branch: {
26
30
  type: string;
27
31
  description: string;
@@ -1,5 +1,6 @@
1
1
  import { wrapWithGate } from '../destructive.js';
2
2
  import { renderAgentTaskMarkdown } from '../renderers/markdown.js';
3
+ import { resolveBodySource } from './body-input.js';
3
4
  function normalizeOutput(data) {
4
5
  if (data && typeof data === 'object' && !Array.isArray(data)) {
5
6
  return data;
@@ -32,7 +33,11 @@ export function createAgentTaskTool(executor) {
32
33
  },
33
34
  prompt: {
34
35
  type: 'string',
35
- description: '任务描述正文或编码指令(create 动作必需,如 "为用户模块增加单元测试")。',
36
+ description: '任务描述正文或编码指令(create 动作必需,如 "为用户模块增加单元测试")。与 prompt_file 二选一。',
37
+ },
38
+ prompt_file: {
39
+ type: 'string',
40
+ description: '任务描述来源:本地 Markdown 文件路径(相对路径按当前工作区解析)。与 prompt 二选一。',
36
41
  },
37
42
  base_branch: {
38
43
  type: 'string',
@@ -63,6 +68,8 @@ export function createAgentTaskTool(executor) {
63
68
  const { cwd, sessionId } = executor.resolveExecScope(execContext);
64
69
  const repoArgs = args.repo ? ['-R', args.repo] : [];
65
70
  const runOpts = { cwd, sessionId };
71
+ // 任务描述来自文件时经 stdin 注入(`--from-file -`):绕开 Windows 约 32K 的命令行上限
72
+ const stdinPatch = (body) => (body.stdin === undefined ? {} : { stdin: body.stdin });
66
73
  switch (args.action) {
67
74
  case 'list': {
68
75
  const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.min(args.limit, 100) : 30;
@@ -111,15 +118,21 @@ export function createAgentTaskTool(executor) {
111
118
  return taskData || { id: cleanId };
112
119
  }
113
120
  case 'create': {
114
- if (!args.prompt || typeof args.prompt !== 'string' || !args.prompt.trim()) {
115
- throw new Error('create 操作必须提供 prompt(任务描述正文或编码指令)');
121
+ // 内联 prompt 继续走位置参数:它的 argv 必须与改造前逐字节一致,只有文件来源才切
122
+ // `--from-file -`。gh agent-task 是 preview 命令,该 flag 形态可能随上游变化。
123
+ const inlinePrompt = typeof args.prompt === 'string' ? args.prompt.trim() : '';
124
+ const body = resolveBodySource({ inline: inlinePrompt, file: args.prompt_file }, { cwd, fileFlag: '--from-file', inlineField: 'prompt', fileField: 'prompt_file' });
125
+ if (body.chars === 0) {
126
+ throw new Error('create 操作必须提供 prompt 或 prompt_file(任务描述正文或编码指令)');
116
127
  }
117
- const createCmd = ['agent-task', 'create', args.prompt.trim(), ...repoArgs];
128
+ const createCmd = body.fromFile
129
+ ? ['agent-task', 'create', ...repoArgs, ...body.argv]
130
+ : ['agent-task', 'create', inlinePrompt, ...repoArgs];
118
131
  if (args.base_branch)
119
132
  createCmd.push('-b', args.base_branch.trim());
120
133
  if (args.custom_agent)
121
134
  createCmd.push('-a', args.custom_agent.trim());
122
- const res = await executor.run(createCmd, { ...runOpts, rawText: true, timeoutMs: 60_000 });
135
+ const res = await executor.run(createCmd, { ...runOpts, rawText: true, timeoutMs: 60_000, ...stdinPatch(body) });
123
136
  if (!res.ok)
124
137
  throw new Error(res.error);
125
138
  return {
@@ -22,6 +22,10 @@ export declare function createApiTool(executor: GhExecutor): {
22
22
  type: string;
23
23
  description: string;
24
24
  };
25
+ input_file: {
26
+ type: string;
27
+ description: string;
28
+ };
25
29
  raw: {
26
30
  type: string;
27
31
  description: string;
package/lib/tools/api.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { wrapWithGate } from '../destructive.js';
2
+ import { resolveBodySource } from './body-input.js';
2
3
  function normalizeOutput(data) {
3
4
  if (data && typeof data === 'object' && !Array.isArray(data)) {
4
5
  return data;
@@ -22,7 +23,7 @@ export function createApiTool(executor) {
22
23
  method: {
23
24
  type: 'string',
24
25
  enum: ['GET', 'POST', 'PATCH', 'DELETE'],
25
- description: 'HTTP 方法,默认为 GET(GraphQL 端点默认 POST)。',
26
+ description: 'HTTP 方法,默认为 GET(GraphQL 端点或提供 input_file 时默认 POST)。',
26
27
  },
27
28
  jq: {
28
29
  type: 'string',
@@ -30,7 +31,11 @@ export function createApiTool(executor) {
30
31
  },
31
32
  fields: {
32
33
  type: 'object',
33
- description: '请求参数或请求体键值对。所有值按字面字符串(gh -f)发送,不做布尔/数字类型转换;嵌套对象请先在端点里用 JSON 字段或改用 raw 模式。',
34
+ description: '请求参数或请求体键值对。所有值按字面字符串(gh -f)发送,不做布尔/数字类型转换;嵌套结构无法作为表单参数,请把完整请求体写入本地文件后用 input_file 传入。',
35
+ },
36
+ input_file: {
37
+ type: 'string',
38
+ description: '请求体来源:本地文件路径(相对路径按当前工作区解析)。映射 gh api --input,文件内容原样作为请求体(GraphQL 需写 {"query":...} 形式的 JSON);与 fields 并用时 gh 会把 fields 追加为查询参数。',
34
39
  },
35
40
  raw: {
36
41
  type: 'boolean',
@@ -72,11 +77,28 @@ export function createApiTool(executor) {
72
77
  throw new Error(`endpoint 不能以 - 开头(会被 gh 当作命令行 flag):"${endpoint}"。`);
73
78
  }
74
79
  const cmd = ['api', endpoint];
80
+ /*
81
+ * 请求体可来自本地文件(`--input -` + stdin)。
82
+ *
83
+ * 只接文件、不接内联:`fields` 就是内联通道,二者按契约可共存(gh 把 fields 追加为
84
+ * 查询参数),所以这里不传 inline——传了会被 helper 判成互斥而误拒合法调用。
85
+ * `maxChars: 0`:`--input` 的请求体是 JSON/GraphQL,不受 GitHub「正文 65536 字符」
86
+ * 口径约束,套用正文上限会把合法的大 mutation 拦死。
87
+ */
88
+ const inputFileGiven = typeof args.input_file === 'string' && args.input_file.trim().length > 0;
89
+ const body = resolveBodySource({ file: args.input_file }, { cwd, flag: '--input', fileFlag: '--input', inlineField: 'fields', fileField: 'input_file', maxChars: 0 });
90
+ // 空文件若静默放过,发出去的是一个没有请求体的请求——服务端报的错与真实原因无关,极难排查
91
+ if (inputFileGiven && body.chars === 0) {
92
+ throw new Error(`input_file 内容为空:指向的文件没有任何内容("${args.input_file}")。请写入完整请求体后重试,或改用 fields。`);
93
+ }
94
+ // 请求体来自文件时经 stdin 注入(`--input -`):绕开 Windows 约 32K 的命令行上限
95
+ const stdinPatch = (b) => (b.stdin === undefined ? {} : { stdin: b.stdin });
75
96
  // gh api 默认在提供 -f/-F 时会自动将 HTTP 方法隐式切换为 POST。
76
97
  // 若调用方未指定 method,且 endpoint 不是以 graphql 开头,则按契约默认为 GET。
77
98
  // 必须显式传入 -X,防止带有 fields 参数的 GET 请求被 gh 自动改写为 POST 导致 422 报错。
78
99
  const isGraphql = endpoint.toLowerCase().startsWith('graphql');
79
- const method = args.method || (isGraphql ? 'POST' : 'GET');
100
+ // 带了 `--input` 却不给 -X 时,gh 仍按 GET 发出,请求体会被服务端静默丢弃——有正文即 POST
101
+ const method = args.method || (isGraphql || body.chars > 0 ? 'POST' : 'GET');
80
102
  cmd.push('-X', method);
81
103
  if (args.jq) {
82
104
  cmd.push('--jq', args.jq);
@@ -96,15 +118,17 @@ export function createApiTool(executor) {
96
118
  continue;
97
119
  if (typeof v === 'object') {
98
120
  // 不把嵌套结构 JSON.stringify 后塞进 -F:gh 侧解析结果不可预期,且会静默产出错误请求体。
99
- // 宁可显式报错,让调用方改用 raw 模式或把结构放进端点自身的 JSON 字段。
100
- throw new Error(`github_api 的字段 "${k}" 是嵌套结构,无法作为表单参数发送。请改用 raw 模式传原始 JSON 请求体,或先把该结构序列化后作为字符串字段传入。`);
121
+ // 宁可显式报错,让调用方改用 input_file 传完整请求体,或把结构序列化后作为字符串字段。
122
+ throw new Error(`github_api 的字段 "${k}" 是嵌套结构,无法作为表单参数发送。请把完整请求体写入本地 JSON 文件后用 input_file 传入(映射 gh api --input),或先把该结构序列化后作为字符串字段传入。`);
101
123
  }
102
124
  // 用 -f(字面字符串)而非 -F:-F 会做 "true"→布尔、"1"→数字的类型转换,
103
125
  // 导致字符串请求体被悄悄改写,是跨 fork 提 PR 等场景失败的常见根因。
104
126
  cmd.push('-f', `${k}=${String(v)}`);
105
127
  }
106
128
  }
107
- const res = await executor.run(cmd, { cwd, sessionId, rawText: args.raw === true });
129
+ // 与 fields 并用时 gh 会把 fields 追加为查询参数,两者不互斥
130
+ cmd.push(...body.argv);
131
+ const res = await executor.run(cmd, { cwd, sessionId, rawText: args.raw === true, ...stdinPatch(body) });
108
132
  if (!res.ok)
109
133
  throw new Error(res.error);
110
134
  return normalizeOutput(res.data !== undefined ? res.data : res.rawOutput);
@@ -0,0 +1,58 @@
1
+ /**
2
+ * GitHub 正文上限(Issue/PR/评论/讨论统一口径 65,536 字符)。
3
+ * 抽成常量而不是散落字面量:这是上游约束,将来调整只改一处。
4
+ */
5
+ export declare const GITHUB_BODY_MAX_CHARS = 65536;
6
+ /**
7
+ * 单个正文文件的字节硬上限(4MB)。
8
+ * 字符上限只管正文语义,管不住「模型指了一个 200MB 日志文件」——先按字节拦住,
9
+ * 避免 readFileSync 把整个文件拉进内存再判定。
10
+ */
11
+ export declare const BODY_FILE_MAX_BYTES: number;
12
+ /** 入参归一化的产出:argv 片段 + 需要注入的 stdin + 权威文本 */
13
+ export interface BodySource {
14
+ /** 交给 gh 的 argv 片段:`[]` | `[flag, text]` | `[fileFlag, '-']` */
15
+ argv: string[];
16
+ /** `[fileFlag, '-']` 时需要经 stdin 注入的正文;其余情况为 undefined */
17
+ stdin?: string;
18
+ /** 权威正文(REST/GraphQL 通道直接用它;调用方做必填判定也看它) */
19
+ text: string;
20
+ /** `text` 的字符数(按 UTF-16 单元近似 GitHub 的字符口径) */
21
+ chars: number;
22
+ fromFile: boolean;
23
+ /** 文件来源时解析后的绝对路径(仅用于回执与错误诊断) */
24
+ file?: string;
25
+ }
26
+ export interface BodySourceOptions {
27
+ /** 相对路径的解析基准(工具层传 `executor.resolveExecScope` 得到的工作区) */
28
+ cwd: string;
29
+ /** 内联 flag,缺省 `--body`(discussion 用 `-b`) */
30
+ flag?: string;
31
+ /** 文件 flag,缺省 `--body-file`(release 用 `--notes-file`,api/agent-task 用各自 flag) */
32
+ fileFlag?: string;
33
+ /** 内联字段名,仅用于错误文案(缺省 `body`) */
34
+ inlineField?: string;
35
+ /** 文件字段名,仅用于错误文案(缺省 `body_file`) */
36
+ fileField?: string;
37
+ /** 无正文时仍下发 `[flag, '']`:create 需要它保持非交互(否则 gh 会打开编辑器) */
38
+ keepEmptyInline?: boolean;
39
+ /** 字符上限覆盖;传 0 关闭字符校验(`gh api --input` 的请求体不受正文口径约束) */
40
+ maxChars?: number;
41
+ }
42
+ /**
43
+ * 解析正文来源。
44
+ *
45
+ * 语义表(与计划文档一致,调用方不再各自二次决策):
46
+ * | 输入 | argv | stdin |
47
+ * | --- | --- | --- |
48
+ * | `file` 有值且文件非空 | `[fileFlag, '-']` | 文件正文 |
49
+ * | `file` 有值但文件为空 | 同「都为空」 | — |
50
+ * | `inline` 非空 | `[flag, inline]` | — |
51
+ * | 都为空 + keepEmptyInline | `[flag, '']` | — |
52
+ * | 都为空 | `[]` | — |
53
+ * | 两者都非空 | 抛错(不做静默优先级) | — |
54
+ */
55
+ export declare function resolveBodySource(input: {
56
+ inline?: unknown;
57
+ file?: unknown;
58
+ }, options?: BodySourceOptions): BodySource;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * 「正文/长文本来自本地文件」的统一入参解析(纯函数,零 gh 依赖)。
3
+ *
4
+ * 为什么需要它:Agent 的典型工作流是**先把报告写成 md 文件**,再提交成 Issue / PR 评论 /
5
+ * 讨论 / 发版说明。改造前这些入参只有字符串 `body`,模型只能把整篇正文搬进参数——
6
+ * 既烧上下文,又会在 Windows 约 32K 的命令行上限处炸掉(`E2BIG/EINVAL`,错误还很难懂)。
7
+ * 于是 Agent 会绕开插件直接用 `gh issue create -F report.md`,插件的确认闸门/错误分类/渲染
8
+ * 全部被跳过。
9
+ *
10
+ * 为什么不让 gh 自己去读文件(`--body-file <path>`):`pr review` 的行级评注走 REST
11
+ * (`POST /pulls/{n}/reviews`),那条通道根本没有 flag,只能插件侧读文件后塞 JSON。
12
+ * 两套通道各写一份,必然漂移成「带 comments 时 body_file 静默失效」。因此统一为:
13
+ * **插件读一次文件** →
14
+ * - CLI 通道:`[fileFlag, '-']` + `stdin`(`--body-file -` 由 gh 读 stdin,无 argv 长度上限);
15
+ * - REST/GraphQL 通道:直接用 `text`。
16
+ *
17
+ * 兼容性纪律:内联来源的 argv 必须与本模块引入前**逐字节一致**(`[flag, text]`),
18
+ * 新通道只在「传了 `*_file`」时才改变 argv。
19
+ */
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ /**
23
+ * GitHub 正文上限(Issue/PR/评论/讨论统一口径 65,536 字符)。
24
+ * 抽成常量而不是散落字面量:这是上游约束,将来调整只改一处。
25
+ */
26
+ export const GITHUB_BODY_MAX_CHARS = 65_536;
27
+ /**
28
+ * 单个正文文件的字节硬上限(4MB)。
29
+ * 字符上限只管正文语义,管不住「模型指了一个 200MB 日志文件」——先按字节拦住,
30
+ * 避免 readFileSync 把整个文件拉进内存再判定。
31
+ */
32
+ export const BODY_FILE_MAX_BYTES = 4 * 1024 * 1024;
33
+ /** 严格 UTF-8 解码器:GBK/ANSI 存的中文 md 会抛错,而不是静默产出乱码 */
34
+ const strictUtf8 = new TextDecoder('utf-8', { fatal: true });
35
+ function emptyResult(flag, keepEmptyInline) {
36
+ return { argv: keepEmptyInline ? [flag, ''] : [], text: '', chars: 0, fromFile: false };
37
+ }
38
+ function inlineResult(flag, text) {
39
+ return { argv: [flag, text], text, chars: text.length, fromFile: false };
40
+ }
41
+ /**
42
+ * 解析正文来源。
43
+ *
44
+ * 语义表(与计划文档一致,调用方不再各自二次决策):
45
+ * | 输入 | argv | stdin |
46
+ * | --- | --- | --- |
47
+ * | `file` 有值且文件非空 | `[fileFlag, '-']` | 文件正文 |
48
+ * | `file` 有值但文件为空 | 同「都为空」 | — |
49
+ * | `inline` 非空 | `[flag, inline]` | — |
50
+ * | 都为空 + keepEmptyInline | `[flag, '']` | — |
51
+ * | 都为空 | `[]` | — |
52
+ * | 两者都非空 | 抛错(不做静默优先级) | — |
53
+ */
54
+ export function resolveBodySource(input, options = { cwd: process.cwd() }) {
55
+ const flag = options.flag ?? '--body';
56
+ const fileFlag = options.fileFlag ?? '--body-file';
57
+ const inlineField = options.inlineField ?? 'body';
58
+ const fileField = options.fileField ?? 'body_file';
59
+ const maxChars = options.maxChars ?? GITHUB_BODY_MAX_CHARS;
60
+ const rawInline = input.inline;
61
+ if (rawInline !== undefined && rawInline !== null && typeof rawInline !== 'string') {
62
+ throw new Error(`${inlineField} 必须是字符串。`);
63
+ }
64
+ // 不做 trim:内联正文要原样下发,保持与改造前逐字节一致
65
+ const inline = typeof rawInline === 'string' ? rawInline : '';
66
+ const rawFile = input.file;
67
+ if (rawFile === undefined || rawFile === null) {
68
+ return inline.length > 0 ? inlineResult(flag, inline) : emptyResult(flag, options.keepEmptyInline);
69
+ }
70
+ if (typeof rawFile !== 'string') {
71
+ throw new Error(`${fileField} 必须是本地文件路径字符串(绝对路径或相对当前工作区的路径)。`);
72
+ }
73
+ const fileInput = rawFile.trim();
74
+ // 空串视同未提供:调用方(含契约测试的样例表)用一个空串表达「这条路不走」
75
+ if (!fileInput) {
76
+ return inline.length > 0 ? inlineResult(flag, inline) : emptyResult(flag, options.keepEmptyInline);
77
+ }
78
+ // 二选一而非优先级:模型拿到一次明确失败,远好过「不知道哪份真正生效」
79
+ if (inline.length > 0) {
80
+ throw new Error(`${inlineField} 与 ${fileField} 同时提供:请二选一(长正文用 ${fileField},短文本用 ${inlineField})。`);
81
+ }
82
+ const abs = path.isAbsolute(fileInput) ? fileInput : path.resolve(options.cwd, fileInput);
83
+ let stat;
84
+ try {
85
+ stat = fs.statSync(abs);
86
+ }
87
+ catch {
88
+ throw new Error(`${fileField} 指向的文件不存在: "${abs}"(输入: "${fileInput}")。`);
89
+ }
90
+ if (!stat.isFile()) {
91
+ throw new Error(`${fileField} 必须是普通文件,当前指向的不是文件: "${abs}"。`);
92
+ }
93
+ if (stat.size > BODY_FILE_MAX_BYTES) {
94
+ // 用字节数报错而不是 toFixed(1) 的 MB:4MB+1 字节会被四舍五入显示成「4.0MB 超过 4MB」,
95
+ // 看上去像插件算错了,反而增加排查成本
96
+ throw new Error(`${fileField} 文件过大: ${stat.size} 字节,超过 ${BODY_FILE_MAX_BYTES} 字节(4MB)上限。`);
97
+ }
98
+ const buf = fs.readFileSync(abs);
99
+ // 二进制先判、再解码:PNG 之类在 UTF-8 解码阶段会报「不是合法 UTF-8」并给出 GBK 另存建议,
100
+ // 对二进制文件是误导;NUL 字节是更准确的判据(文本文件不会有)
101
+ if (buf.includes(0)) {
102
+ throw new Error(`${fileField} 内容是二进制(含 NUL 字节),不能作为正文: "${abs}"。`);
103
+ }
104
+ let text;
105
+ try {
106
+ text = strictUtf8.decode(buf);
107
+ }
108
+ catch {
109
+ throw new Error(`${fileField} 不是合法 UTF-8 文本: "${abs}"。Windows 上常见 GBK/ANSI 编码,请另存为 UTF-8 后重试。`);
110
+ }
111
+ // 解码头本身会剥 BOM(TextDecoder 的 ignoreBOM 默认 false);这里显式再剥一次是防御:
112
+ // 一旦有人把上面改成 ignoreBOM: true,正文首字符就会变成零宽 BOM 字符
113
+ if (text.charCodeAt(0) === 0xfeff)
114
+ text = text.slice(1);
115
+ const chars = text.length;
116
+ if (maxChars > 0 && chars > maxChars) {
117
+ throw new Error(`${fileField} 正文过长: ${chars} 字符,超过上限 ${maxChars} 字符。请拆分或精简后再提交。`);
118
+ }
119
+ // 空文件不进 stdin 通道:不依赖 gh 对空 stdin 的处理,语义与「没正文」完全一致
120
+ if (chars === 0)
121
+ return emptyResult(flag, options.keepEmptyInline);
122
+ return { argv: [fileFlag, '-'], stdin: text, text, chars, fromFile: true, file: abs };
123
+ }
@@ -26,6 +26,10 @@ export declare function createDiscussionTool(executor: GhExecutor): {
26
26
  type: string;
27
27
  description: string;
28
28
  };
29
+ body_file: {
30
+ type: string;
31
+ description: string;
32
+ };
29
33
  category: {
30
34
  type: string;
31
35
  description: string;
@@ -1,5 +1,6 @@
1
1
  import { wrapWithGate } from '../destructive.js';
2
2
  import { renderDiscussionMarkdown } from '../renderers/markdown.js';
3
+ import { resolveBodySource } from './body-input.js';
3
4
  function normalizeOutput(data) {
4
5
  if (data && typeof data === 'object' && !Array.isArray(data)) {
5
6
  return data;
@@ -37,7 +38,11 @@ export function createDiscussionTool(executor) {
37
38
  },
38
39
  body: {
39
40
  type: 'string',
40
- description: '讨论正文或评论正文(create, comment 必需,edit 动作修改正文时可选)。',
41
+ description: '讨论正文或评论正文(create, comment 必需,edit 动作修改正文时可选)。与 body_file 二选一。',
42
+ },
43
+ body_file: {
44
+ type: 'string',
45
+ description: '正文来源:本地 Markdown 文件路径(相对路径按当前工作区解析)。与 body 二选一;长正文用本参数,无需把全文读进上下文。',
41
46
  },
42
47
  category: {
43
48
  type: 'string',
@@ -78,6 +83,14 @@ export function createDiscussionTool(executor) {
78
83
  const { cwd, sessionId } = executor.resolveExecScope(execContext);
79
84
  const repoArgs = args.repo ? ['-R', args.repo] : [];
80
85
  const runOpts = { cwd, sessionId };
86
+ // 正文来自本地文件时经 stdin 注入(`--body-file -`):绕开 Windows 约 32K 的命令行上限
87
+ const stdinPatch = (body) => (body.stdin === undefined ? {} : { stdin: body.stdin });
88
+ /*
89
+ * 内联正文沿用改造前的 trim 口径(旧代码是 `-b args.body.trim()`):discussion 历史上
90
+ * 就按去空白下发,改成原样会动到既有调用方的 argv。文件来源不 trim——那是用户排好版的
91
+ * Markdown,首尾空行/缩进可能有意义,strip 掉等于替用户改稿。
92
+ */
93
+ const trimInline = (v) => (typeof v === 'string' ? v.trim() : v);
81
94
  switch (args.action) {
82
95
  case 'list': {
83
96
  const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.min(args.limit, 100) : 30;
@@ -126,8 +139,10 @@ export function createDiscussionTool(executor) {
126
139
  if (!args.title || typeof args.title !== 'string' || !args.title.trim()) {
127
140
  throw new Error('create 操作必须提供 title(讨论标题)');
128
141
  }
129
- if (!args.body || typeof args.body !== 'string' || !args.body.trim()) {
130
- throw new Error('create 操作必须提供 body(讨论正文)');
142
+ const body = resolveBodySource({ inline: trimInline(args.body), file: args.body_file }, { cwd, flag: '-b' });
143
+ // 判空只看解析后的权威正文:文件来源时 args.body 恒为空,拿它判定会误杀
144
+ if (body.chars === 0) {
145
+ throw new Error('create 操作必须提供 body 或 body_file(讨论正文)');
131
146
  }
132
147
  if (!args.category || typeof args.category !== 'string' || !args.category.trim()) {
133
148
  throw new Error('create 操作必须提供 category(讨论分类,例如 "General"、"Q&A"、"Ideas")');
@@ -135,11 +150,11 @@ export function createDiscussionTool(executor) {
135
150
  const createCmd = [
136
151
  'discussion', 'create',
137
152
  '-t', args.title.trim(),
138
- '-b', args.body.trim(),
153
+ ...body.argv,
139
154
  '-c', args.category.trim(),
140
155
  ...repoArgs,
141
156
  ];
142
- const res = await executor.run(createCmd, { ...runOpts, rawText: true, timeoutMs: 60_000 });
157
+ const res = await executor.run(createCmd, { ...runOpts, rawText: true, timeoutMs: 60_000, ...stdinPatch(body) });
143
158
  if (!res.ok)
144
159
  throw new Error(res.error);
145
160
  return {
@@ -151,15 +166,15 @@ export function createDiscussionTool(executor) {
151
166
  if (!args.discussion_number || typeof args.discussion_number !== 'number') {
152
167
  throw new Error('edit 操作必须提供 discussion_number(讨论数字编号)');
153
168
  }
154
- if (!args.title && !args.body) {
169
+ const body = resolveBodySource({ inline: trimInline(args.body), file: args.body_file }, { cwd, flag: '-b' });
170
+ if (!args.title && body.chars === 0) {
155
171
  throw new Error('edit 操作至少需提供 title 或 body 中的一项进行修改');
156
172
  }
157
173
  const editCmd = ['discussion', 'edit', String(args.discussion_number), ...repoArgs];
158
174
  if (args.title)
159
175
  editCmd.push('-t', String(args.title).trim());
160
- if (args.body)
161
- editCmd.push('-b', String(args.body).trim());
162
- const res = await executor.run(editCmd, { ...runOpts, rawText: true, timeoutMs: 60_000 });
176
+ editCmd.push(...body.argv);
177
+ const res = await executor.run(editCmd, { ...runOpts, rawText: true, timeoutMs: 60_000, ...stdinPatch(body) });
163
178
  if (!res.ok)
164
179
  throw new Error(res.error);
165
180
  return {
@@ -171,15 +186,16 @@ export function createDiscussionTool(executor) {
171
186
  if (!args.discussion_number || typeof args.discussion_number !== 'number') {
172
187
  throw new Error('comment 操作必须提供 discussion_number(讨论数字编号)');
173
188
  }
174
- if (!args.body || typeof args.body !== 'string' || !args.body.trim()) {
175
- throw new Error('comment 操作必须提供 body(评论正文)');
189
+ const body = resolveBodySource({ inline: trimInline(args.body), file: args.body_file }, { cwd, flag: '-b' });
190
+ if (body.chars === 0) {
191
+ throw new Error('comment 操作必须提供 body 或 body_file(评论正文)');
176
192
  }
177
193
  const commentCmd = [
178
194
  'discussion', 'comment', String(args.discussion_number),
179
- '-b', args.body.trim(),
195
+ ...body.argv,
180
196
  ...repoArgs,
181
197
  ];
182
- const res = await executor.run(commentCmd, { ...runOpts, rawText: true, timeoutMs: 60_000 });
198
+ const res = await executor.run(commentCmd, { ...runOpts, rawText: true, timeoutMs: 60_000, ...stdinPatch(body) });
183
199
  if (!res.ok)
184
200
  throw new Error(res.error);
185
201
  return {