sprag-cli 3.65.0 → 3.66.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.
@@ -14,6 +14,12 @@ import type { EngineInterface, Register } from 'claude-code'
14
14
  * hook beneath it explains the refusal exactly as it does today. After a
15
15
  * redirect that hook sees a .md path and stays silent, so the two never
16
16
  * answer the same Read twice.
17
+ *
18
+ * The exception is an encrypted document (password or DRM). Claude Code
19
+ * refuses a pptx, xlsx or docx as a binary file before any settings hook runs,
20
+ * so the settings hook can never tell the model why such a file cannot be
21
+ * read; this mod is the only place that can, so it refuses the Read itself
22
+ * with the CLI's notice.
17
23
  */
18
24
 
19
25
  // Mirrors TARGET_EXTENSIONS in src/doc2md.cjs. A hooks module has no Node, so
@@ -34,13 +40,20 @@ const CONVERT_TIMEOUT_MS = 150_000
34
40
  // prototype shortcut: a `--json` flag on that command should replace it.
35
41
  const CONVERTED = /^✓ (cached|converted): (.+)$/m
36
42
 
43
+ // What `sprag doc2md <path>` prints on stderr for an encrypted document: the
44
+ // reason line, then one indented line holding the notice for the model. A CLI
45
+ // that predates the notice line prints the reason line alone.
46
+ const LOCKED = /^✗ (encrypted|drm-protected)\b.*$/m
47
+
37
48
  type Converted = { cacheFile: string; isCached: boolean }
49
+ type Locked = { locked: string }
38
50
 
39
51
  const baseName = (path: string) => path.split(/[\\/]/).pop() ?? path
40
52
 
41
53
  /**
42
54
  * Runs the conversion the settings hook would run, ledger entry included.
43
- * Answers null on any failure so the caller passes the event on unchanged.
55
+ * Answers null on any failure so the caller passes the event on unchanged,
56
+ * and `{ locked }` with the CLI's notice when the document is encrypted.
44
57
  *
45
58
  * A first conversion can take a minute, and nothing else on screen says the
46
59
  * Read is waiting on it, so the status line under the prompt names the file
@@ -48,11 +61,17 @@ const baseName = (path: string) => path.split(/[\\/]/).pop() ?? path
48
61
  * the file is cached is unknown until the CLI answers, and a log line on every
49
62
  * Read would double the lines for the cached ones too.
50
63
  */
