aura-basetools-dsh 0.7.0 → 0.13.0

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.
@@ -29,7 +29,7 @@ import z from '@deepseek-ai/schemastery'
29
29
  import { defineTool } from '@deepseek-ai/dsh-tools'
30
30
  // 读取逻辑本身框架无关,住在 locator-lab;本文件是它的 DSH 适配器。
31
31
  import { readWith, readManyWith, MODES, BYTE_ORDER_MARK_BYTES, startsWithByteOrderMark } from 'aura-basetools/locator-lab/read-core.mjs'
32
- import { LINE_ADDRESS_HINT, asLinePair, assertKnownArgs, assertReadTargets, assertTargetShape, gaveLineAddress, inferReadMode, lineRangeOf } from 'aura-basetools/locator-lab/args.mjs'
32
+ import { MT_READ_ELEMENT_KEYS, asLinePair, assertKnownArgs, assertReadTargets, assertTargetShape, inferReadMode, lineRangeOf, normalizeReadRequest } from 'aura-basetools/locator-lab/args.mjs'
33
33
  // 仓库级地图(T26):引擎驱动遍历,本文件只把 ctx.fs 的两个原始能力接上去。
34
34
  import { collectRepoMap } from 'aura-basetools/locator-lab/repo-map.mjs'
35
35
 
@@ -80,6 +80,7 @@ export const Config = z.object({})
80
80
  */
