dsh-codex-style-computer-use 2.0.0 → 2.1.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.
package/lib/launch.js ADDED
@@ -0,0 +1,67 @@
1
+ // launch.js — validation for computer_launch. EXECUTION lives in the worker (lib/core/worker.cs
2
+ // `launch` op) on purpose: a launch must go through the SAME funnel as every other actuation —
3
+ // brake/pause checks, the status halo, the audit JSONL and the rate window — and the human has
4
+ // to SEE that the machine was just acted on. A Node-side spawn skipped all of that (measured
5
+ // 2026-11-07: the halo never lit, so nothing told the human an app was opening).
6
+ //
7
+ // Validation still lives here so an impossible target is refused BEFORE dispatch: the receipt
8
+ // says outcome:"not-dispatched", and that has to be true. The policy layer also needs the string.
9
+ //
10
+ // RESOLUTION IS ShellExecute's (worker: ProcessStartInfo.UseShellExecute). Measured on this
11
+ // machine: `explorer.exe calc` opens a FILE EXPLORER window (a bare word is a location to
12
+ // explorer), while ShellExecute("calc") starts Calculator — so the launcher, not a shell
13
+ // wrapper, is the correct resolver for App Paths aliases, shell: verbs, URLs and associations.
14
+ //
15
+ // The target crosses as ONE value: no command line is composed here or in the worker, and no
16
+ // shell interpreter is involved. The deny-list below is defence in depth for anything
17
+ // downstream that might interpolate the string — the boundary is the single-value call.
18
+ const MAX_LEN = 2000
19
+
20
+ // Control characters: C0 (includes \r \n \t), DEL, C1, and the Unicode line/paragraph
21
+ // separators (U+2028/U+2029 still break line discipline in several parsers). None has an
22
+ // honest use in a path, URL or alias.
23
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/
24
+
25
+ // Metacharacters for a shell or a string interpolator. Percent is deliberately NOT here:
26
+ // "%20"-style percent-encoding is a legitimate, unavoidable part of URLs, and no shell is
27
+ // involved, so there is no %VAR% expansion to fear.
28
+ const INJECTIVE = /["'&|;<>$`^]/
29
+
30
+ const badTarget = (why) => {
31
+ throw Object.assign(new Error(`bad launch target: ${why}`), { code: 'LAUNCH_BAD_TARGET', outcome: 'not-dispatched' })
32
+ }
33
+
34
+ /** Trim and validate a launch target; throws LAUNCH_BAD_TARGET (not-dispatched) when impossible.
35
+ * Everything is checked on the TRIMMED string, i.e. on exactly what the worker will receive. */
36
+ export function validateLaunchTarget (target) {
37
+ if (typeof target !== 'string') badTarget('must be a string')
38
+ const t = target.trim()
39
+ if (!t) badTarget('empty (or only whitespace)')
40
+ if (t.length > MAX_LEN) badTarget(`longer than ${MAX_LEN} characters`)
41
+ const c = CONTROL.exec(t)
42
+ if (c) badTarget(`control character U+${c[0].codePointAt(0).toString(16).toUpperCase().padStart(4, '0')}`)
43
+ const m = INJECTIVE.exec(t)
44
+ if (m) badTarget(`forbidden character ${JSON.stringify(m[0])}`)
45
+ return t
46
+ }
47
+
48
+ const badArgs = (why) => {
49
+ throw Object.assign(new Error(`bad launch arguments: ${why}`), { code: 'LAUNCH_BAD_ARGS', outcome: 'not-dispatched' })
50
+ }
51
+
52
+ /** Validate the optional argument array. Each entry is ONE argument VALUE — never a command
53
+ * line, never shell syntax: the worker quotes each entry exactly once. Spaces, backslashes and
54
+ * `--flag=value` are ordinary; only NUL, control characters and empties are impossible. */
55
+ export function validateLaunchArgs (args) {
56
+ if (args === undefined || args === null) return undefined
57
+ if (!Array.isArray(args)) badArgs('must be an array of strings')
58
+ if (args.length > 64) badArgs('more than 64 arguments')
59
+ return args.map((value, index) => {
60
+ if (typeof value !== 'string') badArgs(`argument ${index} must be a string`)
61
+ if (value.length === 0) badArgs(`argument ${index} is empty`)
62
+ if (value.length > MAX_LEN) badArgs(`argument ${index} longer than ${MAX_LEN} characters`)
63
+ const c = CONTROL.exec(value)
64
+ if (c) badArgs(`argument ${index} contains control character U+${c[0].codePointAt(0).toString(16).toUpperCase().padStart(4, '0')}`)
65
+ return value
66
+ })
67
+ }
package/lib/policy.js CHANGED
@@ -27,9 +27,18 @@ export const OBSERVATION_TOOLS = new Set([
27
27
  export const ACTUATION_TOOLS = new Set([
28
28
  'computer_click', 'computer_move', 'computer_drag', 'computer_scroll', 'computer_select',
29
29
  'computer_key', 'computer_type', 'computer_uia_act', 'computer_window', 'computer_batch',
30
+ 'computer_launch', 'computer_focus',
30
31
  ])
31
32
 
32
33
  export const HIGH_RISK_NOTE = 'high-risk action (destructive key / closing a window / password field) — always requires human approval'
34
+ export const LAUNCH_HIGH_RISK_NOTE = 'high-risk launch (executable / script / installer / UNC / file: target) — explorer will run what it resolves; always requires human approval'
35
+
36
+ // A launch target that IS code (or points at it) escalates everywhere it can: the launcher
37
+ // executes what it resolves, so argv discipline cannot make an .exe/.bat/UNC target "just a
38
+ // file". Extensions only by design: bare App-Paths aliases (calc, cmd) stay allowed — the
39
+ // measured trap here was the opposite direction (explorer opened a FOLDER for an alias), and
40
+ // a path spelling of the same binary asks while an alias does not carry arguments either way.
41
+ const LAUNCH_CODE_TARGET = /\.(exe|scr|msi|hta|bat|cmd|com|pif|cpl|msc|reg|jse|vbe|vbs|ws|wsf|wsh|ps1|lnk|url|jar|application|appref-ms|appx|msix|gadget|sct)$/i
33
42
 
34
43
  /** @returns {{kind:'allow'|'deny'|'ask', reason?: string}} */
35
44
  export function policyDecision (name, args, mode) {
@@ -76,14 +85,15 @@ export function policyDecision (name, args, mode) {
76
85
  function decideActuation (name, a, mode) {
77
86
  let hr
78
87
  try { hr = highRisk(name, a) } catch (error) { return { kind: 'deny', reason: error.message } }
88
+ const note = name === 'computer_launch' ? LAUNCH_HIGH_RISK_NOTE : HIGH_RISK_NOTE
79
89
  switch (mode) {
80
90
  case 'read-only':
81
91
  return { kind: 'deny', reason: `automationMode=read-only: ${name} drives the machine` }
82
92
  case 'standard':
83
- return hr ? { kind: 'ask', reason: HIGH_RISK_NOTE + riskSuffix(name, a) }
93
+ return hr ? { kind: 'ask', reason: note + riskSuffix(name, a) }
84
94
  : { kind: 'ask', reason: `automationMode=standard: actuation ${name}` }
85
95
  case 'autonomous':
86
- return hr ? { kind: 'ask', reason: HIGH_RISK_NOTE + riskSuffix(name, a) } : { kind: 'allow' }
96
+ return hr ? { kind: 'ask', reason: note + riskSuffix(name, a) } : { kind: 'allow' }
87
97
  case 'unrestricted':
88
98
  return { kind: 'allow' }
89
99
  default:
@@ -98,6 +108,7 @@ function riskSuffix (name, a) {
98
108
  if (name === 'computer_key') return ` [combo=${a.combo}]`
99
109
  if (name === 'computer_window') return ` [op=${a.op}]`
100
110
  if (name === 'computer_type') return ' [allowPassword]'
111
+ if (name === 'computer_launch') return ` [target=${String(a.target || '').slice(0, 80)}]`
101
112
  if (name === 'computer_batch') {
102
113
  const actions = Array.isArray(a.actions) ? a.actions : []
103
114
  for (let i = 0; i < actions.length; i++) {
@@ -118,6 +129,15 @@ export function highRisk (name, args) {
118
129
  }
119
130
  if (name === 'computer_window' && String(a.op || 'activate') === 'close') return true
120
131
  if (name === 'computer_type' && a.allowPassword) return true
132
+ if (name === 'computer_launch') {
133
+ // Win32 strips trailing dots/spaces/ADS-colons when resolving, so "x.exe." RUNS as x.exe —
134
+ // normalize before matching or the $ anchor is bypassable. http(s) targets lose ?query and
135
+ // #fragment first so "x.exe?dl=1" is judged exactly like "x.exe". Forward slashes map to
136
+ // backslashes at resolve time, so //server/share is the UNC it will become.
137
+ let target = String(a.target || '').trim().replace(/[.\s:]+$/, '')
138
+ if (/^\w+:\/\//i.test(target)) target = target.replace(/[?#].*$/, '')
139
+ if (LAUNCH_CODE_TARGET.test(target) || /^\\\\|^\/\//.test(target) || /^file:/i.test(target)) return true
140
+ }
121
141
  if (name === 'computer_batch') {
122
142
  const actions = Array.isArray(a.actions) ? a.actions : []
123
143
  for (const step of actions) {
package/lib/tools.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // tools.js — defineTool registrations for dsh-computer-use.
2
2
  //
3
- // 18 TOOLS. The previous 28 were merged, not deleted; every capability still exists and the
3
+ // 20 TOOLS. The previous 28 were merged, not deleted; every capability still exists and the
4
4
  // behavioural detail lives in the skill (loaded only for computer-use work, while these
5
5
  // definitions are paid for on EVERY request).
6
6
  //
@@ -22,11 +22,13 @@ import { ObservationStore } from './observations.js'
22
22
  import { feedbackPolicy, feedbackActions, feedbackVerification, selectFeedbackDetail } from './feedback.js'
23
23
  import { collectCompactState, limitCompactFeedback, feedbackViewport } from './compact-feedback.js'
24
24
  import { handoffResult } from './continuation.js'
25
+ import { validateLaunchArgs, validateLaunchTarget } from './launch.js'
25
26
  import { join } from 'node:path'
26
27
 
27
28
  const P = (type, description, extra = {}) => ({ type, description, ...extra })
28
29
  const cooperativeTools = new Set(['computer_click', 'computer_move', 'computer_drag', 'computer_scroll',
29
- 'computer_select', 'computer_key', 'computer_type', 'computer_window', 'computer_uia_act', 'computer_batch', 'computer_clip'])
30
+ 'computer_select', 'computer_key', 'computer_type', 'computer_window', 'computer_uia_act', 'computer_batch', 'computer_clip',
31
+ 'computer_focus'])
30
32
  const usesInput = (name, args = {}) => name === 'computer_clip' ? args.text !== undefined :
31
33
  name === 'computer_batch' ? (args.actions || []).some(step => usesInput(step.tool, step.args)) :
32
34
  cooperativeTools.has(name)
@@ -73,8 +75,21 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
73
75
  return saveOwnedShot(dataDir, p, buf)
74
76
  }
75
77
 
78
+ // Per-call image delivery. `auto` is the existing behaviour and stays the default; `path`
79
+ // withholds the image block without withholding the file. An unknown value refuses here
80
+ // rather than silently behaving like auto — a typo must not quietly reintroduce the cost the
81
+ // caller asked to avoid.
82
+ const deliveryMode = (value) => {
83
+ if (value === undefined || value === 'auto') return 'auto'
84
+ if (value === 'path') return 'path'
85
+ throw Object.assign(new Error('delivery must be "auto" or "path"'), { code: 'BAD_DELIVERY', outcome: 'not-dispatched' })
86
+ }
87
+
76
88
  // Shared by direct calls, explicit batch steps, and the batch checkpoint.
77
89
  const takeScreenshot = async (args, exec) => {
90
+ // Refuse a bad value BEFORE the capture: a refused request that already wrote a file and
91
+ // reports outcome:"not-dispatched" is a false receipt.
92
+ const delivery = deliveryMode(args.delivery)
78
93
  const r = rect(args.region)
79
94
  const shot = await cu.screenshot({
80
95
  x: r && r.x, y: r && r.y, width: r && r.width, height: r && r.height,
@@ -83,7 +98,7 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
83
98
  })
84
99
  const path = saveShot(shot.image, shot.mime === 'image/png' ? 'png' : 'jpg')
85
100
  const res = { path, mime: shot.mime, region: shot.region, ...screenshotMetadata(shot) }
86
- return attachScreenshot(ctx, exec, res, shot.image, { rules: config.feedbackRules })
101
+ return attachScreenshot(ctx, exec, res, shot.image, { rules: config.feedbackRules, delivery })
87
102
  }
88
103
 
89
104
  const clickTarget = async (args) => {
@@ -149,7 +164,7 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
149
164
  if (exec?.signal?.aborted || cu.stopped || cu.exited || cu.cooperation?.active) return true
150
165
  try { return !!hooks?.askBlocked?.('computer_shot') } catch { return true }
151
166
  }
152
- const captureFeedback = async (exec, policy, context = {}) => {
167
+ const captureFeedback = async (exec, policy, context = {}, delivery = 'auto') => {
153
168
  // Route resolution is async: the human can brake while it is pending.
154
169
  if (feedbackBlocked(exec)) return { observationSkipped: 'cancelled-or-paused' }
155
170
  const compact = policy.detail === 'compact'
@@ -160,7 +175,7 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
160
175
  const observation = policy.kind === 'state'
161
176
  ? compact ? await collectCompactState(cu, { ...context, checkAllowed,
162
177
  archive: raw => hooks?.archiveFeedback ? hooks.archiveFeedback(exec, raw) : archiveFeedback(exec, raw) }) : await observeState({}, exec)
163
- : await takeScreenshot(compact ? { region: feedbackViewport(context.args, context.receipt).region } : {}, exec)
178
+ : await takeScreenshot(compact ? { region: feedbackViewport(context.args, context.receipt).region, delivery } : { delivery }, exec)
164
179
  const envelope = { observation, feedback: policy, verification: feedbackVerification }
165
180
  if (compact && policy.kind === 'state' && observation.inlineEvidence) {
166
181
  // Resource fallback deliberately favors recoverable evidence over the
@@ -354,6 +369,7 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
354
369
  preset: P('string', 'taskbar'),
355
370
  png: P('boolean', 'PNG instead of JPEG'),
356
371
  wait: P('string', 'auto (default) | none'),
372
+ delivery: P('string', 'auto (default) | path — path writes the file and returns its absolute path with no image block'),
357
373
  },
358
374
  output: out((a, v) => renderScreenshot(v)),
359
375
  isConcurrencySafe: () => false,
@@ -496,6 +512,7 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
496
512
  clicks: P('number', '1 | 2 (double) | 3 (triple)'),
497
513
  shift: P('boolean', 'hold SHIFT (range selection)'),
498
514
  verificationRegion: P('string', 'expected effect region "x,y,w,h" in screen pixels; may differ from click point; a change alone does not prove task success'),
515
+ allowCovered: P('boolean', 'click a pixel owned by the target app\'s OWN embedded renderer (a descendant process of the target, e.g. a WeChat article panel); an unrelated window on top is still refused and the receipt says COVERED-UNRELATED'),
499
516
  },
500
517
  output: outJSON(),
501
518
  isConcurrencySafe: () => false,
@@ -626,6 +643,49 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
626
643
  async execute (args) { return args.text === undefined ? cu.clipRead() : cu.clipWrite(args.text) },
627
644
  })
628
645
 
646
+ // ---------------- launch ----------------
647
+ // ONE call replaces the Win+R route (activate, combo, type, enter, verify): the worker resolves
648
+ // the target with ShellExecute, so App Paths aliases (calc), shell: folders, URLs, folders and
649
+ // document associations all work. The launch is a real actuation — brake checks, the status
650
+ // halo (with a short self-expiring tail, because a launch returns in milliseconds) and the
651
+ // audit trail all come from the worker op, not from this file.
652
+ reg({
653
+ name: 'computer_launch',
654
+ description: 'Open an app/URL/folder/document in one call — replaces the Win+R route (activate, combo, type, enter). Aliases (calc), shell: folders, http/https URLs, absolute paths. Pass `args` to start an executable with arguments (a dedicated browser profile: target "…\\msedge.exe", args ["--user-data-dir=…", "--new-window", "<url>"]); each entry is one argument value, quoted once in the worker. Launched means the target reached the launcher, not that the app is ready; verify with computer_state.',
655
+ parameters: {
656
+ target: P('string', 'app alias (calc) | shell: folder | http/https URL | absolute path', { required: true }),
657
+ args: P('array', 'optional argument values for an executable target, one array entry per argument (e.g. ["--user-data-dir=D:\\\\profiles\\\\job", "--new-window", "https://…"]); no shell quoting — spaces and backslashes are ordinary'),
658
+ tailMs: P('number', 'how long to keep the status halo lit after the launch, 0–10000 ms (default 2500)'),
659
+ },
660
+ output: outJSON(),
661
+ isConcurrencySafe: () => false,
662
+ timeoutMs: 15_000,
663
+ async execute (args) { return cu.launch(validateLaunchTarget(args.target), { args: validateLaunchArgs(args.args) }) },
664
+ })
665
+
666
+ // ---------------- focus before typing ----------------
667
+ reg({
668
+ name: 'computer_focus',
669
+ description: 'Put keyboard focus on a FIELD and PROVE it before typing: clicks x,y, then reports the focused UIA control and whether a caret is provable there (OS caret, else a two-frame blink check). Typing follows the focused control, not the window — activating a dialog can leave focus in the document, where text is silently swallowed. Type only when verified:true.',
670
+ parameters: {
671
+ x: P('number', 'field centre x in screen pixels', { required: true }),
672
+ y: P('number', 'field centre y in screen pixels', { required: true }),
673
+ hwnd: P('number', 'target window; a modal dialog of the same app is accepted'),
674
+ verifyCaret: P('boolean', 'when the OS reports no caret, fall back to a two-frame blink check (default true)'),
675
+ },
676
+ output: outJSON(),
677
+ isConcurrencySafe: () => false,
678
+ timeoutMs: 15_000,
679
+ async execute (args) {
680
+ return cu.focusField({
681
+ x: args.x,
682
+ y: args.y,
683
+ ...(args.hwnd !== undefined ? { hwnd: args.hwnd } : {}),
684
+ ...(args.verifyCaret !== undefined ? { verifyCaret: args.verifyCaret } : {}),
685
+ })
686
+ },
687
+ })
688
+
629
689
  // ---------------- the agent asks ----------------
630
690
  reg({
631
691
  name: 'computer_ask',
@@ -686,6 +746,7 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
686
746
  parameters: {
687
747
  actions: P('array', '[{tool:"computer_click", args:{mark:"M3"}}, ...]', { required: true }),
688
748
  shot: P('string', 'auto (default) | always | never; no automatic image after failure, cancel or pause'),
749
+ delivery: P('string', 'auto (default) | path — the checkpoint keeps its absolute path but attaches no image'),
689
750
  },
690
751
  output: out((a, v) => {
691
752
  const { observation, ...receipt } = v
@@ -699,6 +760,9 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
699
760
  isConcurrencySafe: () => false,
700
761
  timeoutMs: 120_000,
701
762
  async execute (args, exec) {
763
+ // Validate before any step runs: a bad delivery value must not be discovered only at the
764
+ // checkpoint, after the actions have already been dispatched.
765
+ const delivery = deliveryMode(args.delivery)
702
766
  let automatic, feedback, lastContext = {}
703
767
  const detail = selectFeedbackDetail(args.feedback,
704
768
  config.actionFeedback === false ? 'none' : config.actionFeedbackDetail ?? 'compact')
@@ -719,15 +783,15 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
719
783
  if (feedbackBlocked(exec)) { feedback = { observationSkipped: 'cancelled-or-paused' }; return null }
720
784
  if (!policy.kind) { feedback = { feedback: policy, observationSkipped: policy.reason || 'disabled' }; return null }
721
785
  hooks?.assertOwnership?.(exec)
722
- if (policy.kind === 'state') { feedback = await captureFeedback(exec, policy, lastContext); return null }
723
- const captured = await captureFeedback(exec, policy, lastContext)
786
+ if (policy.kind === 'state') { feedback = await captureFeedback(exec, policy, lastContext, delivery); return null }
787
+ const captured = await captureFeedback(exec, policy, lastContext, delivery)
724
788
  const { observation, ...details } = captured
725
789
  automatic = observation
726
790
  feedback = details
727
791
  if (!automatic) return null
728
792
  return automatic.path
729
793
  } })
730
- return { ...selectBatchScreenshot(result, feedback?.observation ?? automatic, feedback?.feedback?.kind), ...feedback }
794
+ return { ...selectBatchScreenshot(result, feedback?.observation ?? automatic, feedback?.feedback?.kind, delivery), ...feedback }
731
795
  },
732
796
  })
733
797
 
@@ -798,6 +862,8 @@ export function registerTools ({ ctx, cu, config, dataDir, log, snapshotPath, de
798
862
  computer_type: (a) => cu.type(a.text, { mode: a.mode, allowPassword: a.allowPassword }),
799
863
  computer_window: (a) => ((a.op || 'activate') === 'activate' ? cu.activateWindow({ hwnd: a.hwnd }) : cu.windowOp({ hwnd: a.hwnd }, a.op, rect(a.rect) || {})),
800
864
  computer_clip: (a) => (a.text === undefined ? cu.clipRead() : cu.clipWrite(a.text)),
865
+ computer_launch: (a) => cu.launch(validateLaunchTarget(a.target), { args: validateLaunchArgs(a.args) }),
866
+ computer_focus: (a) => cu.focusField({ x: a.x, y: a.y, ...(a.hwnd !== undefined ? { hwnd: a.hwnd } : {}), ...(a.verifyCaret !== undefined ? { verifyCaret: a.verifyCaret } : {}) }),
801
867
  computer_uia_act: actUia,
802
868
  computer_ask: (a, exec) => askQuestion(a, exec),
803
869
  computer_wait: (a) => a.mode === 'stable' ? cu.waitStable({ timeoutMs: a.timeoutMs, hwnd: a.hwnd })
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "dsh-codex-style-computer-use",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Codex-style Computer Use for DeepSeek Harness: Windows desktop control, screenshots, UI Automation, human input handoff, approval gates and prebuilt native workers. Install and update from dsh-market without PowerShell 7 or build steps.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
+ "bin": {
8
+ "dsh-codex-style-computer-use": "mcp/src/index.js",
9
+ "dsh-computer-use-mcp": "mcp/src/index.js"
10
+ },
7
11
  "exports": {
8
12
  ".": "./lib/index.js",
9
13
  "./package.json": "./package.json",
@@ -23,6 +23,15 @@ for (const [source, binary] of [
23
23
  assert.equal(manifest.architecture, 'x64')
24
24
  }
25
25
  for (const file of ['lib/index.js', 'lib/approval-attribution.js', 'lib/core/native-runtime.js', 'lib/attention-native.js', 'skills/computer-use/SKILL.md', 'mcp/src/index.js']) assert(fs.existsSync(path.join(root, file)), file)
26
+ // The npx entry is model-facing documentation: a bin that is absent, points at a missing file,
27
+ // or lacks the node shebang is a broken promise that the marketplace cannot detect.
28
+ const bins = pkg.bin ?? {}
29
+ assert(Object.keys(bins).length > 0, 'package must expose a bin for the embedded MCP server')
30
+ for (const [name, target] of Object.entries(bins)) {
31
+ const file = path.join(root, target)
32
+ assert(fs.existsSync(file), `bin ${name} target missing: ${target}`)
33
+ assert(fs.readFileSync(file, 'utf8').startsWith('#!/usr/bin/env node'), `bin ${name} target needs a node shebang`)
34
+ }
26
35
  const attention = fs.readFileSync(path.join(root, 'lib/attention-native.js'), 'utf8')
27
36
  assert(attention.includes("'./core/native-runtime.js'"), 'attention helper must use the bundled core')
28
37
  console.log(`ok package: ${pkg.name}@${pkg.version}; prebuilt helpers verified; no install hooks`)