51
- async function convert($: EngineInterface, path: string): Promise<Converted | null> {
64
+ async function convert($: EngineInterface, path: string): Promise<Converted | Locked | null> {
52
65
  $.ui.status(`doc2md: ${baseName(path)} 변환 중…`)
53
66
  try {
54
67
  const run = await $.process.run([CLI, 'doc2md', path], { timeoutMs: CONVERT_TIMEOUT_MS })
55
- if (run.exitCode !== 0) return null
68
+ if (run.exitCode !== 0) {
69
+ const reason = LOCKED.exec(run.stderr)
70
+ if (!reason) return null
71
+ const rest = run.stderr.slice(reason.index + reason[0].length)
72
+ const notice = rest.split('\n').map(line => line.trim()).find(line => line !== '')
73
+ return notice ? { locked: notice } : null
74
+ }
56
75
  const match = CONVERTED.exec(run.stdout)
57
76
  if (!match) return null
58
77
  return { cacheFile: match[2].trim(), isCached: match[1] === 'cached' }
@@ -63,19 +82,25 @@ async function convert($: EngineInterface, path: string): Promise<Converted | nu
63
82
  }
64
83
  }
65
84
 
85
+ const isLocked = (doc: Converted | Locked): doc is Locked => 'locked' in doc
86
+
66
87
  function logRedirect($: EngineInterface, from: string, doc: Converted) {
67
88
  $.ui.log(`doc2md: ${baseName(from)} 대신 변환본을 읽습니다${doc.isCached ? ' (캐시)' : ''}`)
68
89
  }
69
90
 
70
91
  export const register: Register = on => {
71
- // Both hooks fail open, which is the point of the .catch on each: this mod
72
- // saves a round trip and guards nothing, so a hook that breaks hands the
73
- // event on as it came and sprag's settings hook beneath answers as before.
92
+ // Both hooks fail open, which is the point of the .catch on each: a hook that
93
+ // breaks hands the event on as it came and sprag's settings hook beneath
94
+ // answers as before. Only the encrypted-document refusal in the Read hook guards
95
+ // anything, and failing open there costs the notice, not safety.
74
96
  // Where the hook had already called next, next(e) replays that call.
75
97
  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
76
98
  if (!TARGET.test(e.file_path)) return next(e)
77
99
  const doc = await convert($, e.file_path)
78
100
  if (!doc) return next(e)
101
+ // Refuse rather than pass the original on: it cannot be opened, and the
102
+ // binary-file refusal it would meet says nothing of why.
103
+ if (isLocked(doc)) return { deny: `[doc2md] ${doc.locked}` }
79
104
  logRedirect($, e.file_path, doc)
80
105
  // Line and page ranges addressed the binary and mean nothing in the
81
106
  // Markdown (`pages` applies to PDFs only), so the whole file is read.
@@ -87,7 +112,9 @@ export const register: Register = on => {
87
112
  // Read hook above never sees it. Redirect the attachment the same way.
88
113
  on('prompt.mention', { path: TARGET }, async ($, e, next) => {
89
114
  const doc = await convert($, e.path)
90
- if (!doc) return next(e)
115
+ // A deny here would only reach the debug log, not the model, so an
116
+ // encrypted mention goes on as it came; the prompt hook names it instead.
117
+ if (!doc || isLocked(doc)) return next(e)
91
118
  logRedirect($, e.path, doc)
92
119
  // A mention carries no `pages` (PromptMentionInput has mention, path,
93
120
  // offset, limit and agentId), so only the line range is dropped here.
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "sprag-statusline",
3
+ "version": "0.1.0",
4
+ "description": "Draws sprag's statusline above the prompt in the Claude desktop app, which never runs the statusLine command, and keeps the snapshots the statusline leaves behind",
5
+ "author": { "name": "DeepPulse", "url": "https://www.youtube.com/@DeepPulseKR" },
6
+ "types": "./types/index.d.ts"
7
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "modules": ["./register.tsx"]
3
+ }
@@ -0,0 +1,176 @@
1
+ import { atom, read, update } from 'claude-code'
2
+ import type { EngineInterface, Register, SessionUsage } from 'claude-code'
3
+
4
+ /**
5
+ * sprag-statusline: sprag's statusline in the Claude desktop app.
6
+ *
7
+ * The desktop app's Code tab runs claude in stream-json mode, which never runs
8
+ * settings.json's statusLine.command. Hooks and mods do run there, so this mod
9
+ * runs the same sprag command itself and draws its text in the band above the
10
+ * prompt. Running the real CLI also leaves what a statusline render leaves: the
11
+ * rate-limit snapshot and the context window size that the delegation briefing
12
+ * reads back.
13
+ *
14
+ * The terminal has its own statusline, so the band is drawn on the desktop
15
+ * surface only. Nothing here may slow or break a session: a run is skipped
16
+ * while another is in flight, cut off after a few seconds, and a failure keeps
17
+ * the last text on screen.
18
+ */
19
+
20
+ const lines = atom({ plugin: 'sprag-statusline', key: 'lines' } as const, [] as string[])
21
+
22
+ const REFRESH_MS = 5000
23
+ const RUN_TIMEOUT_MS = 3000
24
+ const DEFAULT_ARGV = ['sprag', '--statusline', '--no-color']
25
+
26
+ // The executable names src/installer.js's argsAfterOurExecutable accepts
27
+ const OUR_EXECUTABLES = ['sprag', 'claude-token-saver', 'sprag-cli']
28
+ const RUNNERS = /^(npx|bunx|pnpx)$/
29
+ const RUNNER_SUBCOMMANDS: Record<string, string[]> = {
30
+ pnpm: ['dlx', 'exec'],
31
+ yarn: ['dlx', 'run'],
32
+ bun: ['x', 'run'],
33
+ npm: ['exec'],
34
+ }
35
+
36
+ /** Whether a statusLine command starts with sprag's executable, as the installer judges it. */
37
+ function isOurCommand(tokens: string[]): boolean {
38
+ let i = 0
39
+ if (RUNNERS.test(tokens[0] ?? '')) i = 1
40
+ else if (RUNNER_SUBCOMMANDS[tokens[0] ?? '']?.includes(tokens[1] ?? '')) i = 2
41
+ const token = tokens[i]
42
+ if (!token) return false
43
+ const exe = (token.split(/[/\\]/).pop() ?? '').replace(/\.(cmd|exe|ps1)$/i, '')
44
+ return OUR_EXECUTABLES.includes(exe)
45
+ }
46
+
47
+ /**
48
+ * The command to run: the user's own sprag statusLine command with --no-color
49
+ * added, or the default one. This module has no shell, so the command is split
50
+ * on whitespace, the same way the installer reads it; a command with quoting
51
+ * falls back to the default rather than being run in pieces.
52
+ */
53
+ export function statuslineArgv(settings: Readonly<Record<string, unknown>>): string[] {
54
+ const statusLine = settings.statusLine as { command?: unknown } | undefined
55
+ const command = typeof statusLine?.command === 'string' ? statusLine.command.trim() : ''
56
+ if (!command || /["'`$;|&<>\\]/.test(command)) return DEFAULT_ARGV
57
+ const tokens = command.split(/\s+/)
58
+ if (!isOurCommand(tokens)) return DEFAULT_ARGV
59
+ return tokens.includes('--no-color') ? tokens : [...tokens, '--no-color']
60
+ }
61
+
62
+ /**
63
+ * The JSON Claude Code's statusline would have been fed. Fields the engine has
64
+ * no value for are left out, never zeroed: sprag reads an absent rate_limits as
65
+ * "no data" but a 0 as "0% used". transcript_path is left out because the mod
66
+ * cannot know it, and sprag takes it only for the effort chip.
67
+ */
68
+ export function statuslinePayload(input: {
69
+ sessionId: string
70
+ cwd: string
71
+ model: string
72
+ usage: SessionUsage
73
+ }) {
74
+ const { usage } = input
75
+ const payload: Record<string, unknown> = {
76
+ session_id: input.sessionId,
77
+ cwd: input.cwd,
78
+ workspace: { current_dir: input.cwd, project_dir: input.cwd },
79
+ model: { id: input.model, display_name: input.model },
80
+ }
81
+ if (usage.context.percent !== undefined) {
82
+ payload.context_window = {
83
+ context_window_size: usage.context.window,
84
+ used_percentage: usage.context.percent,
85
+ }
86
+ }
87
+ if (usage.rateLimits.length) {
88
+ const limits: Record<string, { used_percentage: number; resets_at?: number }> = {}
89
+ for (const limit of usage.rateLimits) {
90
+ const at = limit.resetsAt ? Math.floor(Date.parse(limit.resetsAt) / 1000) : NaN
91
+ limits[limit.kind] = {
92
+ used_percentage: limit.percentUsed,
93
+ ...(Number.isFinite(at) ? { resets_at: at } : {}),
94
+ }
95
+ }
96
+ payload.rate_limits = limits
97
+ }
98
+ if (usage.cost) payload.cost = { total_cost_usd: usage.cost.usd }
99
+ return payload
100
+ }
101
+
102
+ /** Output with stray ANSI escapes removed and blank lines dropped. */
103
+ export function cleanOutput(text: string): string[] {
104
+ return text
105
+ .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '')
106
+ .replace(/\u001b\][^\u0007]*(\u0007|\u001b\\)/g, '')
107
+ .split(/\r?\n/)
108
+ .map(line => line.trimEnd())
109
+ .filter(line => line.trim() !== '')
110
+ }
111
+
112
+ let isRunning = false
113
+ let timer: { cancel: () => void } | undefined
114
+
115
+ /** Runs the statusline once and stores its text; any failure leaves the last text as it was. */
116
+ async function refresh($: EngineInterface): Promise<void> {
117
+ if (isRunning) return
118
+ isRunning = true
119
+ try {
120
+ if (!(await $.session.surfaces()).includes('desktop')) return
121
+ const [sessionId, cwd, model, usage, settings] = await Promise.all([
122
+ $.session.id(),
123
+ $.session.cwd(),
124
+ $.session.model(),
125
+ $.session.usage(),
126
+ $.settings.read(),
127
+ ])
128
+ const run = await $.process.run(statuslineArgv(settings), {
129
+ stdin: JSON.stringify(statuslinePayload({ sessionId, cwd, model, usage })),
130
+ timeoutMs: RUN_TIMEOUT_MS,
131
+ })
132
+ if (run.exitCode !== 0) return
133
+ const next = cleanOutput(run.stdout)
134
+ if (!next.length) return
135
+ // An unchanged text is not written, so the band is not redrawn every tick
136
+ const same = JSON.stringify(await read($, lines)) === JSON.stringify(next)
137
+ if (!same) await update($, lines, () => next)
138
+ } catch {
139
+ // The statusline is a convenience; a refused or timed-out run is not worth an error
140
+ } finally {
141
+ isRunning = false
142
+ }
143
+ }
144
+
145
+ export const register: Register = on => {
146
+ on('session.start', ($, e, next) => {
147
+ timer?.cancel()
148
+ timer = $.clock.every(REFRESH_MS, () => void refresh($))
149
+ void refresh($)
150
+ return next(e)
151
+ })
152
+
153
+ // Not awaited: a slow statusline must never hold a turn open
154
+ on('turn.complete', ($, e, next) => {
155
+ void refresh($)
156
+ return next(e)
157
+ })
158
+
159
+ on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
160
+ if (e.surface !== 'desktop' || e.props.hasSurvey) return next(e)
161
+ const shown = (await read($, lines)).slice(0, Math.max(0, e.props.maxRows))
162
+ if (!shown.length) return next(e)
163
+
164
+ const { Box, Text } = $.ui.resolve(e)
165
+ // One statusline line per row; a Box lays its children out in a row unless told otherwise
166
+ return (
167
+ <Box flexDirection="column">
168
+ {shown.map((line, i) => (
169
+ <Text key={`line-${i}`} dimColor>
170
+ {line}
171
+ </Text>
172
+ ))}
173
+ </Box>
174
+ )
175
+ })
176
+ }
@@ -0,0 +1,5 @@
1
+ declare module 'claude-code' {
2
+ interface PluginState {
3
+ 'sprag-statusline': { lines: string[] }
4
+ }
5
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprag-cli",
3
- "version": "3.65.0",
3
+ "version": "3.66.0",
4
4
  "description": "Lower your Claude Code bill without changing how you work: simple tasks go to cheaper models, documents go in as compact Markdown, and the statusline shows spend and savings live. Works with Codex too.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,7 @@
15
15
  "release:notes": "node scripts/release-notes.mjs",
16
16
  "site:docs": "node scripts/build-site-docs.mjs",
17
17
  "test": "node --test",
18
- "test:mods": "claude plugin test mod/sprag-doc2md && claude plugin test mod/sprag-delegation-meter",
18
+ "test:mods": "claude plugin test mod/sprag-doc2md && claude plugin test mod/sprag-delegation-meter && claude plugin test mod/sprag-statusline",
19
19
  "version": "node scripts/sync-site-version.mjs"
20
20
  },
21
21
  "files": [
@@ -23,6 +23,7 @@
23
23
  "src/",
24
24
  "mod/*/.claude-plugin/plugin.json",
25
25
  "mod/*/hooks/**",
26
+ "mod/*/types/**",
26
27
  "presets/",
27
28
  "examples/",
28
29
  "README.md",
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { createRequire } from 'node:module';
13
13
  import { readdirSync, rmSync, existsSync, statSync } from 'node:fs';
14
- import { join } from 'node:path';
14
+ import { basename, join } from 'node:path';
15
15
 
16
16
  const require = createRequire(import.meta.url);
17
17
 
@@ -149,6 +149,15 @@ export async function run({ args, hasFlag }) {
149
149
  }
150
150
  console.error(`✗ ${result.reason}${result.detail ? `: ${result.detail}` : ''}`);
151
151
  if (result.reason === 'no-markitdown') console.error(` ${doc2md.INSTALL_HINT}`);
152
+ // The line after the reason is the same sentence the hooks give the model;
153
+ // the mod reads it back from here, so keep the first line's format as is.
154
+ let lang = 'en';
155
+ try {
156
+ const { userLanguage } = await import('../config.js');
157
+ lang = userLanguage();
158
+ } catch { /* the diagnostic must not depend on the config module */ }
159
+ const notice = doc2md.lockedNotice(basename(sub), result, lang);
160
+ if (notice) console.error(` ${notice}`);
152
161
  process.exitCode = 1;
153
162
  return;
154
163
  }
@@ -87,6 +87,8 @@ export async function run({ hasFlag }) {
87
87
  print('PostToolUse hook (delegation rescan)', r.delegationHook);
88
88
  print('mod (delegation meter, Claude Code 2.1.287+)', r.delegationMeterMod);
89
89
  if (r.delegationMeterMod.reason) console.log(` ${r.delegationMeterMod.reason}`);
90
+ print('mod (desktop statusline, Claude Code 2.1.287+)', r.statuslineMod);
91
+ if (r.statuslineMod.reason) console.log(` ${r.statuslineMod.reason}`);
90
92
  print('UserPromptSubmit hook (brief)', r.briefHook);
91
93
  {
92
94
  let s = r.statusline;
package/src/doc2md.cjs CHANGED
@@ -678,8 +678,9 @@ function decideForRead(context, opts = {}) {
678
678
  return { deny: true, reason: bits.join('\n') };
679
679
  }
680
680
 
681
- // From here down the Read is allowed through untouched. The only question is
682
- // whether the model is told why nothing was converted.
681
+ // From here down the Read is allowed through untouched, except for the
682
+ // locked and unsafe-archive cases below. The only question is whether the
683
+ // model is told why nothing was converted.
683
684
  if (result.reason === 'no-markitdown') {
684
685
  if (noticeAlreadyShown()) return null;
685
686
  markNoticeShown();
@@ -690,21 +691,10 @@ function decideForRead(context, opts = {}) {
690
691
  + ` 진행 상황: ${INSTALL_HINT} 를 직접 실행하면 설치 로그를 볼 수 있습니다.`,
691
692
  };
692
693
  }
693
- if (result.reason === 'drm-protected') {
694
- return {
695
- deny: false,
696
- reason: `[doc2md] ${name} 는 DRM 으로 보호된 파일이라 변환하지 못했습니다(${result.detail || ''}).\n`
697
- + ' DRM 은 암호 입력으로 풀리지 않습니다. 벤더 에이전트가 허용한 프로그램에서만 평문이 보이므로, '
698
- + '사용자에게 DRM 클라이언트에서 연 뒤 해제본으로 저장하거나 반출 승인을 받은 사본을 요청하십시오.',
699
- };
700
- }
701
- if (result.reason === 'encrypted') {
702
- return {
703
- deny: false,
704
- reason: `[doc2md] ${name} 는 암호가 걸린 문서라 변환하지 못했습니다. `
705
- + '사용자에게 암호를 푼 사본을 요청하십시오. 이 도구는 암호를 묻거나 저장하지 않습니다.',
706
- };
707
- }
694
+ // An encrypted document blocks the Read. It cannot be opened anyway, and
695
+ // handing the original on only invites the model to try to get around it.
696
+ const locked = lockedNotice(name, result, 'ko');
697
+ if (locked) return { deny: true, reason: '[doc2md] ' + locked };
708
698
  if (result.reason === 'python-too-old') {
709
699
  if (noticeAlreadyShown()) return null;
710
700
  markNoticeShown();
@@ -827,6 +817,23 @@ function conversionSummary(sourcePath, meta = {}) {
827
817
  return parts.join(', ');
828
818
  }
829
819
 
820
+ /**
821
+ * The one sentence for a document that cannot be opened because it is
822
+ * encrypted, or null for any other result.
823
+ *
824
+ * Password protection and DRM are deliberately not told apart here. To the
825
+ * user both are a document that cannot be opened, and releasing either one
826
+ * goes through the company's own export procedure, so asking for an unlocked
827
+ * copy is never something the model should do. The Read hook, the prompt hook,
828
+ * the CLI diagnostic and the mod all share this wording.
829
+ */
830
+ function lockedNotice(name, result, lang = 'en') {
831
+ if (!result || (result.reason !== 'encrypted' && result.reason !== 'drm-protected')) return null;
832
+ return lang === 'ko'
833
+ ? `${name} 는 암호화된 문서라 내용을 확인할 수 없습니다${result.detail ? `(${result.detail})` : ''}. 사용자에게 그대로 알리고, 암호 해제나 다른 변환 방법은 시도하지 마십시오.`
834
+ : `${name} is encrypted, so its contents cannot be read${result.detail ? ` (${result.detail})` : ''}. Tell the user so, and do not try to unlock it or convert it another way.`;
835
+ }
836
+
830
837
  /**
831
838
  * Context to inject for a UserPromptSubmit payload, or null.
832
839
  *
@@ -863,14 +870,8 @@ function contextForPrompt(payload, opts = {}) {
863
870
  : lang === 'ko'
864
871
  ? ` ${name}: 변환기를 설치하는 중입니다(첫 실행에만 걸립니다). 설치가 끝나면 다음 요청부터 자동 변환됩니다.`
865
872
  : ` ${name}: the converter is installing now (first run only). It will convert automatically from the next request.`);
866
- } else if (result.reason === 'drm-protected') {
867
- lines.push(lang === 'ko'
868
- ? ` ${name}: DRM 으로 보호된 파일입니다(${result.detail || ''}). 암호로는 풀 수 없으므로, DRM 클라이언트에서 저장한 해제본이나 반출 승인 사본을 달라고 사용자에게 요청하십시오.`
869
- : ` ${name}: the file is DRM-wrapped (${result.detail || ''}). A password will not open it — ask the user for a copy released from DRM.`);
870
- } else if (result.reason === 'encrypted') {
871
- lines.push(lang === 'ko'
872
- ? ` ${name}: 암호가 걸린 문서입니다. 암호를 푼 사본을 달라고 사용자에게 요청하십시오.`
873
- : ` ${name}: the document is password-protected. Ask the user for an unlocked copy.`);
873
+ } else if (result.reason === 'drm-protected' || result.reason === 'encrypted') {
874
+ lines.push(` ${lockedNotice(name, result, lang)}`);
874
875
  } else if (result.reason === 'python-too-old') {
875
876
  lines.push(lang === 'ko'
876
877
  ? ` ${name}: 변환기가 Python 3.10 이상을 요구하는데 PATH 에 없습니다(macOS 기본은 3.9). \`brew install python\` 후 다시 시도하도록 사용자에게 안내하십시오.`
@@ -921,15 +922,17 @@ function contextForPrompt(payload, opts = {}) {
921
922
  /**
922
923
  * The standing note injected at session start.
923
924
  *
924
- * Two things the model cannot work out on its own: that attaching a document
925
- * costs far more than naming its path, and that the binary-file refusal it
926
- * will meet on a pptx has a one-command answer.
925
+ * Three things the model cannot work out on its own: that attaching a document
926
+ * costs far more than naming its path, that the binary-file refusal it
927
+ * will meet on a pptx has a one-command answer, and that an encrypted document
928
+ * is to be reported, not worked around.
927
929
  */
928
930
  function sessionNote(lang = 'en') {
929
931
  if (lang === 'ko') {
930
932
  return [
931
933
  '[doc2md] 문서(pptx·xlsx·pdf·docx)를 다룰 때의 규칙입니다.',
932
934
  ' 문서를 열어야 하는데 Read 가 이진 파일이라며 거부하면, `sprag doc2md <경로>` 를 실행하고 그 결과로 나온 .md 를 Read 하십시오.',
935
+ ' 변환이 암호화 문서(encrypted·drm-protected)라는 이유로 실패하면, 내용을 확인할 수 없다고 사용자에게 알리고 암호 해제나 다른 변환 방법은 시도하지 마십시오.',
933
936
  ' 사용자가 문서를 메시지에 직접 첨부했다면, 첨부는 내용 전체가 컨텍스트에 실려 토큰을 크게 씁니다. 다음부터는 첨부 대신 파일 경로를 적어 달라고 안내하십시오. 경로로 주면 변환본만 읽습니다.',
934
937
  ' 변환은 단방향입니다. 변환본 .md 를 고쳐도 원본에는 반영되지 않습니다. 문서 수정 요청을 받으면: ① 원본을 복사하고 ② 복사본을 스크립트로 수정하십시오. pptx·docx·xlsx 는 도구 venv 파이썬에 python-pptx·python-docx·openpyxl 이 준비되어 있고(`sprag doc2md` 상태 출력에 경로가 나옵니다), .fig 는 openfig-core 로 편집·재인코드합니다. ③ 수정한 복사본을 doc2md 로 재변환해 변경이 들어갔는지 검증하십시오. 차트·이미지 같은 시각 요소는 변환본에 안 잡히므로 텍스트 검증만으로 완료를 단정하지 마십시오.',
935
938
  ].join('\n');
@@ -937,6 +940,7 @@ function sessionNote(lang = 'en') {
937
940
  return [
938
941
  '[doc2md] Handling documents (pptx/xlsx/pdf/docx):',
939
942
  ' If Read refuses a document as a binary file, run `sprag doc2md <path>` and Read the .md it prints.',
943
+ ' If conversion fails because the document is encrypted (encrypted or drm-protected), tell the user its contents cannot be read, and do not try to unlock it or convert it another way.',
940
944
  ' If the user attached a document to their message, its full contents were billed into the context. Tell them that naming the file path instead is far cheaper, since only the converted Markdown gets read.',
941
945
  ' Conversions are one-way: editing the cached .md changes nothing in the source. When asked to modify a document: ① copy the original, ② edit the COPY with a script — the managed venv python has python-pptx/python-docx/openpyxl, and .fig edits go through openfig-core — then ③ re-convert the copy with doc2md to verify the change landed. Charts and images do not appear in conversions, so text verification alone does not prove visual edits.',
942
946
  ].join('\n');
@@ -1043,6 +1047,7 @@ module.exports = {
1043
1047
  isTargetPath,
1044
1048
  isSensitivePath,
1045
1049
  documentPathsIn,
1050
+ lockedNotice,
1046
1051
  contextForPrompt,
1047
1052
  sessionNote,
1048
1053
  findInterpreter,
package/src/installer.js CHANGED
@@ -709,7 +709,8 @@ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
709
709
  const PLUGIN_DIRS_ENV = 'CLAUDE_CODE_PLUGIN_DIRS';
710
710
  export const DOC2MD_MOD = 'sprag-doc2md';
711
711
  export const DELEGATION_METER_MOD = 'sprag-delegation-meter';
712
- export const MOD_NAMES = [DOC2MD_MOD, DELEGATION_METER_MOD];
712
+ export const STATUSLINE_MOD = 'sprag-statusline';
713
+ export const MOD_NAMES = [DOC2MD_MOD, DELEGATION_METER_MOD, STATUSLINE_MOD];
713
714
 
714
715
  export function modDir(name) {
715
716
  return join(PACKAGE_ROOT, 'mod', name);
@@ -809,6 +810,26 @@ export function installDelegationMeterMod() {
809
810
  return { path: file, action: read.state === 'absent' ? 'created' : 'updated' };
810
811
  }
811
812
 
813
+ // The statusline mod draws sprag's statusline in the Claude desktop app, whose
814
+ // Code tab never runs settings.json's statusLine.command (see
815
+ // mod/sprag-statusline). The terminal keeps its own statusline; the mod draws
816
+ // on the desktop surface only. Ships on with installAll() and is taken out
817
+ // with every other mod by uninstallAll().
818
+ export function installStatuslineMod() {
819
+ const dir = claudeUserDir();
820
+ const file = join(dir, 'settings.json');
821
+ mkdirSync(dir, { recursive: true });
822
+ const read = readSettings(file);
823
+ if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
824
+ const settings = read.settings;
825
+ const changed = addModDir(settings, STATUSLINE_MOD);
826
+ if (typeof changed === 'string') return { path: file, action: 'skipped', reason: changed };
827
+ if (!changed) return { path: file, action: 'exists' };
828
+ const writeProblem = writeSettings(file, settings);
829
+ if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
830
+ return { path: file, action: read.state === 'absent' ? 'created' : 'updated' };
831
+ }
832
+
812
833
  // Registers the PreToolUse hook that converts attached documents to Markdown
813
834
  // before the model reads them. PreToolUse rather than PostToolUse because the
814
835
  // point is to intervene before a pptx lands in the context window; afterwards
@@ -1247,6 +1268,7 @@ export function installAll({ force = false } = {}) {
1247
1268
  sessionStartHook: installSessionStartHook(),
1248
1269
  delegationHook: installDelegationHook(),
1249
1270
  delegationMeterMod: installDelegationMeterMod(),
1271
+ statuslineMod: installStatuslineMod(),
1250
1272
  briefHook: installBriefHook(),
1251
1273
  cacheMonitorMigration: migrateLegacyCacheMonitorHook(),
1252
1274
  legacy: removeLegacyCommand(),
@@ -371,18 +371,59 @@ function addVote(votes, pid, role, kind) {
371
371
  }
372
372
 
373
373
  /**
374
- * Read one main transcript: which profile id the session itself ran on, and
375
- * which role each Task/Agent tool_use asked for (joined later by tool_use id).
374
+ * The model id Claude Code told a transcript's agent it runs on: the
375
+ * `attachment.type === 'model'` record (`identity.modelId`) written at the
376
+ * start of a session and of every subagent run. On a gateway `message.model`
377
+ * is an opaque profile ARN, while this names what the alias resolved to.
378
+ */
379
+ function declaredModelOf(entry) {
380
+ const a = entry?.type === 'attachment' ? entry.attachment : null;
381
+ if (!a || a.type !== 'model') return null;
382
+ const id = a.identity?.modelId;
383
+ return typeof id === 'string' && id ? id : null;
384
+ }
385
+
386
+ /**
387
+ * The role one delegated run votes for its profile id, or null for no vote.
388
+ *
389
+ * What the run was told it is outranks what its caller asked for. A Task that
390
+ * asks for haiku in a session whose haiku alias resolves to nothing runs on
391
+ * the session model instead: the Claude Desktop app does this when its
392
+ * provider settings register no haiku model, and the transcript keeps the
393
+ * `model: "haiku"` request while the run answers on the parent's profile.
394
+ * Counting the request stamped that profile 'haiku' through the explicit-veto
395
+ * path in tallyVotes (1 explicit vote against 4,153 inferred 'main' ones,
396
+ * measured 2026-10-12), and once the gateway table aged out every record of
397
+ * the session model would have priced as Haiku.
398
+ *
399
+ * A declared id that names a family decides. One that is opaque too (an alias
400
+ * pointed at a profile ARN) and equals the parent's own declared model means
401
+ * the run fell back, so it says nothing about the tier it was asked for.
402
+ * Transcripts written before the declaration existed keep voting the request.
403
+ */
404
+ export function voteRole(requested, declared, parentDeclared) {
405
+ const stated = roleFromModelParam(declared);
406
+ if (stated) return stated;
407
+ if (declared && declared === parentDeclared && requested && !rolesAgree(requested, 'main')) return null;
408
+ return requested || null;
409
+ }
410
+
411
+ /**
412
+ * Read one main transcript: which profile id the session itself ran on, the
413
+ * model it was declared to run on, and which role each Task/Agent tool_use
414
+ * asked for (joined later by tool_use id).
376
415
  */
377
416
  async function scanMainTranscript(path, votes, requestedByToolUse, agentTypeCache) {
378
417
  let sawGateway = false;
418
+ let declared = null;
379
419
  const rl = createInterface({
380
420
  input: createReadStream(path, { encoding: 'utf8' }),
381
421
  crlfDelay: Infinity,
382
422
  });
383
423
  try {
384
424
  for await (const line of rl) {
385
- if (!line.includes('arn:aws:bedrock:') && !line.includes('"Task"') && !line.includes('"Agent"')) {
425
+ if (!line.includes('arn:aws:bedrock:') && !line.includes('"Task"') && !line.includes('"Agent"')
426
+ && !line.includes('"identity"')) {
386
427
  continue;
387
428
  }
388
429
  let entry;
@@ -391,6 +432,7 @@ async function scanMainTranscript(path, votes, requestedByToolUse, agentTypeCach
391
432
  } catch {
392
433
  continue;
393
434
  }
435
+ if (!declared && entry.isSidechain !== true) declared = declaredModelOf(entry);
394
436
  const msg = entry.message;
395
437
  if (!msg) continue;
396
438
 
@@ -418,31 +460,36 @@ async function scanMainTranscript(path, votes, requestedByToolUse, agentTypeCach
418
460
  } finally {
419
461
  rl.close();
420
462
  }
421
- return sawGateway;
463
+ return { sawGateway, declared };
422
464
  }
423
465
 
424
- /** First profile id used by a subagent transcript (a run uses exactly one). */
425
- async function subagentProfileId(path) {
466
+ /**
467
+ * First profile id used by a subagent transcript (a run uses exactly one),
468
+ * and the model the run was declared to run on, which comes before it.
469
+ */
470
+ async function subagentModelInfo(path) {
471
+ let declared = null;
426
472
  const rl = createInterface({
427
473
  input: createReadStream(path, { encoding: 'utf8' }),
428
474
  crlfDelay: Infinity,
429
475
  });
430
476
  try {
431
477
  for await (const line of rl) {
432
- if (!line.includes('arn:aws:bedrock:')) continue;
478
+ if (!line.includes('arn:aws:bedrock:') && !line.includes('"identity"')) continue;
433
479
  let entry;
434
480
  try {
435
481
  entry = JSON.parse(line);
436
482
  } catch {
437
483
  continue;
438
484
  }
485
+ declared ??= declaredModelOf(entry);
439
486
  const pid = profileIdFrom(entry.message?.model);
440
- if (pid) return pid;
487
+ if (pid) return { pid, declared };
441
488
  }
442
489
  } finally {
443
490
  rl.close();
444
491
  }
445
- return null;
492
+ return { pid: null, declared };
446
493
  }
447
494
 
448
495
  /** Subagent transcripts a session spawned (mirrors subagent-records layout). */
@@ -580,7 +627,8 @@ function newestFirst(paths) {
580
627
  * The join is exact rather than time-windowed: `.meta.json` carries the
581
628
  * `toolUseId` of the Task block that spawned the run, and that block names the
582
629
  * model tier. A subagent transcript uses exactly one profile id, so the run's
583
- * id and the requested role identify each other.
630
+ * id and the requested role identify each other, unless the run declares that
631
+ * it ran on another model (see voteRole).
584
632
  *
585
633
  * @param {object} opts
586
634
  * @param {string[]} opts.sessionPaths transcripts to read, in any order — they
@@ -597,8 +645,9 @@ export async function learnProfileMapping({ sessionPaths = [], maxSessions = 40
597
645
  for (const sessionPath of newestFirst(sessionPaths).slice(0, maxSessions)) {
598
646
  const requestedByToolUse = new Map();
599
647
  let sawGateway = false;
648
+ let parentDeclared = null;
600
649
  try {
601
- sawGateway = await scanMainTranscript(sessionPath, votes, requestedByToolUse, agentTypeCache);
650
+ ({ sawGateway, declared: parentDeclared } = await scanMainTranscript(sessionPath, votes, requestedByToolUse, agentTypeCache));
602
651
  } catch {
603
652
  continue;
604
653
  }
@@ -609,14 +658,16 @@ export async function learnProfileMapping({ sessionPaths = [], maxSessions = 40
609
658
  try {
610
659
  meta = JSON.parse(await readFile(jsonl.replace(/\.jsonl$/, '.meta.json'), 'utf8'));
611
660
  } catch { /* pre-toolUseId runs simply cast no vote */ }
612
- const role = (meta?.toolUseId && requestedByToolUse.get(meta.toolUseId))
661
+ const requested = (meta?.toolUseId && requestedByToolUse.get(meta.toolUseId))
613
662
  || roleFromAgentType(meta?.agentType, agentTypeCache);
614
- if (!role) continue;
615
- const pid = await subagentProfileId(jsonl);
663
+ if (!requested) continue;
664
+ const { pid, declared } = await subagentModelInfo(jsonl);
616
665
  if (!pid) continue;
666
+ const role = voteRole(requested, declared, parentDeclared);
667
+ if (!role) continue;
617
668
  sawGateway = true;
618
669
  // Stated evidence: the Task call named this tier, or the agent
619
- // definition it used did.
670
+ // definition it used did, or the run itself declared what it ran on.
620
671
  addVote(votes, pid, role, 'explicit');
621
672
  }
622
673
  if (sawGateway) gateway = true;
@@ -111,6 +111,29 @@ export function sessionModelRank({ env = process.env, transcriptPath = null, sna
111
111
  return rankOf(snap && snap.model);
112
112
  }
113
113
 
114
+ /**
115
+ * Tiers a delegation cannot reach in this session, because the alias the hint
116
+ * names resolves to no model here.
117
+ *
118
+ * A host that manages the provider writes the ANTHROPIC_DEFAULT_*_MODEL
119
+ * variables itself, from the models registered in its own settings, and
120
+ * leaves the unregistered ones empty; settings.json `env` does not override
121
+ * them. The Claude Desktop app does this in its third-party mode
122
+ * (CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1), and a Task(model: "haiku") there
123
+ * runs on the session model. Measured 2026-10-12: with no haiku registered the
124
+ * run answered on the parent's Opus profile, and after registering one, on the
125
+ * Haiku profile. Hinting a delegation in that state costs a subagent's context
126
+ * and saves nothing. Outside such a host an unset variable selects Claude
127
+ * Code's built-in default for the tier, so nothing is reported unreachable.
128
+ */
129
+ const TIER_ALIAS_ENV = { T2: 'ANTHROPIC_DEFAULT_HAIKU_MODEL', T1: 'ANTHROPIC_DEFAULT_SONNET_MODEL' };
130
+ const TIER_ALIAS = { T2: 'haiku', T1: 'sonnet' };
131
+
132
+ export function unreachableTiers(env = process.env) {
133
+ if (env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST !== '1') return [];
134
+ return Object.keys(TIER_ALIAS_ENV).filter((tier) => !env[TIER_ALIAS_ENV[tier]]);
135
+ }
136
+
114
137
  /**
115
138
  * The rules a prompt matches, or null when the hint should stay quiet.
116
139
  *
@@ -120,9 +143,13 @@ export function sessionModelRank({ env = process.env, transcriptPath = null, sna
120
143
  * @param {number|null} [opts.sessionRank] price rank of the model reading the
121
144
  * hint (see sessionModelRank). null means unknown, and an unknown session
122
145
  * model filters nothing.
123
- * @returns {{cat: object, t2: object|undefined, t1: object|undefined}|null}
146
+ * @param {object} [opts.env] environment to read tier aliases from (see
147
+ * unreachableTiers)
148
+ * @returns {{cat: object, t2: object|undefined, t1: object|undefined, unreachable?: string[]}|null}
149
+ * `unreachable` is set, with neither t2 nor t1, when every matching tier is
150
+ * one this session cannot reach
124
151
  */
125
- export function routeMatch(text, { rules, sessionRank = null, root } = {}) {
152
+ export function routeMatch(text, { rules, sessionRank = null, root, env = process.env } = {}) {
126
153
  const t = String(text || '').trim();
127
154
  if (t.length < MIN_LEN) return null;
128
155
  // Judgement and irreversible work stay on the top tier. This check comes
@@ -167,9 +194,15 @@ export function routeMatch(text, { rules, sessionRank = null, root } = {}) {
167
194
  ? matched
168
195
  : matched.filter((r) => worthDelegating(r.tier, sessionRank));
169
196
  if (!usable.length) return null;
197
+ // A tier whose alias resolves to nothing would run on the session model, so
198
+ // it is dropped like a tier that saves nothing. When that empties the list
199
+ // the match is still reported, so the hint can say why it does not delegate.
200
+ const unreachable = unreachableTiers(env);
201
+ const reachable = usable.filter((r) => !unreachable.includes(r.tier));
202
+ if (!reachable.length) return { cat, unreachable: [...new Set(usable.map((r) => r.tier))] };
170
203
 
171
204
  // The closest scope states the cap, whatever order the registry holds.
172
- const nearest = [...usable].sort((a, b) => scopeRank(b) - scopeRank(a));
205
+ const nearest = [...reachable].sort((a, b) => scopeRank(b) - scopeRank(a));
173
206
  const t2 = nearest.find((r) => r.tier === 'T2');
174
207
  const t1 = nearest.find((r) => r.tier === 'T1');
175
208
  return t2 || t1 ? { cat, t2, t1 } : null;
@@ -184,6 +217,24 @@ export function matchCaps(match) {
184
217
  return caps;
185
218
  }
186
219
 
220
+ /**
221
+ * What the hint says instead of a delegation when every matching tier is
222
+ * unreachable (see unreachableTiers). The model is told to work directly, and
223
+ * to pass the fix on once: the registration lives in the app's settings,
224
+ * which only the user can change.
225
+ */
226
+ function unreachableNotice(label, tiers, ko) {
227
+ const models = tiers.map((t) => TIER_ALIAS[t]).join(', ');
228
+ return ko
229
+ ? `[sprag] 이 요청은 위임 룰 "${label}" 에 해당하지만, 이 세션에는 ${models} 모델이 등록되어 있지 않습니다. `
230
+ + `위임해도 세션 모델이 그대로 처리하므로 비용이 줄지 않으니, 위임하지 말고 직접 처리하십시오. `
231
+ + `이 안내를 이번 세션에서 처음 받았다면 답변 끝에 "Claude 앱 설정에 ${models} 모델을 등록하면 이런 요청을 더 싼 모델에 맡길 수 있습니다."라고 사용자에게 한 줄로 알리십시오.`
232
+ : `[sprag] This request matches the delegation rule "${label}", but no ${models} model is registered for this session, `
233
+ + `so a delegated run would stay on the session model and save nothing. Handle it directly instead of delegating. `
234
+ + `If this is the first such notice in this session, end your reply with one line for the user: `
235
+ + `"Register a ${models} model in the Claude app settings to hand requests like this to a cheaper model."`;
236
+ }
237
+
187
238
  /**
188
239
  * @param {string} text the prompt just submitted
189
240
  * @param {object} [opts] as routeMatch, plus:
@@ -194,12 +245,13 @@ export function matchCaps(match) {
194
245
  * @param {object|null} [opts.match] a routeMatch result the caller already has
195
246
  * @returns {string|null} the line to inject, or null to stay quiet
196
247
  */
197
- export function routeHint(text, { rules, lang = userLanguage(), root, sessionRank = null, match } = {}) {
198
- const found = match === undefined ? routeMatch(text, { rules, sessionRank, root }) : match;
248
+ export function routeHint(text, { rules, lang = userLanguage(), root, sessionRank = null, match, env } = {}) {
249
+ const found = match === undefined ? routeMatch(text, { rules, sessionRank, root, env }) : match;
199
250
  if (!found) return null;
200
251
  const { cat, t2, t1 } = found;
201
252
  const ko = lang === 'ko';
202
253
  const label = ko ? (cat.label || cat.id) : (cat.labelEn || cat.label || cat.id);
254
+ if (!t2 && !t1 && found.unreachable?.length) return unreachableNotice(label, found.unreachable, ko);
203
255
  // `root` is threaded through to agentPhrase, which names the subagent only
204
256
  // when its .md actually exists on this machine. A caller that pins it can
205
257
  // therefore get a deterministic phrase; a test that does not pin it reads