sprag-cli 3.65.0 → 3.67.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/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/harness.js CHANGED
@@ -70,7 +70,7 @@ function ratchetMdPath(root) {
70
70
  return join(root, '.claude', 'ratchet.md');
71
71
  }
72
72
 
73
- function globalRatchetMdPath() {
73
+ export function globalRatchetMdPath() {
74
74
  return join(homedir(), '.claude', 'ratchet.md');
75
75
  }
76
76
 
@@ -1,11 +1,14 @@
1
+ import { agentPhrase, agentPhraseEn, resolveAgentName } from './agents.js';
2
+
1
3
  /** Shared policy, rendered with each client's native model selector. */
2
4
  export function implementationDelegationRule(agent = 'claude', lang = 'en') {
3
5
  const target = agent === 'codex'
4
6
  ? 'the resolved T1 model for the current parent model and provider (explicit mapping first, automatic resolution otherwise)'
5
- : 'a model: sonnet subagent';
7
+ : (resolveAgentName('sonnet', null) ? agentPhraseEn('sonnet', { root: null }) : 'a model: sonnet subagent');
8
+ const targetKo = resolveAgentName('sonnet', null) ? agentPhrase('sonnet', { root: null }) : 'model: sonnet 서브에이전트';
6
9
  if (lang === 'ko') return [
7
10
  '- **구현 분업**: 여러 파일을 고칠 때 설계와 스펙 확정은 메인 모델이 맡습니다.',
8
- ` 구현은 ${agent === 'codex' ? '현재 부모 모델과 프로바이더의 T1 모델' : 'model: sonnet 서브에이전트'}에 위임합니다.`,
11
+ ` 구현은 ${agent === 'codex' ? '현재 부모 모델과 프로바이더의 T1 모델' : targetKo}에 위임합니다.`,
9
12
  ' Claude는 sonnet을 쓰고, Codex는 명시적 T1 매핑을 우선하며 없으면 자동 해석한 T1을 씁니다.',
10
13
  ' 두 모델의 성능이 같다고 가정하지 않으며 haiku나 T2에 구현을 맡기지 않습니다.',
11
14
  ' 위임 프롬프트에는 설계, 수정할 파일, 제약, 완료 기준, 테스트 명령, 예산과',
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;
@@ -33,7 +33,7 @@ import { join, dirname } from 'node:path';
33
33
  import { homedir } from 'node:os';
34
34
  import { userDataDir } from './paths.js';
35
35
  import { userLanguage } from './config.js';
36
- import { agentPhrase, agentPhraseEn } from './agents.js';
36
+ import { agentPhrase, agentPhraseEn, resolveAgentName } from './agents.js';
37
37
  import { writeStateFile } from './state-file.js';
38
38
  import { signedUsd } from './money.js';
39
39
  import { implementationDelegationRule } from './implementation-delegation.js';
@@ -132,16 +132,23 @@ function shortExample(text) {
132
132
  return one.length <= EXAMPLE_MAX ? one : `${one.slice(0, EXAMPLE_MAX - 1)}…`;
133
133
  }
134
134
 
135
+ // T1 is named through its delegation agent only when that file exists; without
136
+ // it the literal `model: sonnet` stays so output on such machines is unchanged.
137
+ function t1Target(root, ko) {
138
+ if (resolveAgentName('sonnet', root) === null) return null;
139
+ return (ko ? agentPhrase : agentPhraseEn)('sonnet', { root });
140
+ }
141
+
135
142
  export function modelRuleBaseText(g, lang = userLanguage(), { root } = {}) {
136
143
  if (lang === 'ko') {
137
144
  return g.tier === 'T2'
138
145
  ? `"${g.label}" 단순 요청(예: "${shortExample(g.example)}")은 ${agentPhrase(g.agent, { root })}에 위임`
139
- : `"${g.label}" 중간 난도 요청(예: "${shortExample(g.example)}")은 model: sonnet에 위임`;
146
+ : `"${g.label}" 중간 난도 요청(예: "${shortExample(g.example)}")은 ${t1Target(root, true) ?? 'model: sonnet'}에 위임`;
140
147
  }
141
148
  const example = g.exampleEn || g.example;
142
149
  return g.tier === 'T2'
143
150
  ? `Simple "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${agentPhraseEn(g.agent, { root })}`
144
- : `Moderate "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to a model: sonnet subagent`;
151
+ : `Moderate "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${t1Target(root, false) ?? 'a model: sonnet subagent'}`;
145
152
  }
146
153
 
147
154
  // See the note in route-scan.js — paths.js is the only place that resolves
@@ -401,10 +408,10 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
401
408
  // match at a glance.
402
409
  const rule = ko
403
410
  ? `"${t2.label}": 기본 ${agentPhrase(t2.agent, { root })}(예: "${ex(t2)}"), ` +
404
- `여러 단계·여러 파일이 얽히면 model: sonnet(예: "${ex(t1)}"). ` +
411
+ `여러 단계·여러 파일이 얽히면 ${t1Target(root, true) ?? 'model: sonnet'}(예: "${ex(t1)}"). ` +
405
412
  mergedBudget(t2, t1)
406
413
  : `"${t2.labelEn || t2.label}": ${agentPhraseEn(t2.agent, { root })} by default (e.g. "${ex(t2)}"), ` +
407
- `model: sonnet when it spans multiple steps or file edits (e.g. "${ex(t1)}"). ` +
414
+ `${t1Target(root, false) ?? 'model: sonnet'} when it spans multiple steps or file edits (e.g. "${ex(t1)}"). ` +
408
415
  mergedBudget(t2, t1);
409
416
  lines.push(`- ${rule}${healthOf(t2)}${healthOf(t1)}${extraHealth} <!-- T2 ${statsOf(t2)} / T1 ${statsOf(t1)}${extraStats} -->`);
410
417
  } else if (t2 || t1) {
@@ -34,7 +34,7 @@
34
34
  import { categorizeScored, ESCALATE_RE, EDIT_RE, worthDelegating } from './route-scan.js';
35
35
  import { loadModelRules, budgetCapPhrase, ruleBudget } from './model-rules.js';
36
36
  import { sharedProjectRoots, realProjectRoot, scopeRank } from './shared-model-rules.js';
37
- import { agentPhrase, agentPhraseEn } from './agents.js';
37
+ import { agentPhrase, agentPhraseEn, resolveAgentName } from './agents.js';
38
38
  import { userLanguage } from './config.js';
39
39
  import { aliasForRole, resolveModelAlias } from './model-alias.js';
40
40
  import { isRecognizedModelId, modelRank } from './cost.js';
@@ -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
@@ -207,11 +259,16 @@ export function routeHint(text, { rules, lang = userLanguage(), root, sessionRan
207
259
  const raw = ko ? agentPhrase : agentPhraseEn;
208
260
  const phrase = (name) => raw(name, root === undefined ? undefined : { root });
209
261
 
262
+ // T1 is named through its delegation agent only when that file exists here;
263
+ // otherwise the literal tier string stays, so output without agents is unchanged.
264
+ const t1Root = root === undefined ? undefined : root;
265
+ const t1Named = resolveAgentName('sonnet', t1Root) !== null;
266
+ const t1Phrase = t1Named ? phrase('sonnet') : 'model: sonnet';
210
267
  let target;
211
268
  if (t2 && t1) {
212
269
  target = ko
213
- ? `기본 ${phrase(t2.agent)}, 여러 단계·여러 파일이 얽히면 model: sonnet`
214
- : `${phrase(t2.agent)} by default, model: sonnet when it spans multiple steps or file edits`;
270
+ ? `기본 ${phrase(t2.agent)}, 여러 단계·여러 파일이 얽히면 ${t1Phrase}`
271
+ : `${phrase(t2.agent)} by default, ${t1Phrase} when it spans multiple steps or file edits`;
215
272
  } else {
216
273
  const only = t2 || t1;
217
274
  target = ko ? phrase(only.agent) : phrase(only.agent);