81
81
  export const PASSTHROUGH_FIELDS = [
82
82
  'symbols',
83
+ 'metrics',
83
84
  'version',
84
85
  'omitted',
85
86
  'next',
@@ -152,6 +153,10 @@ export const READ_ARG_KEYS = [
152
153
  'context',
153
154
  'since',
154
155
  'kind',
156
+ 'max_chars',
157
+ 'budget',
158
+ 'offset',
159
+ 'limit',
155
160
  // fs-contract-v2 §2.1:多目标形状。它与 `file_path` **互斥**(判据只有一份:`assertReadTargets`)。
156
161
  'targets',
157
162
  ]
@@ -407,7 +412,7 @@ function batchEntryOf(entry) {
407
412
  * 返回对象是**必须**的,不再是“可选美化”:句柄的身份标签按 `ledgerIdentityKey(filePath, identityKey)`
408
413
  * 算,而单目标那条路递的是 `ctx.fs.resolve()` 的 `targetKey`(C61)。多目标若只把路径串当身份,
409
414
  * 同一文件同一区间在两条路上会得到**两个不同的句柄**(实测 `h4745015d4840:…` vs `he6dd015d4840:…`),
410
- * 于是“一次读一批、再一次改一批”这条正路会在 `aura_edit` 上以 `PATCH_HANDLE_IDENTITY_MISMATCH` 断掉。
415
+ * 于是“一次读一批、再一次改一批”这条正路会在 `aura_basetools_edit` 上以 `PATCH_HANDLE_IDENTITY_MISMATCH` 断掉。
411
416
  * `readManyTarget` 现在把 `displayPath` 当显示路径、`targetKey` 当身份键(**不新造第三份口径**:
412
417
  * `ledgerIdentityKey` / `IDENTITY_KEYS` 就是既有那一份)。
413
418
  * · 同一路径不得解析三次(resolve 在真实后端上是 realpath)⇒ 按路径**记忆化一个 Promise**,
@@ -476,7 +481,7 @@ async function readTargetsBatch(ctx, exec, targets) {
476
481
  }
477
482
 
478
483
  /**
479
- * 注册 `aura_read` 工具,以及配套的提示段。
484
+ * 注册 `aura_basetools_read` 工具,以及配套的提示段。
480
485
  *
481
486
  * ## 为什么必须同时注册提示段
482
487
  *
@@ -504,14 +509,14 @@ export function apply(ctx) {
504
509
  name: 'tool:aura-read',
505
510
  order: 1310,
506
511
  text: ({ scope }) =>
507
- ctx.tools.get('aura_read', scope) === undefined
512
+ ctx.tools.get('aura_basetools_read', scope) === undefined
508
513
  ? ''
509
514
  : [
510
515
  // 措辞与内置指导**同形**(命令式)。理由见 aura-edit 里同一处的注释:
511
516
  // 建议式的措辞在实测中只在部分会话里生效——它是在跟先验拔河。
512
517
  // 兜底措辞分两处写:开头**不**给平级选项(给了就等于发许可证——实测中性任务下模型会顺着它选内置),
513
- // 只在末尾给"aura_read 真做不到"的那两种情况。
514
- 'Use the aura_read tool — not read — for every text-file read: it is the default, and it shows structure instead of raw bytes.',
518
+ // 只在末尾给"aura_basetools_read 真做不到"的那两种情况。
519
+ 'Use the aura_basetools_read tool — not read — for every text-file read: it is the default, and it shows structure instead of raw bytes.',
515
520
  'Start with `map` when you do not yet know the file (an importance-ranked symbol directory); use `around` or a line range (`start_line` + `end_line`) for the part you will edit; `digest` for a file\'s interface and dependencies; `changed` to see what moved since a previous read.',
516
521
  // 刻意**不再**写"未知扩展名请用内置 read"。实测这句话是错的:未知扩展名上
517
522
  // `auto` 会降级到 `full`,内容照给(见 locator-lab/disable-check.mjs)。
@@ -522,13 +527,13 @@ export function apply(ctx) {
522
527
  // 措辞仍是命令式,与上下文同形;放在兜底条件**之前**,让“何时该用内置 read”仍是最后一句。
523
528
  'When several files are needed — or several places in files you are about to change — pass `targets` instead of calling once per file: one call, one receipt per target, output in input order, and a missing file does not fail the rest.',
524
529
  // 兜底条件写明白:不写会让模型在确实需要内置工具时犹豫(超大窗口 / aura 直接拒读)。
525
- 'The built-in `read` remains only for what aura_read cannot do: a raw window larger than one reply, or a file aura_read refuses.',
530
+ 'The built-in `read` remains only for what aura_basetools_read cannot do: a raw window larger than one reply, or a file aura_basetools_read refuses.',
526
531
  ].join(' '),
527
532
  })
528
533
 
529
534
  ctx.tools.register(
530
535
  defineTool({
531
- name: 'aura_read',
536
+ name: 'aura_basetools_read',
532
537
  description:
533
538
  'Read a file the way you actually need it. Seven modes, and `auto` picks the smallest one for you: ' +
534
539
  '`map` (what is in this file — an importance-ranked symbol directory), ' +
@@ -628,13 +633,29 @@ export function apply(ctx) {
628
633
  type: 'string',
629
634
  description: 'mode=search: a substring to find across the file.',
630
635
  },
636
+ max_chars: {
637
+ type: 'integer',
638
+ description: 'Ask for a smaller content budget than the host ceiling (chars).',
639
+ },
640
+ budget: {
641
+ type: 'integer',
642
+ description: 'Pipeline ceiling in chars; overrides the host pruner threshold for this call.',
643
+ },
644
+ offset: {
645
+ type: 'integer',
646
+ description: 'Pagination: 0-based line offset; use with `limit` (mutually exclusive with lines/start_line+end_line).',
647
+ },
648
+ limit: {
649
+ type: 'integer',
650
+ description: 'Pagination: number of lines (default 2000); use with `offset`.',
651
+ },
631
652
  context: {
632
653
  type: 'integer',
633
654
  description: 'mode=around: how many lines of context on each side. Default 8.',
634
655
  },
635
656
  since: {
636
657
  type: 'string',
637
- description: 'mode=changed: the `version` value from a previous aura_read of this file.',
658
+ description: 'mode=changed: the `version` value from a previous aura_basetools_read of this file.',
638
659
  },
639
660
  kind: {
640
661
  type: 'string',
@@ -667,7 +688,7 @@ export function apply(ctx) {
667
688
  totalLines: { type: 'integer' },
668
689
  // `text` 必须声明。第一版漏了它——而渲染器返回的正是 `text`,
669
690
  // 于是**每一条成功路径**都被框架以 `additionalProperties: false` 拒绝:
670
- // tool "aura_read" returned invalid output: "value.text" is not a declared property
691
+ // tool "aura_basetools_read" returned invalid output: "value.text" is not a declared property
671
692
  // 我的 load-test 当时 29/29 全绿,因为它从不校验输出是否合自己声明的 schema。
672
693
  // 现在那道校验补上了,这类错误不会再穿过测试。
673
694
  text: { type: 'string', required: true },
@@ -702,6 +723,21 @@ export function apply(ctx) {
702
723
  },
703
724
  },
704
725
  symbols: { type: 'integer' },
726
+ /**
727
+ * 0.9.8:确定性的回执 metrics(引擎统一附加)。只放可由内容复算的事实,
728
+ * 不放墙钟;`truncated` 表示 `omitted` 是否存在(有没有被预算裁过)。
729
+ */
730
+ metrics: {
731
+ type: 'object',
732
+ additionalProperties: false,
733
+ properties: {
734
+ mode: { type: 'string', required: true },
735
+ lines: { type: 'integer', required: true },
736
+ chars: { type: 'integer', required: true },
737
+ symbols: { type: 'integer' },
738
+ truncated: { type: 'boolean', required: true },
739
+ },
740
+ },
705
741
  // `version` 可选,而不是必填。历史:它曾是必填,而当时 `changed` 在 `since` 缺省下不给令牌,
706
742
  // 于是 around/lines/search 与所有未索引扩展名上它是 undefined,真实会话里 .json/.yml/.md
707
743
  // 的读取被框架整条拒掉,才改成可选。
@@ -873,31 +909,29 @@ export function apply(ctx) {
873
909
  * 它抛的是**参数面**错(`ARGS_TARGETS_SHAPE` / `ARGS_TARGETS_LIMIT` / `ARGS_SHAPE_EXCLUSIVE`),
874
910
  * 逐项失败则是结果通道(引擎已把每项封成结果,不抛)。
875
911
  */
876
- const batch = assertReadTargets(args, { toolName: 'aura_read' })
912
+ const batch = assertReadTargets(args, { toolName: 'aura_basetools_read', elementKeys: MT_READ_ELEMENT_KEYS })
877
913
  if (batch !== undefined) {
878
914
  // 多目标形状同样要过未知参数守卫:拼错的键不因为换了形状就被放行。
879
- assertKnownArgs(args, READ_ARG_KEYS, 'aura_read')
915
+ assertKnownArgs(args, READ_ARG_KEYS, 'aura_basetools_read')
880
916
  return await readTargetsBatch(ctx, exec, batch)
881
917
  }
882
918
  /**
883
919
  * “二选一”的**另一半**(两个都没给 ⇒ 也拒)只有一份判据,住在 `args.mjs` 的
884
920
  * `assertTargetShape`(`src/locator-lab/args.mjs:455`)——壳里原来那句
885
921
  * `file_path must be a non-empty string` 是特例写法,已删(它把“没有形状可用”
886
- * 说成了“file_path 写错了”,而 aura_edit 壳里还长着同一句:两处必然会漂)。
922
+ * 说成了“file_path 写错了”,而 aura_basetools_edit 壳里还长着同一句:两处必然会漂)。
887
923
  * 抛的仍是参数面同族码 `ARGS_SHAPE_EXCLUSIVE`;单目标调用照旧带 `file_path`,故这条路一字未变。
888
924
  */
889
- assertTargetShape(args, { toolName: 'aura_read' })
890
- // 参数形状在这里收敛一次:后面只认 {start, end},三个调用面(工具壳/CLI/MCP)判据一致。
891
- const lineRange = lineRangeOf(args)
892
- if (lineRange === undefined && gaveLineAddress(args)) {
893
- throw new Error(LINE_ADDRESS_HINT)
925
+ assertTargetShape(args, { toolName: 'aura_basetools_read' })
926
+ // 参数形状在这里收敛一次:行区间 / 分页 / 模式推断与 CLI/MCP、targets 元素共用
927
+ // 同一个 `normalizeReadRequest`。旧版这里只看 `lines`,`offset/limit` 在本面被静默忽略。
928
+ assertKnownArgs(args, READ_ARG_KEYS, 'aura_basetools_read')
929
+ const shape = normalizeReadRequest(args)
930
+ if (shape.ok !== true) {
931
+ throw Object.assign(new Error(shape.error), { code: shape.code })
894
932
  }
895
- // 给了行区间就用 lines 模式,省掉"顺手再写一个 mode"的机会(写了也尊重)。
896
- // 未知参数不回默认行为:拼错的字段被静默忽略时,模型看到的是**成功**。
897
- assertKnownArgs(args, READ_ARG_KEYS, 'aura_read')
898
- // 未知参数不回默认行为:拼错的字段被静默忽略时,模型看到的是**成功**。
899
- assertKnownArgs(args, READ_ARG_KEYS, 'aura_read')
900
- const mode = inferReadMode(args, lineRange)
933
+ let mode = shape.mode
934
+ let lineRange = shape.lines
901
935
  const cwd = exec.agent?.session?.header?.cwd
902
936
  const target = await ctx.fs.resolve(args.file_path, {
903
937
  ...(cwd !== undefined ? { cwd } : {}),
@@ -907,12 +941,15 @@ export function apply(ctx) {
907
941
  const info = await ctx.fs.stat(target, exec.signal)
908
942
  if (info === undefined) {
909
943
  // **缺失也要记录成一次观测。** 这是 dsh-tool-fs 的契约:`fs/observed` 的
910
- // `{kind:'absent'}` 让后续 `write` 能走"带防护的创建"而不是被策略拒绝。
911
- // 我第一版直接抛错、不记录,于是"读一个还不存在的文件,然后创建它"这条
944
+ // `{kind:'absent'}` 让后续 `create` 能走"带防护的创建"而不是被策略拒绝。
945
+ // 我第一版直接抛错、不记录,于是"读一个还不存在的文件、然后创建它"这条
912
946
  // 完全正常的流程会在写入侧失败——而且失败原因看起来与读取毫无关系。
913
947
  ctx.emit('fs/observed', target, { kind: 'absent' }, exec)
914
948
  throw new Error(`cannot read "${target.displayPath}": not found`)
915
949
  }
950
+ if (info.type === 'directory' && shape.paginate) {
951
+ throw Object.assign(new Error('offset/limit applies to files; a directory is returned as a repo map — drop pagination'), { code: 'READ_BAD_ARGS' })
952
+ }
916
953
  /**
917
954
  * 目录:**不读文件,直接给一张仓库地图**(T26)。
918
955
  *
@@ -971,7 +1008,7 @@ export function apply(ctx) {
971
1008
  source = await ctx.fs.readText(target, exec.signal)
972
1009
  } catch (error) {
973
1010
  if (!isNonUtf8TextFailure(error)) throw error
974
- throw Object.assign(new Error(nonUtf8RefusalMessage('read', target.displayPath, 'aura_read')), {
1011
+ throw Object.assign(new Error(nonUtf8RefusalMessage('read', target.displayPath, 'aura_basetools_read')), {
975
1012
  code: typeof error.code === 'string' && error.code.length > 0 ? error.code : 'ERR_ENCODING_INVALID_ENCODED_DATA',
976
1013
  cause: error,
977
1014
  })
@@ -979,6 +1016,16 @@ export function apply(ctx) {
979
1016
  // BOM 只存在于字节里(readText 会在解码时丢掉它)——版本令牌要能区分只差 BOM 的两个文件,
980
1017
  // 就必须把这个事实从字节里取出来交给引擎。
981
1018
  const byteOrderMark = await fileStartsWithByteOrderMark(ctx.fs, target, exec.signal)
1019
+ /**
1020
+ * 第二遍收敛:拿到总行数后才能做 offset 越界与尾行截断。
1021
+ * 与单目标 `readEnvelope`、targets 元素**同一个**判定器。
1022
+ */
1023
+ const finalShape = normalizeReadRequest(args, source.split(/\r?\n/).length)
1024
+ if (finalShape.ok !== true) {
1025
+ throw Object.assign(new Error(finalShape.error), { code: finalShape.code })
1026
+ }
1027
+ mode = finalShape.mode
1028
+ lineRange = finalShape.lines
982
1029
  /**
983
1030
  * 宿主给"单条工具结果"定的字符上限,**当场读**(不在引擎里抄)。
984
1031
  *
@@ -992,6 +1039,8 @@ export function apply(ctx) {
992
1039
  * 且因为披露机制在,偏保守不会丢信息。
993
1040
  */
994
1041
  const hostCeiling = (() => {
1042
+ const explicit = Number.isInteger(args.budget) && args.budget > 0 ? args.budget : undefined
1043
+ if (explicit !== undefined) return explicit
995
1044
  const pruner = ctx.get('toolResultPruner')
996
1045
  const threshold = pruner?.config?.thresholdChars
997
1046
  return Number.isInteger(threshold) && threshold > 0 ? threshold : undefined
@@ -1065,6 +1114,8 @@ export function apply(ctx) {
1065
1114
  // changed 的 `since` 用文件版本而非内容指纹:调用方从 read 结果里拿到的是版本,
1066
1115
  // 而版本来自 fs 后端,因此跨进程、跨工具都一致。
1067
1116
  ...(args.since === undefined ? {} : { since: args.since }),
1117
+ // `max_chars` 是调用方要求**更小**的预算;与 CLI/MCP/多目标同一字段。
1118
+ ...(args.max_chars === undefined ? {} : { maxChars: args.max_chars }),
1068
1119
  // 流水线硬上限**现读现传**,不在引擎里抄一个常量。
1069
1120
  // `toolResultPruner.config.thresholdChars` 是可配置的(dsh-base 与三个 preset 都显式写着),
1070
1121
  // 所以"抄进引擎"会在宿主改配置时静默失准(调大→白白少给,调小→中段被剪)。
package/cordis.patch.yml CHANGED
@@ -4,6 +4,7 @@
4
4
  # (dsh-app-boot/README.md:59),因此包装在 Windows / macOS / Linux、装在任意目录都成立。
5
5
  #
6
6
  # 内置 read / write / edit / read_image 一个都没停用:只加不减。
7
+ # aura_basetools_write 与 aura_basetools_edit 由同一个插件模块注册(只多一个工具,不多一行 patch)。
7
8
  # read_image 没有 Aura 替代品,停用 tool-fs 会净损失读图能力。
8
9
  - insert:
9
10
  - id: tool-aura-read
@@ -0,0 +1,290 @@
1
+ /**
2
+ * argv-shell —— 宿主钩子专用的「不经任何 shell」执行器(`ctx.shell` 的提供者)。
3
+ *
4
+ * 【这是什么】把钩子桥交来的命令【字符串】按最小词法切成 argv,直接交给
5
+ * `ctx.subprocess.spawn({ argv })`;中间不启动任何 shell、不做命令替换、不做元字符解释。
6
+ * 语义等价于 `@deepseek-ai/dsh-native-command` 的 `runNativeCommand`(execFile,argv 原样给 OS),
7
+ * 只是搬到了 shell 接缝上 —— 因为 `dsh-hook-protocol` 的 `runHook()` 只认
8
+ * `ctx.shell.run(ctx.shell.resolve({ command }))`,钩子配置里没有任何 argv 字段。
9
+ *
10
+ * 【为什么】宿主现行 `ctx.shell` 是 `@deepseek-ai/dsh-pwsh-local`:命令串被拼成
11
+ * `pwsh -NoLogo -NoProfile -NonInteractive -Command "<前置编码指令><串>"` 再解析一遍。
12
+ * PreToolUse 每个工具调用都要付这一层(p50 686ms,n=58,139),其中 76–84% 是这层壳,
13
+ * 真正的判定+落账只占 4.7%。换成本执行器 = 把壳层整个删掉。
14
+ *
15
+ * 【口径】只做最小、可预测的词法:空白分词;双/单引号成组并去掉引号;反斜杠只在成对引号内
16
+ * 转义(Windows 路径里的 `\` 必须原样保留)。`;` `|` `&` `$` `%` 反引号【都不是元字符】——
17
+ * 这正是它与 shell 的全部差别。引号不配对 = 抛错(不猜,宁可响亮失败)。
18
+ *
19
+ * 【边界】只实现 `run()`(钩子桥只用它)。`start()` 明确抛错:本执行器只挂在
20
+ * `polaris-hooks-unconfined` 隔离组里,组内没有任何后台消费者 —— 不做半个实现。
21
+ *
22
+ * 【回滚】把 `<DSH_HOME>/profiles/<profile>/cordis.patch.yml` 里那一行的 name 改回
23
+ * `@deepseek-ai/dsh-pwsh-local` 即可(本文件可以留着,没人指向它就永远不会被 import)。
24
+ *
25
+ * @module dsh/argv-shell
26
+ */
27
+ import path from 'node:path'
28
+ import { ShellExecutor } from '@deepseek-ai/dsh-shell'
29
+ import z from '@deepseek-ai/schemastery'
30
+
31
+ const DEFAULT_GRACE_MS = 3000
32
+ const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
33
+ /** 与 pwsh-local 同款的非交互环境覆盖:去掉颜色与分页器,免得混进被抓取的 stdout。 */
34
+ const ENV_OVERRIDES = { NO_COLOR: '1', PAGER: 'cat', GIT_PAGER: 'cat' }
35
+
36
+ /**
37
+ * 命令串 -> argv。纯函数、无副作用、不碰任何 shell 语义。
38
+ * @param command - 钩子配置里的命令串(跨 shell 同形写法:只用双引号、路径用正斜杠)。
39
+ * @returns argv;argv[0] 是程序,其余原样传给子进程。
40
+ * @throws 引号不配对时抛错(不猜一个"大概是这个意思"的 argv)。
41
+ */
42
+ export function tokenizeArgv (command) {
43
+ if (typeof command !== 'string') throw new TypeError('argv-shell: command must be a string')
44
+ const out = []
45
+ let token = ''
46
+ let started = false
47
+ let quote = null
48
+ for (let i = 0; i < command.length; i++) {
49
+ const ch = command[i]
50
+ if (quote !== null) {
51
+ // 反斜杠只在【紧跟本段引号】时转义;其余一律字面(Windows 路径不能被吃掉反斜杠)。
52
+ if (ch === '\\' && command[i + 1] === quote) { token += quote; i++; continue }
53
+ if (ch === quote) { quote = null; continue }
54
+ token += ch
55
+ continue
56
+ }
57
+ if (ch === '"' || ch === "'") { quote = ch; started = true; continue }
58
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
59
+ if (started) { out.push(token); token = ''; started = false }
60
+ continue
61
+ }
62
+ token += ch
63
+ started = true
64
+ }
65
+ if (quote !== null) throw new Error('argv-shell: unbalanced ' + quote + ' in command: ' + command)
66
+ if (started) out.push(token)
67
+ return out
68
+ }
69
+
70
+ // —— AuraPolaris PreToolUse 钩子的【进程内短路支路】———————————————
71
+ // 【为什么】这条钩子是每个工具调用都要付一次的固定成本,而它要干的判定只有几毫秒:剩下全是
72
+ // "起两个 Node 进程"的税(宿主 → windows-job runner → 真身)。本执行器【就活在宿主进程里】
73
+ // (见文件头:它是 ctx.shell 的提供者),所以这一次调用完全可以不出进程。
74
+ // 【代价面】2026-10-15 实测(串行、每路 n=24):
75
+ // spawn 路径(含 windows-job runner 间接层)p50 ≈ 250–276ms,进程内路径 p50 ≈ 4ms;
76
+ // 其中钩子自己的判定只占 ≈6ms(关掉:AURA_HOOK_INPROC=0)。
77
+ // 【边界】①只认 PreToolUse(其它事件不归本支路);②任何不确定都退回下面的 spawn 原路;
78
+ // ③非钩子命令在这段里只多一次 O(argv) 的字符串预筛,一个字节的行为变化都没有。
79
+ // 关掉:环境变量 AURA_HOOK_INPROC=0(不必改宿主 profile)。
80
+ const HOOK_SCRIPT_RE = /(?:^|[\\/])hook\.mjs$/i
81
+ const NODE_BASENAMES = new Set(['node', 'node.exe', 'nodejs', 'nodejs.exe'])
82
+
83
+ /**
84
+ * 便宜预筛:这串 argv 像不像「node <…>/hook.mjs … --event=PreToolUse」。
85
+ * 【这不是判定】—— 权威识别在 hook-inproc.recognizePreToolUseHook(那里解析脚本路径、校验同目录的
86
+ * guard-core/adapters 布局)。这里只回答"值不值得去 import 那个模块":漏判 = 白付一次 spawn,
87
+ * 误判 = 多一次 import + 一次失败返回,两种都不改变行为、都不会变成放行。
88
+ */
89
+ function looksLikePreToolUseHook (argv) {
90
+ if (!Array.isArray(argv) || argv.length < 2) return false
91
+ if (typeof argv[0] !== 'string' || !NODE_BASENAMES.has(path.basename(argv[0]).toLowerCase())) return false
92
+ let script = false
93
+ let event = false
94
+ for (let i = 1; i < argv.length; i++) {
95
+ const token = argv[i]
96
+ if (typeof token !== 'string') continue
97
+ if (!script && !token.startsWith('-') && HOOK_SCRIPT_RE.test(token)) { script = true; continue }
98
+ if (token.startsWith('--event=')) event = token.slice('--event='.length).trim().toLowerCase() === 'pretooluse'
99
+ else if (token === '--event' && typeof argv[i + 1] === 'string') event = argv[i + 1].trim().toLowerCase() === 'pretooluse'
100
+ }
101
+ return script && event
102
+ }
103
+
104
+ /** 记一次分派(可观测标记)。写标记失败不许影响这次调用:吞掉。 */
105
+ function noteDispatch (mod, kind, reason) {
106
+ try { if (typeof mod?.noteHookDispatch === 'function') mod.noteHookDispatch(kind, reason) } catch { /* 标记是读数,不是判据 */ }
107
+ }
108
+
109
+ /**
110
+ * 短路支路:把已知的 AuraPolaris PreToolUse 钩子在本进程内跑完。
111
+ * @returns 与 spawn 路径【同形】的结果对象;返回 null = 走原来的 spawn(一个字都没改的那条路)。
112
+ */
113
+ async function inProcessShortCircuit (executor, argv, spec) {
114
+ if (!looksLikePreToolUseHook(argv)) return null
115
+ // 懒加载:非钩子命令不付这个模块的任何代价(构造执行器时也不 import)。
116
+ let mod
117
+ try { mod = await import('./hook-inproc.mjs') } catch { return null }
118
+ if (process.env.AURA_HOOK_INPROC === '0') { noteDispatch(mod, 'spawn', 'disabled'); return null }
119
+ let result = null
120
+ try {
121
+ result = await mod.runPreToolUseHookInProcess({
122
+ argv,
123
+ stdin: typeof spec.stdin === 'string' ? spec.stdin : '',
124
+ workdir: spec.workdir,
125
+ stdoutMaxBytes: spec.stdoutMaxBytes,
126
+ stderrMaxBytes: executor.config.maxOutputBytes,
127
+ signalAborted: spec.signal !== undefined && spec.signal.aborted === true,
128
+ })
129
+ } catch (error) {
130
+ // 抛 = 这条支路不可用。这里【只】退回 spawn:合成任何判定都等于把"护栏坏了"变成"放行"。
131
+ noteDispatch(mod, 'spawn', `threw:${String(error?.message ?? error).slice(0, 60)}`)
132
+ return null
133
+ }
134
+ // 形状不对(缺字段 / 类型不对 / ok 不是 true)= 与"抛"同等处理:退回 spawn。
135
+ if (result === null || typeof result !== 'object' || result.ok !== true ||
136
+ typeof result.exitCode !== 'number' || typeof result.stdout !== 'string' || typeof result.stderr !== 'string') {
137
+ noteDispatch(mod, 'spawn', String(result?.reason ?? 'unshaped'))
138
+ return null
139
+ }
140
+ noteDispatch(mod, 'inproc', 'ok')
141
+ return {
142
+ exitCode: result.exitCode,
143
+ signal: null,
144
+ timedOut: false,
145
+ aborted: spec.signal !== undefined && spec.signal.aborted === true,
146
+ timeoutMs: spec.timeoutMs,
147
+ stdout: { text: result.stdout, truncated: false },
148
+ stderr: { text: result.stderr, truncated: false },
149
+ }
150
+ }
151
+
152
+ /** 把已结算的 collect 读取器投影成 CollectedOutput。 */
153
+ function finalOutput (reader) {
154
+ const read = reader.readFrom(0)
155
+ const out = { text: read.text, truncated: read.lossy }
156
+ if (read.spillPath !== undefined) out.spillPath = read.spillPath
157
+ return out
158
+ }
159
+
160
+ function assertPositiveFinite (name, value) {
161
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
162
+ throw new Error('argv-shell: ' + name + ' must be a positive finite number')
163
+ }
164
+ }
165
+
166
+ function clampTimeout (value, base, max, label) {
167
+ if (value === undefined) return base
168
+ assertPositiveFinite(label, value)
169
+ return Math.min(value, max)
170
+ }
171
+
172
+ /**
173
+ * 本地 argv 执行器:`ctx.shell` 的无 shell 实现。
174
+ */
175
+ export class ArgvShellExecutor extends ShellExecutor {
176
+ static inject = ['subprocess']
177
+ static Config = z.object({
178
+ cwd: z.string(),
179
+ timeoutMs: z.number().default(120000),
180
+ maxTimeoutMs: z.number().default(600000),
181
+ maxOutputBytes: z.number().default(64000),
182
+ maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES),
183
+ graceMs: z.number().default(DEFAULT_GRACE_MS),
184
+ })
185
+
186
+ constructor (ctx, config) {
187
+ super(ctx)
188
+ assertPositiveFinite('argv-shell: timeoutMs', config.timeoutMs)
189
+ assertPositiveFinite('argv-shell: maxTimeoutMs', config.maxTimeoutMs)
190
+ assertPositiveFinite('argv-shell: maxOutputBytes', config.maxOutputBytes)
191
+ assertPositiveFinite('argv-shell: maxSpillBytes', config.maxSpillBytes)
192
+ assertPositiveFinite('argv-shell: graceMs', config.graceMs)
193
+ this.entry = config
194
+ }
195
+
196
+ get config () { return this.entry }
197
+
198
+ /** 不沙箱化:本执行器是「宿主壳层的替身」,不引入任何新的执行语义。 */
199
+ get sandboxMode () { return undefined }
200
+
201
+ /** 与 pwsh-local 同款:填 workdir / timeoutMs / stdoutMaxBytes,其余原样透传。 */
202
+ resolve (request) {
203
+ const timeoutMs = clampTimeout(request.timeoutMs, this.config.timeoutMs, this.config.maxTimeoutMs, 'argv-shell: request.timeoutMs')
204
+ const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes
205
+ assertPositiveFinite('request.stdoutMaxBytes', stdoutMaxBytes)
206
+ const spec = {
207
+ command: request.command,
208
+ workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
209
+ timeoutMs,
210
+ stdoutMaxBytes,
211
+ sandboxPolicy: request.sandboxPolicy,
212
+ }
213
+ if (request.signal !== undefined) spec.signal = request.signal
214
+ if (request.stdin !== undefined) spec.stdin = request.stdin
215
+ if (request.env !== undefined) spec.env = request.env
216
+ if (request.dshEnv !== undefined) spec.dshEnv = request.dshEnv
217
+ return spec
218
+ }
219
+
220
+ /** 命令串 -> argv。本执行器与 pwsh-local 的【唯一】实质差别就在这一步。 */
221
+ argv (spec) {
222
+ const out = tokenizeArgv(spec.command)
223
+ if (out.length === 0) throw new Error('argv-shell: empty command')
224
+ return out
225
+ }
226
+
227
+ spawnSpec (spec, stdoutMaxBytes, signal, argv) {
228
+ const collect = (maxBytes) => ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
229
+ const out = {
230
+ argv: [...argv],
231
+ cwd: spec.workdir,
232
+ stdio: {
233
+ stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore',
234
+ stdout: collect(stdoutMaxBytes),
235
+ stderr: collect(this.config.maxOutputBytes),
236
+ },
237
+ graceMs: this.config.graceMs,
238
+ env: { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv },
239
+ }
240
+ if (signal !== undefined) out.signal = signal
241
+ return out
242
+ }
243
+
244
+ async run (spec) {
245
+ const argv = this.argv(spec)
246
+ // 短路支路:已知的 AuraPolaris PreToolUse 钩子在进程内跑完。null = 原路(下面的代码一个字没改)。
247
+ const shortcut = await inProcessShortCircuit(this, argv, spec)
248
+ if (shortcut !== null) return shortcut
249
+ const controller = new AbortController()
250
+ const onCallerAbort = () => controller.abort()
251
+ let timedOut = false
252
+ const timer = setTimeout(() => { timedOut = true; controller.abort() }, spec.timeoutMs)
253
+ if (spec.signal !== undefined) {
254
+ if (spec.signal.aborted) controller.abort()
255
+ else spec.signal.addEventListener('abort', onCallerAbort, { once: true })
256
+ }
257
+ let handle
258
+ let outcome = { exitCode: null, signal: null }
259
+ let failure = null
260
+ try {
261
+ handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, controller.signal, argv))
262
+ outcome = await handle.done
263
+ } catch (error) {
264
+ failure = error
265
+ } finally {
266
+ clearTimeout(timer)
267
+ if (spec.signal !== undefined) spec.signal.removeEventListener('abort', onCallerAbort)
268
+ }
269
+ const outputs = handle === undefined ? undefined : handle.collected
270
+ const stdout = outputs !== undefined && outputs.stdout !== undefined ? finalOutput(outputs.stdout) : { text: '', truncated: false }
271
+ const stderr = outputs !== undefined && outputs.stderr !== undefined
272
+ ? finalOutput(outputs.stderr)
273
+ : { text: failure === null ? '' : String(failure.message ?? failure), truncated: false }
274
+ return {
275
+ ...outcome,
276
+ timedOut,
277
+ aborted: spec.signal !== undefined && spec.signal.aborted === true && !timedOut,
278
+ timeoutMs: spec.timeoutMs,
279
+ stdout,
280
+ stderr,
281
+ }
282
+ }
283
+
284
+ /** 后台执行不属于本执行器的用例面(隔离组里只有钩子桥这一个消费者)。 */
285
+ start () {
286
+ throw new Error('argv-shell: background execution is not implemented — this executor only serves the hooks bridge')
287
+ }
288
+ }
289
+
290
+ export default ArgvShellExecutor