typeclaw 0.37.6 → 0.37.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.37.6",
3
+ "version": "0.37.7",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -50,7 +50,7 @@
50
50
  "@mariozechner/pi-tui": "^0.67.3",
51
51
  "@modelcontextprotocol/sdk": "^1.29.0",
52
52
  "@mozilla/readability": "^0.6.0",
53
- "agent-messenger": "2.20.1",
53
+ "agent-messenger": "2.20.5",
54
54
  "cheerio": "^1.2.0",
55
55
  "citty": "^0.2.2",
56
56
  "cron-parser": "^5.5.0",
@@ -0,0 +1,590 @@
1
+ import type { ThinkingLevel } from '@mariozechner/pi-agent-core'
2
+
3
+ function normalize(text: string): string {
4
+ return text
5
+ .toLowerCase()
6
+ .replace(/[`*_~]/g, ' ')
7
+ .replace(/\s+/g, ' ')
8
+ .trim()
9
+ }
10
+
11
+ // Human attention-escalation is a budget hint, not a command. Keep phrases narrow:
12
+ // explicit demands for care, or clear dissatisfaction directed at this response.
13
+ const EN_PHRASES: readonly string[] = [
14
+ 'do it properly',
15
+ 'do it right',
16
+ 'do it correctly',
17
+ 'be careful',
18
+ 'be thorough',
19
+ 'think hard',
20
+ 'think harder',
21
+ 'think carefully',
22
+ 'more carefully',
23
+ 'ultrathink',
24
+ 'ultrawork',
25
+ 'ultracode',
26
+ 'wtf',
27
+ 'wtf is this',
28
+ 'fuck',
29
+ 'fucking',
30
+ 'fucked',
31
+ 'fuck this',
32
+ 'fuck off',
33
+ 'what the fuck',
34
+ 'what the hell',
35
+ 'da fuck',
36
+ 'the fuck',
37
+ 'what are you doing',
38
+ 'what are u doing',
39
+ 'this is wrong',
40
+ "that's wrong",
41
+ 'ffs',
42
+ 'for fucks sake',
43
+ 'shit',
44
+ 'this is shit',
45
+ 'bullshit',
46
+ 'damn it',
47
+ 'damn this',
48
+ 'dammit',
49
+ 'goddamn',
50
+ 'god damn',
51
+ 'this is crap',
52
+ 'piece of shit',
53
+ 'screw this',
54
+ 'screwed up',
55
+ 'you suck',
56
+ 'this sucks',
57
+ 'garbage',
58
+ 'trash',
59
+ 'useless',
60
+ 'are you serious',
61
+ 'seriously?',
62
+ 'are you kidding',
63
+ 'you kidding me',
64
+ 'come on',
65
+ 'cmon',
66
+ 'jesus christ',
67
+ 'oh my god',
68
+ 'omfg',
69
+ 'stupid',
70
+ 'idiot',
71
+ 'moron',
72
+ 'pathetic',
73
+ 'terrible',
74
+ 'awful',
75
+ 'broken again',
76
+ 'still broken',
77
+ 'not again',
78
+ ]
79
+
80
+ const KO_PHRASES: readonly string[] = [
81
+ '제대로 해',
82
+ '제대로 좀',
83
+ '똑바로 해',
84
+ '똑바로 좀',
85
+ '잘 좀',
86
+ '잘 해',
87
+ '신중하게',
88
+ '꼼꼼하게',
89
+ '씨발',
90
+ '시발',
91
+ '씨바',
92
+ 'ㅅㅂ',
93
+ '시바',
94
+ '존나',
95
+ '졸라',
96
+ 'ㅈㄴ',
97
+ '개같',
98
+ '개판',
99
+ '병신',
100
+ 'ㅂㅅ',
101
+ '미친',
102
+ '미쳤',
103
+ 'ㅁㅊ',
104
+ '엿같',
105
+ '짜증',
106
+ '짜증나',
107
+ '빡치',
108
+ '빡쳐',
109
+ '개소리',
110
+ '말도 안',
111
+ '실화냐',
112
+ '에휴',
113
+ '아오',
114
+ '하…',
115
+ '쓰레기',
116
+ '구려',
117
+ '구리',
118
+ '왜 안',
119
+ '또 안',
120
+ '또 틀',
121
+ '왜 이래',
122
+ '뭐하는 거야',
123
+ '뭐하는거야',
124
+ '뭐 하는 거야',
125
+ '아 진짜',
126
+ '장난해',
127
+ '장난하냐',
128
+ '이게 뭐야',
129
+ '똑바로 안 해',
130
+ ]
131
+
132
+ const ES_PHRASES: readonly string[] = [
133
+ 'hazlo bien',
134
+ 'hazlo correctamente',
135
+ 'con cuidado',
136
+ 'piensa bien',
137
+ 'qué haces',
138
+ 'que haces',
139
+ 'mierda',
140
+ 'joder',
141
+ 'jode',
142
+ 'puto',
143
+ 'puta madre',
144
+ 'cabrón',
145
+ 'cabron',
146
+ 'coño',
147
+ 'no jodas',
148
+ 'qué carajo',
149
+ 'que carajo',
150
+ 'carajo',
151
+ 'basura',
152
+ 'esto es una mierda',
153
+ 'es una basura',
154
+ 'inútil',
155
+ 'inutil',
156
+ 'me cago',
157
+ 'qué asco',
158
+ 'que asco',
159
+ 'maldita sea',
160
+ 'estás de broma',
161
+ 'estas de broma',
162
+ 'qué mierda',
163
+ 'que mierda',
164
+ 'en serio',
165
+ 'esto está mal',
166
+ 'esto esta mal',
167
+ ]
168
+
169
+ const FR_PHRASES: readonly string[] = [
170
+ 'fais-le correctement',
171
+ 'fais-le bien',
172
+ 'sois attentif',
173
+ 'réfléchis bien',
174
+ 'reflechis bien',
175
+ "qu'est-ce que tu fais",
176
+ 'putain',
177
+ 'merde',
178
+ 'fait chier',
179
+ 'fait chié',
180
+ 'connerie',
181
+ 'conneries',
182
+ 'bordel',
183
+ 'foutu',
184
+ 'fous-toi',
185
+ "c'est de la merde",
186
+ 'c’est de la merde',
187
+ "c'est pourri",
188
+ 'c’est pourri',
189
+ 'tu te fous',
190
+ "n'importe quoi",
191
+ 'n’importe quoi',
192
+ 'inutile',
193
+ 'poubelle',
194
+ 'tu déconnes',
195
+ 'tu deconnes',
196
+ 'c’est nul',
197
+ "c'est nul",
198
+ 'sérieusement',
199
+ 'serieusement',
200
+ 'c’est faux',
201
+ "c'est faux",
202
+ ]
203
+
204
+ const IT_PHRASES: readonly string[] = [
205
+ 'fallo bene',
206
+ 'con attenzione',
207
+ 'pensa bene',
208
+ 'ma che fai',
209
+ 'cazzo',
210
+ 'merda',
211
+ 'che cazzo',
212
+ 'porca',
213
+ 'porca miseria',
214
+ 'stronzata',
215
+ 'stronzate',
216
+ 'vaffanculo',
217
+ 'è una merda',
218
+ 'e una merda',
219
+ 'che schifo',
220
+ 'schifo',
221
+ 'spazzatura',
222
+ 'inutile',
223
+ 'ma che cavolo',
224
+ 'fai schifo',
225
+ 'scherzi',
226
+ 'che cavolo',
227
+ 'sul serio',
228
+ 'è sbagliato',
229
+ 'e sbagliato',
230
+ ]
231
+
232
+ const PT_PHRASES: readonly string[] = [
233
+ 'faça direito',
234
+ 'faca direito',
235
+ 'faça corretamente',
236
+ 'faca corretamente',
237
+ 'com cuidado',
238
+ 'pense bem',
239
+ 'que isso',
240
+ 'merda',
241
+ 'porra',
242
+ 'caralho',
243
+ 'que merda',
244
+ 'que porra',
245
+ 'puta que pariu',
246
+ 'porcaria',
247
+ 'é uma merda',
248
+ 'e uma merda',
249
+ 'que saco',
250
+ 'lixo',
251
+ 'inútil',
252
+ 'inutil',
253
+ 'que droga',
254
+ 'tá de brincadeira',
255
+ 'ta de brincadeira',
256
+ 'foda-se',
257
+ 'foda se',
258
+ 'está errado',
259
+ 'esta errado',
260
+ ]
261
+
262
+ const DE_PHRASES: readonly string[] = [
263
+ 'mach es richtig',
264
+ 'sei gründlich',
265
+ 'sei gruendlich',
266
+ 'denk nach',
267
+ 'sorgfältig',
268
+ 'sorgfaeltig',
269
+ 'was machst du',
270
+ 'was soll das',
271
+ 'scheiße',
272
+ 'scheisse',
273
+ 'scheiss',
274
+ 'verdammt',
275
+ 'verflucht',
276
+ 'so ein mist',
277
+ 'blödsinn',
278
+ 'bloedsinn',
279
+ 'quatsch',
280
+ 'das ist mist',
281
+ 'das ist müll',
282
+ 'das ist mull',
283
+ 'müll',
284
+ 'schwachsinn',
285
+ 'nutzlos',
286
+ 'unbrauchbar',
287
+ 'verarschst',
288
+ 'soll das',
289
+ 'im ernst',
290
+ 'ernsthaft',
291
+ 'das ist falsch',
292
+ ]
293
+
294
+ const RU_PHRASES: readonly string[] = [
295
+ 'сделай правильно',
296
+ 'сделай как надо',
297
+ 'внимательно',
298
+ 'тщательно',
299
+ 'что ты делаешь',
300
+ 'что за',
301
+ 'блять',
302
+ 'бля',
303
+ 'блядь',
304
+ 'сука',
305
+ 'нахуй',
306
+ 'нахер',
307
+ 'пиздец',
308
+ 'хуйня',
309
+ 'говно',
310
+ 'дерьмо',
311
+ 'бред',
312
+ 'это бред',
313
+ 'фигня',
314
+ 'хрень',
315
+ 'мусор',
316
+ 'бесполезно',
317
+ 'издеваешься',
318
+ 'охренеть',
319
+ 'какого черта',
320
+ 'какого чёрта',
321
+ 'да блин',
322
+ 'серьёзно',
323
+ 'серьезно',
324
+ 'это неправильно',
325
+ ]
326
+
327
+ const ZH_PHRASES: readonly string[] = [
328
+ '认真做',
329
+ '好好做',
330
+ '仔细点',
331
+ '用心做',
332
+ '卧槽',
333
+ '我操',
334
+ '操你',
335
+ '操你妈',
336
+ '我擦',
337
+ '草泥马',
338
+ '我草',
339
+ '草你',
340
+ '我靠',
341
+ '靠北',
342
+ '靠杯',
343
+ '妈的',
344
+ '他妈的',
345
+ 'tmd',
346
+ '垃圾',
347
+ '狗屎',
348
+ '搞屁',
349
+ '搞毛',
350
+ '什么垃圾',
351
+ '太烂了',
352
+ '烂代码',
353
+ '废话',
354
+ '神经病',
355
+ '有病',
356
+ '认真点',
357
+ '搞什么',
358
+ '搞什么鬼',
359
+ '你在干什么',
360
+ '什么鬼',
361
+ '认真的吗',
362
+ ]
363
+
364
+ const JA_PHRASES: readonly string[] = [
365
+ 'ちゃんとやって',
366
+ 'しっかりやって',
367
+ '真面目にやって',
368
+ '丁寧に',
369
+ 'くそ',
370
+ 'クソ',
371
+ 'くそが',
372
+ 'ふざけんな',
373
+ 'ふざけるな',
374
+ 'ばか',
375
+ 'バカ',
376
+ 'あほ',
377
+ 'アホ',
378
+ 'ゴミ',
379
+ 'クズ',
380
+ 'ちくしょう',
381
+ '畜生',
382
+ '使えない',
383
+ '最悪',
384
+ 'ありえない',
385
+ 'うざい',
386
+ 'むかつく',
387
+ 'なんなの',
388
+ 'ksk',
389
+ '何やってんの',
390
+ '何してるの',
391
+ 'ふざけてるの',
392
+ 'マジで',
393
+ 'ちゃんとして',
394
+ ]
395
+
396
+ const AR_PHRASES: readonly string[] = [
397
+ 'اعملها صح',
398
+ 'بعناية',
399
+ 'فكر جيدا',
400
+ 'تبا',
401
+ 'تباً',
402
+ 'اللعنة',
403
+ 'لعنة',
404
+ 'قرف',
405
+ 'هراء',
406
+ 'حقير',
407
+ 'غبي',
408
+ 'سخيف',
409
+ 'فاشل',
410
+ 'هذا هراء',
411
+ 'ما هذا الهراء',
412
+ 'تافه',
413
+ 'زبالة',
414
+ 'عديم الفائدة',
415
+ 'هل تمزح',
416
+ 'ماذا تفعل',
417
+ 'ما هذا',
418
+ 'بجدية',
419
+ 'هذا خطأ',
420
+ ]
421
+
422
+ const HI_PHRASES: readonly string[] = [
423
+ 'ठीक से करो',
424
+ 'ध्यान से',
425
+ 'अच्छे से करो',
426
+ 'बकवास',
427
+ 'यह बकवास है',
428
+ 'बेकार',
429
+ 'बेवकूफ',
430
+ 'घटिया',
431
+ 'कचरा',
432
+ 'गंदा',
433
+ 'मज़ाक',
434
+ 'मजाक',
435
+ 'धत',
436
+ 'क्या बकवास',
437
+ 'पागल हो',
438
+ 'बहुत बुरा',
439
+ 'क्या कर रहे हो',
440
+ 'यह गलत है',
441
+ 'सच में',
442
+ ]
443
+
444
+ const TR_PHRASES: readonly string[] = [
445
+ 'düzgün yap',
446
+ 'duzgun yap',
447
+ 'doğru yap',
448
+ 'dogru yap',
449
+ 'dikkatli ol',
450
+ 'iyice düşün',
451
+ 'iyice dusun',
452
+ 'siktir',
453
+ 'lanet',
454
+ 'kahretsin',
455
+ 'saçmalık',
456
+ 'saçma',
457
+ 'bok',
458
+ 'boktan',
459
+ 'rezalet',
460
+ 'berbat',
461
+ 'çöp',
462
+ 'işe yaramaz',
463
+ 'ise yaramaz',
464
+ 'aptal',
465
+ 'salak',
466
+ 'dalga mı',
467
+ 'dalga mi',
468
+ 'bu ne rezalet',
469
+ 'ne yapıyorsun',
470
+ 'ne yapiyorsun',
471
+ 'ne saçmalık',
472
+ 'ne sacmalik',
473
+ 'cidden mi',
474
+ 'bu yanlış',
475
+ 'bu yanlis',
476
+ ]
477
+
478
+ const VI_PHRASES: readonly string[] = [
479
+ 'làm cho đúng',
480
+ 'lam cho dung',
481
+ 'làm cẩn thận',
482
+ 'lam can than',
483
+ 'suy nghĩ kỹ',
484
+ 'suy nghi ky',
485
+ 'đệch',
486
+ 'đếch',
487
+ 'vãi',
488
+ 'cứt',
489
+ 'rác',
490
+ 'vô dụng',
491
+ 'vo dung',
492
+ 'ngớ ngẩn',
493
+ 'tệ',
494
+ 'tệ hại',
495
+ 'quá tệ',
496
+ 'đùa à',
497
+ 'dua a',
498
+ 'vớ vẩn',
499
+ 'vo van',
500
+ 'chán',
501
+ 'đang làm gì vậy',
502
+ 'dang lam gi vay',
503
+ 'cái gì vậy',
504
+ 'cai gi vay',
505
+ 'nghiêm túc',
506
+ 'nghiem tuc',
507
+ 'sai rồi',
508
+ 'sai roi',
509
+ ]
510
+
511
+ const ID_PHRASES: readonly string[] = [
512
+ 'lakukan dengan benar',
513
+ 'hati-hati',
514
+ 'pikirkan baik-baik',
515
+ 'anjing',
516
+ 'bangsat',
517
+ 'sialan',
518
+ 'kampret',
519
+ 'goblok',
520
+ 'tolol',
521
+ 'bodoh',
522
+ 'sampah',
523
+ 'jelek',
524
+ 'payah',
525
+ 'tidak berguna',
526
+ 'gak guna',
527
+ 'omong kosong',
528
+ 'yang benar saja',
529
+ 'bercanda',
530
+ 'lagi ngapain',
531
+ 'apa-apaan',
532
+ 'ini salah',
533
+ ]
534
+
535
+ const ALL_PHRASES: readonly string[] = [
536
+ ...EN_PHRASES,
537
+ ...KO_PHRASES,
538
+ ...ES_PHRASES,
539
+ ...FR_PHRASES,
540
+ ...IT_PHRASES,
541
+ ...PT_PHRASES,
542
+ ...DE_PHRASES,
543
+ ...RU_PHRASES,
544
+ ...ZH_PHRASES,
545
+ ...JA_PHRASES,
546
+ ...AR_PHRASES,
547
+ ...HI_PHRASES,
548
+ ...TR_PHRASES,
549
+ ...VI_PHRASES,
550
+ ...ID_PHRASES,
551
+ ]
552
+
553
+ const MORPHEME_PATTERNS: readonly RegExp[] = []
554
+
555
+ const MIN_LENGTH = 2
556
+
557
+ export function detectAttentionEscalation(text: string): boolean {
558
+ if (text.length < MIN_LENGTH) return false
559
+ const normalized = normalize(text)
560
+ if (normalized.length < MIN_LENGTH) return false
561
+ if (ALL_PHRASES.some((phrase) => normalized.includes(phrase))) return true
562
+ return MORPHEME_PATTERNS.some((pattern) => pattern.test(normalized))
563
+ }
564
+
565
+ // Not `xhigh`: that level is OpenAI-family-only. `high` is universal and
566
+ // `setThinkingLevel` clamps it down per-model, so it's safe to pass unconditionally.
567
+ const ESCALATED_LEVEL: ThinkingLevel = 'high'
568
+
569
+ export function resolveTurnThinkingLevel(
570
+ text: string,
571
+ sessionDefault: ThinkingLevel | undefined,
572
+ ): ThinkingLevel | undefined {
573
+ return detectAttentionEscalation(text) ? ESCALATED_LEVEL : sessionDefault
574
+ }
575
+
576
+ type ThinkingLevelSettable = {
577
+ setThinkingLevel(level: ThinkingLevel): void
578
+ }
579
+
580
+ // `setThinkingLevel` only mutates reasoning_effort (a per-request param), so a
581
+ // per-turn bump preserves the prompt-prefix cache — no session recreation, no
582
+ // model swap. Skipping the call when nothing resolves leaves the SDK default intact.
583
+ export function applyTurnThinkingLevel(
584
+ session: ThinkingLevelSettable,
585
+ text: string,
586
+ sessionDefault: ThinkingLevel | undefined,
587
+ ): void {
588
+ const resolved = resolveTurnThinkingLevel(text, sessionDefault)
589
+ if (resolved !== undefined) session.setThinkingLevel(resolved)
590
+ }
@@ -538,16 +538,19 @@ function renderMembershipSummary(
538
538
  function renderResearchReportDeliveryGuidance(platformInfo: PlatformInfo): string[] {
539
539
  if (!platformInfo.supportsAttachments) return []
540
540
  return [
541
- `**Ship reports as a PDF by default.** ${platformInfo.displayName} accepts file`,
542
- 'attachments. When the user asks for a report, document, brief, or "the report", or when a',
543
- '`researcher` subagent returns `research-<slug>.md` in `<report>`, render the',
544
- 'markdown with `typeclaw-render-pdf` and deliver via',
541
+ `**Ship explicit deliverables as PDFs.** ${platformInfo.displayName} accepts file`,
542
+ 'attachments. When the user clearly asks for a PDF/file/export/attachment, a standalone',
543
+ 'document/brief, or when a `researcher` subagent returns `research-<slug>.md` in',
544
+ '`<report>`, render the markdown with `typeclaw-render-pdf` and deliver via',
545
545
  '`channel_send({ ..., attachments: [{ path, filename }] })` plus a 1–2 line',
546
- 'summary. A `researcher` `<summary>` is a teaser, NOT the deliverable. Never',
547
- 'use an ad-hoc library (jsPDF, pdfkit, raw-text dump); it breaks markdown/CJK.',
546
+ 'summary. Do not treat the bare word "report" as enough: routine daily stats,',
547
+ 'user trends, status reports, and other operational updates should stay inline',
548
+ 'unless the user asks for a downloadable/exported artifact. A `researcher`',
549
+ '`<summary>` is a teaser, NOT the deliverable. Never use an ad-hoc library',
550
+ '(jsPDF, pdfkit, raw-text dump); it breaks markdown/CJK.',
548
551
  "For Korean/Japanese/Chinese, follow the skill's CJK guidance and never ship",
549
552
  'tofu boxes. Do not paste the full markdown into chat; do not attach the raw `.md`',
550
- 'unless explicitly asked; inline text is only for short content.',
553
+ 'unless explicitly asked; inline text is right for routine updates.',
551
554
  '',
552
555
  ]
553
556
  }
@@ -5,6 +5,7 @@ import type { PermissionService } from '@/permissions'
5
5
  import type { HookBus } from '@/plugin'
6
6
  import type { Stream, Unsubscribe } from '@/stream'
7
7
 
8
+ import { applyTurnThinkingLevel } from './attention-escalation'
8
9
  import { type AgentSession, createSession, type PluginSessionWiring } from './index'
9
10
  import { subscribeProviderErrors } from './provider-error'
10
11
  import type { SubagentBashPolicy } from './reviewer-bash-policy'
@@ -304,6 +305,7 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
304
305
  retrievalContext.results.length > 0
305
306
  ? `${renderTurnTimeAnchor()}\n\n${userPromptForTurn}\n\n${retrievalContext.results}`
306
307
  : `${renderTurnTimeAnchor()}\n\n${userPromptForTurn}`
308
+ applyTurnThinkingLevel(session, userPromptForTurn, session.thinkingLevel)
307
309
  await session.prompt(turnText)
308
310
  } finally {
309
311
  if (hooks && turnEvent !== undefined) {
@@ -62,9 +62,9 @@ Do not narrate routine low-risk tools. Narrate only for multi-step context, risk
62
62
 
63
63
  ## Delivering reports and documents
64
64
 
65
- When the user asks for a *report*, *document*, *brief*, *PDF*, or asks you to *send/show/attach/export* a generated result anything a human would download, print, or forward produce a polished file, not a chat wall or substance-dropping summary. A summary is a pointer to the deliverable, never the deliverable itself; when the user asked for the report, ship the report.
65
+ Produce a polished file only when the user clearly asks for something a human would download, print, forward, attach, export, or keep as a standalone deliverable. Do **not** treat the bare word "report" as enough by itself: routine operational updates, daily stats, user trends, status reports, and other chat-native summaries should stay inline unless the user asks for a file/PDF/export. A summary is a pointer to the deliverable, never the deliverable itself, but only after a deliverable was actually requested.
66
66
 
67
- For Markdown-to-PDF, use the bundled \`typeclaw-render-pdf\` skill; it is the supported path and renders headings, lists, and tables. Never hand-roll PDFs with jsPDF, pdfkit, canvas text dumps, raw headless-browser prints, or ReportLab: they often emit raw markup and mojibake for non-Latin text. For Korean/Japanese/Chinese, follow the skill's CJK font guidance and do not ship tofu boxes. Short answers/snippets/explanations can stay inline.
67
+ For Markdown-to-PDF, use the bundled \`typeclaw-render-pdf\` skill; it is the supported path and renders headings, lists, and tables. Never hand-roll PDFs with jsPDF, pdfkit, canvas text dumps, raw headless-browser prints, or ReportLab: they often emit raw markup and mojibake for non-Latin text. For Korean/Japanese/Chinese, follow the skill's CJK font guidance and do not ship tofu boxes. Short answers, snippets, explanations, and routine reports can stay inline.
68
68
 
69
69
  ## Long-running and interactive shell work
70
70
 
@@ -5,6 +5,7 @@ import type { AssistantMessage } from '@mariozechner/pi-ai'
5
5
  import { SessionManager } from '@mariozechner/pi-coding-agent'
6
6
 
7
7
  import { createSession, renderTurnRoleAnchor, renderTurnTimeAnchor, type AgentSession } from '@/agent'
8
+ import { applyTurnThinkingLevel } from '@/agent/attention-escalation'
8
9
  import { forgetSharedLoopGuardTool } from '@/agent/plugin-tools'
9
10
  import { subscribeProviderErrors } from '@/agent/provider-error'
10
11
  import type { RestartHandoff } from '@/agent/restart-handoff'
@@ -573,6 +574,10 @@ type LiveSession = {
573
574
  key: ChannelKey
574
575
  keyId: string
575
576
  session: AgentSession
577
+ // The session's creation-time thinking level, captured once. A later escalated
578
+ // turn moves `session.thinkingLevel` to `high`, so the live getter can't be the
579
+ // reset target — this preserves the real default across the session's lifetime.
580
+ turnThinkingDefault: AgentSession['thinkingLevel']
576
581
  sessionId: string
577
582
  dispose: () => Promise<void>
578
583
  hooks: HookBus | undefined
@@ -1659,6 +1664,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1659
1664
  key,
1660
1665
  keyId,
1661
1666
  session: created.session,
1667
+ turnThinkingDefault: created.session.thinkingLevel,
1662
1668
  sessionId: created.sessionId,
1663
1669
  dispose: created.dispose,
1664
1670
  hooks: created.hooks,
@@ -2395,8 +2401,10 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2395
2401
  live.policyDeniedToolSendsThisTurn.clear()
2396
2402
  resetReviewTurn(live.sessionId)
2397
2403
  const isRealUserTurn = batch.length > 0
2398
- const retrievalContext = await fireSessionTurnStart(live, composeRetrievalQuery(batch))
2404
+ const retrievalQuery = composeRetrievalQuery(batch)
2405
+ const retrievalContext = await fireSessionTurnStart(live, retrievalQuery)
2399
2406
  const promptText = retrievalContext.results.length > 0 ? `${text}\n\n${retrievalContext.results}` : text
2407
+ applyTurnThinkingLevel(live.session, retrievalQuery, live.turnThinkingDefault)
2400
2408
  live.promptInFlight = true
2401
2409
  try {
2402
2410
  await live.session.prompt(promptText)
@@ -1116,7 +1116,7 @@ async function promptDiscordToken(): Promise<string> {
1116
1116
  [
1117
1117
  'https://discord.com/developers/applications',
1118
1118
  'New Application → Bot tab → Reset Token.',
1119
- 'Enable the MESSAGE CONTENT intent.',
1119
+ 'Under Privileged Gateway Intents, enable MESSAGE CONTENT and GUILD MEMBERS.',
1120
1120
  ].join('\n'),
1121
1121
  'Get a Discord bot token',
1122
1122
  )
package/src/cli/init.ts CHANGED
@@ -554,6 +554,7 @@ export async function collectWizardInputs(
554
554
  const decision = await prompts.confirmResumeCheckpoint(sanitized)
555
555
  if (decision === 'resume') {
556
556
  seedWizardState(state, sanitized)
557
+ step = resumeStep(state)
557
558
  } else {
558
559
  await checkpointStore.clear(cwd)
559
560
  }
@@ -1030,6 +1031,12 @@ function resolveModelOption(catalog: WizardState['catalog'], ref: string | undef
1030
1031
  return catalog.options.find((option) => option.ref === ref)
1031
1032
  }
1032
1033
 
1034
+ function resumeStep(state: WizardState): StepId {
1035
+ if (state.providerId !== undefined) return 'reuse-existing-key'
1036
+ if (state.vendorId !== undefined) return 'pick-provider-variant'
1037
+ return 'pick-vendor'
1038
+ }
1039
+
1033
1040
  function projectCheckpoint(cwd: string, state: WizardState): WizardAnswerCheckpointV1 {
1034
1041
  return checkpointFromSelections({
1035
1042
  cwd,
@@ -1429,7 +1436,7 @@ async function runDiscordFlow(): Promise<StepResult<CollectedInputs['channelSecr
1429
1436
  [
1430
1437
  'https://discord.com/developers/applications',
1431
1438
  'New Application → Bot tab → Reset Token.',
1432
- 'Enable the MESSAGE CONTENT intent.',
1439
+ 'Under Privileged Gateway Intents, enable MESSAGE CONTENT and GUILD MEMBERS.',
1433
1440
  ].join('\n'),
1434
1441
  'Get a Discord bot token',
1435
1442
  )
@@ -86,6 +86,7 @@ export const restartCommand = defineCommand({
86
86
  }
87
87
  startSpin.stop('Started.')
88
88
 
89
+ reportConfigWarnings(started.dockerfileWarnings)
89
90
  console.log(renderStartSuccess(started))
90
91
  },
91
92
  })
package/src/cli/start.ts CHANGED
@@ -77,6 +77,7 @@ export const startCommand = defineCommand({
77
77
  }
78
78
  s.stop(result.alreadyRunning ? 'Already running.' : 'Started.')
79
79
 
80
+ reportConfigWarnings(result.dockerfileWarnings)
80
81
  console.log(renderStartSuccess(result))
81
82
  },
82
83
  })
@@ -1200,14 +1200,18 @@ export function validateConfig(cwd: string, options: ValidateConfigOptions = {})
1200
1200
  const parsed = parseConfigJson(raw, { migrate: true, persistTarget: cwd })
1201
1201
  if (!parsed.ok) return parsed
1202
1202
 
1203
- const allowUnsafeAppend = process.env[ALLOW_UNSAFE_DOCKER_APPEND_ENV] === '1'
1203
+ // Append lines are advisory here — never fatal. The Dockerfile renderer
1204
+ // (renderCustomDockerfileLines) is the enforcement boundary: it STRIPS unsafe
1205
+ // lines so the container still comes up, and a bad line written by the
1206
+ // in-container agent can never brick `typeclaw start`. We surface the same
1207
+ // strip/warn decisions as warnings so the operator sees them pre-build.
1204
1208
  const warnings: string[] = []
1205
1209
  const appendLines = parsed.config.docker.file.append
1206
1210
  for (let i = 0; i < appendLines.length; i++) {
1207
1211
  const check = validateDockerfileAppendLine(appendLines[i]!)
1208
1212
  if (!check.ok) {
1209
- if (check.kind === 'semantic' && allowUnsafeAppend) continue
1210
- return { ok: false, reason: `docker.file.append[${i}] ${check.reason}` }
1213
+ warnings.push(`docker.file.append[${i}] will be stripped on start — ${check.reason}`)
1214
+ continue
1211
1215
  }
1212
1216
  if (check.warning) warnings.push(`docker.file.append[${i}] ${check.warning}`)
1213
1217
  }
@@ -1317,12 +1321,6 @@ export function validateMount(mount: Mount, cwd: string): ValidateConfigResult {
1317
1321
  return { ok: true }
1318
1322
  }
1319
1323
 
1320
- // Host env (not config) on purpose: an in-container agent can edit its own
1321
- // typeclaw.json but cannot set the env of the host `typeclaw start` that runs
1322
- // this gate, so it can never waive its own footgun. Only relaxes SEMANTIC
1323
- // blocks; structural blocks always fire (they break Dockerfile generation).
1324
- const ALLOW_UNSAFE_DOCKER_APPEND_ENV = 'TYPECLAW_ALLOW_UNSAFE_DOCKER_APPEND'
1325
-
1326
1324
  // FROM/ENTRYPOINT/CMD/MAINTAINER are intentionally excluded — see the
1327
1325
  // structural blocks in validateDockerfileAppendLine for why.
1328
1326
  const ALLOWED_APPEND_INSTRUCTIONS = new Set([
@@ -1450,9 +1448,13 @@ export function validateDockerfileAppendLine(line: string): AppendLineCheck {
1450
1448
  }
1451
1449
  }
1452
1450
  if (!ALLOWED_APPEND_INSTRUCTIONS.has(instruction)) {
1451
+ // Reason is intentionally input-free: this string is forwarded verbatim into
1452
+ // operator-facing warnings (classifyDockerfileAppend -> dockerfileWarnings),
1453
+ // and the offending token is user-controlled — echoing it could leak a
1454
+ // secret/token-like first word from a malformed line into start/doctor output.
1453
1455
  return {
1454
1456
  ok: false,
1455
- reason: `does not begin with a recognized Dockerfile instruction (got "${instruction}")`,
1457
+ reason: 'does not begin with a recognized Dockerfile instruction',
1456
1458
  kind: 'structural',
1457
1459
  }
1458
1460
  }
@@ -20,7 +20,7 @@ import {
20
20
  readInstalledTypeclawVersionFromAgent,
21
21
  } from '@/init/auto-upgrade'
22
22
  import { resolveBaseImageVersion } from '@/init/cli-version'
23
- import { buildDockerfile, DOCKERFILE } from '@/init/dockerfile'
23
+ import { buildDockerfile, classifyDockerfileAppend, DOCKERFILE } from '@/init/dockerfile'
24
24
  import { ensureDepsInstalled, type EnsureDepsResult } from '@/init/ensure-deps'
25
25
  import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
26
26
  import { refreshPackageJson } from '@/init/packagejson'
@@ -148,6 +148,10 @@ export type StartResult =
148
148
  // registry. Non-fatal by design: a typo'd or unpublished plugin warns
149
149
  // instead of blocking the launch.
150
150
  skippedPlugins: string[]
151
+ // Non-fatal warnings from docker.file.append: unsafe lines stripped from
152
+ // the generated Dockerfile, plus warn-but-allow lines (curl|bash, remote
153
+ // ADD). Surfaced by the CLI so a stripped line is never a silent no-op.
154
+ dockerfileWarnings: string[]
151
155
  }
152
156
  | { ok: false; reason: string }
153
157
 
@@ -485,6 +489,7 @@ export async function start({
485
489
  alreadyRunning: false,
486
490
  autoUpgrade: upgrade,
487
491
  skippedPlugins: pluginReconcile.skipped,
492
+ dockerfileWarnings: dockerfileRefresh.warnings,
488
493
  }
489
494
  } catch (error) {
490
495
  return { ok: false, reason: error instanceof Error ? error.message : String(error) }
@@ -701,18 +706,24 @@ async function resolvePublishHost(exec: DockerExec): Promise<string> {
701
706
  // the cheapest correct signal: the build context for `docker build` is the
702
707
  // Dockerfile itself, so equal contents definitionally produce an equivalent
703
708
  // image.
704
- export async function refreshDockerfile(cwd: string, opts: { buildKit?: boolean } = {}): Promise<{ changed: boolean }> {
709
+ export async function refreshDockerfile(
710
+ cwd: string,
711
+ opts: { buildKit?: boolean } = {},
712
+ ): Promise<{ changed: boolean; warnings: string[] }> {
705
713
  const cfg = await loadTypeclawConfig(cwd)
706
714
  const next = buildDockerfile(cfg.docker.file, {
707
715
  baseImageVersion: resolveBaseImageVersion(cwd),
708
716
  cjkFontsAuto: hostLocaleIsCjk(),
709
717
  buildKit: opts.buildKit,
710
718
  })
719
+ // Reuse the renderer's classifier so reported warnings match exactly what was
720
+ // stripped/kept in the Dockerfile above (single source of truth).
721
+ const { warnings } = classifyDockerfileAppend(cfg.docker.file.append)
711
722
  const path = join(cwd, DOCKERFILE)
712
723
  const prev = await readFile(path, 'utf8').catch(() => null)
713
- if (prev === next) return { changed: false }
724
+ if (prev === next) return { changed: false, warnings }
714
725
  await writeFile(path, next)
715
- return { changed: true }
726
+ return { changed: true, warnings }
716
727
  }
717
728
 
718
729
  // Builds the agent image with a seamless buildx->legacy fallback. The preferred
@@ -836,6 +847,7 @@ async function reportAlreadyRunning(exec: DockerExec, cwd: string, containerName
836
847
  }
837
848
  const tuiToken = await resolveTuiToken({ cwd, exec })
838
849
  const plan = await planStart({ cwd, hostPort, imageExists: true, forceBuild: false, tuiToken })
850
+ const { warnings: dockerfileWarnings } = classifyDockerfileAppend((await loadTypeclawConfig(cwd)).docker.file.append)
839
851
  return {
840
852
  ok: true,
841
853
  plan,
@@ -847,6 +859,7 @@ async function reportAlreadyRunning(exec: DockerExec, cwd: string, containerName
847
859
  alreadyRunning: true,
848
860
  autoUpgrade: { kind: 'skipped-already-running' },
849
861
  skippedPlugins: [],
862
+ dockerfileWarnings,
850
863
  }
851
864
  }
852
865
 
@@ -1,4 +1,5 @@
1
1
  import type { AgentSession } from '@/agent'
2
+ import { applyTurnThinkingLevel } from '@/agent/attention-escalation'
2
3
  import { promptWithFallback, resolveFallbackChain } from '@/agent/model-fallback'
3
4
  import type { SessionOrigin } from '@/agent/session-origin'
4
5
  import { getConfig } from '@/config'
@@ -235,6 +236,12 @@ async function runPromptOnce(
235
236
  if (created.hooks && turnEvent !== undefined) {
236
237
  await created.hooks.runSessionTurnStart({ ...turnEvent, userPrompt: job.prompt, retrievalContext })
237
238
  }
239
+ // Cron sessions are created fresh per fallback attempt, so the live getter
240
+ // is still the creation-time default here — safe to read without a separate
241
+ // captured field. The test-fake path omits `.session`; skip it then.
242
+ if (created.session !== undefined) {
243
+ applyTurnThinkingLevel(created.session, job.prompt, created.session.thinkingLevel)
244
+ }
238
245
  // Bridge the CronSession wrapper into the AgentSession surface the
239
246
  // fallback helper expects:
240
247
  // prompt → CronSession.prompt (wrapper that calls AgentSession.prompt
@@ -210,7 +210,19 @@ function configValid(): DoctorCheck {
210
210
  applies: (ctx) => ctx.hasAgentFolder,
211
211
  async run(ctx) {
212
212
  const result = validateConfig(ctx.cwd)
213
- if (result.ok) return { status: 'ok', message: 'typeclaw.json valid; mounts accessible' }
213
+ if (result.ok) {
214
+ if (result.warnings && result.warnings.length > 0) {
215
+ return {
216
+ status: 'warning',
217
+ message: `typeclaw.json valid; ${result.warnings.length} docker.file.append warning(s):\n${result.warnings.join('\n')}`,
218
+ fix: {
219
+ description:
220
+ 'Review the docker.file.append entries above; unsafe lines are stripped from the Dockerfile on start.',
221
+ },
222
+ }
223
+ }
224
+ return { status: 'ok', message: 'typeclaw.json valid; mounts accessible' }
225
+ }
214
226
  return {
215
227
  status: 'error',
216
228
  message: result.reason,
@@ -1,3 +1,4 @@
1
+ import { validateDockerfileAppendLine } from '@/config/config'
1
2
  import type { DockerfileConfig, DockerfileFeatureToggle } from '@/config/config'
2
3
  import {
3
4
  CLAUDE_CREDENTIALS_FILE_NAME,
@@ -1700,10 +1701,57 @@ RUN ${aptCacheMount(buildKit)}apt-get update \\
1700
1701
  `
1701
1702
  }
1702
1703
 
1704
+ // Render-time enforcement is the security boundary: this is the sole bottleneck
1705
+ // where docker.file.append entries reach the generated Dockerfile, so unsafe
1706
+ // lines are STRIPPED here (never spliced into the build) rather than blocking
1707
+ // `typeclaw start`. docker.file.append is untrusted input even when the
1708
+ // in-container agent writes it — a bad line (e.g. the decode-and-exec footgun
1709
+ // that bricked a real build) becomes ineffective, not fatal. Validation
1710
+ // elsewhere only warns; this is what guarantees the line never runs.
1703
1711
  function renderCustomDockerfileLines(lines: string[]): string {
1704
- if (lines.length === 0) return ''
1705
- return `# Custom lines from typeclaw.json#docker.file.append.
1706
- ${lines.join('\n')}
1712
+ const { kept, strippedCount } = classifyDockerfileAppend(lines)
1713
+ if (kept.length === 0 && strippedCount === 0) return ''
1714
+ // Durable, payload-free record so a stripped line isn't a silent no-op: the
1715
+ // operator sees the Dockerfile itself note the count (full reasons surface as
1716
+ // startup warnings). Never echo the stripped content — it may carry secrets.
1717
+ const strippedNote =
1718
+ strippedCount > 0
1719
+ ? `# typeclaw stripped ${strippedCount} unsafe docker.file.append line(s); see startup warnings and typeclaw.json.
1720
+ `
1721
+ : ''
1722
+ if (kept.length === 0) return strippedNote ? `${strippedNote}\n` : ''
1723
+ return `${strippedNote}# Custom lines from typeclaw.json#docker.file.append.
1724
+ ${kept.join('\n')}
1707
1725
 
1708
1726
  `
1709
1727
  }
1728
+
1729
+ export type DockerfileAppendClassification = {
1730
+ kept: string[]
1731
+ warnings: string[]
1732
+ strippedCount: number
1733
+ }
1734
+
1735
+ // Single source of truth for "what happens to each docker.file.append line",
1736
+ // reusing the config-layer classifier (validateDockerfileAppendLine). Both the
1737
+ // renderer (enforcement: drops unsafe lines) and refreshDockerfile (reporting:
1738
+ // surfaces warnings to the user) call this so the two never drift. Warn-but-
1739
+ // allow lines (curl|bash, remote ADD) are KEPT but produce a warning; structural
1740
+ // and semantic blocks are stripped with a warning.
1741
+ export function classifyDockerfileAppend(lines: string[]): DockerfileAppendClassification {
1742
+ const kept: string[] = []
1743
+ const warnings: string[] = []
1744
+ let strippedCount = 0
1745
+ for (let i = 0; i < lines.length; i++) {
1746
+ const line = lines[i]!
1747
+ const check = validateDockerfileAppendLine(line)
1748
+ if (!check.ok) {
1749
+ strippedCount++
1750
+ warnings.push(`docker.file.append[${i}] stripped — ${check.reason}`)
1751
+ continue
1752
+ }
1753
+ if (check.warning) warnings.push(`docker.file.append[${i}] ${check.warning}`)
1754
+ kept.push(line)
1755
+ }
1756
+ return { kept, warnings, strippedCount }
1757
+ }
@@ -5,6 +5,7 @@ import {
5
5
  type CreateSessionResult,
6
6
  type SessionOrigin,
7
7
  } from '@/agent'
8
+ import { applyTurnThinkingLevel } from '@/agent/attention-escalation'
8
9
  import type { ChannelRouter } from '@/channels/router'
9
10
  import type { McpManager } from '@/mcp'
10
11
  import type { PermissionService } from '@/permissions'
@@ -431,6 +432,7 @@ export async function runPromptForCommand(args: {
431
432
  retrievalContext.results.length > 0
432
433
  ? `${renderTurnTimeAnchor()}\n\n${args.text}\n\n${retrievalContext.results}`
433
434
  : `${renderTurnTimeAnchor()}\n\n${args.text}`
435
+ applyTurnThinkingLevel(session, args.text, session.thinkingLevel)
434
436
  try {
435
437
  await session.prompt(turnText)
436
438
  } finally {
@@ -8,6 +8,7 @@ import {
8
8
  type CreateSessionOptions,
9
9
  type CreateSessionResult,
10
10
  } from '@/agent'
11
+ import { applyTurnThinkingLevel } from '@/agent/attention-escalation'
11
12
  import { runPluginDoctorChecks, runPluginDoctorFix } from '@/agent/doctor'
12
13
  import type { LiveSessionRegistry } from '@/agent/live-sessions'
13
14
  import type { LiveSubagentRegistry } from '@/agent/live-subagents'
@@ -174,6 +175,11 @@ type QueuedPrompt = {
174
175
 
175
176
  type SessionState = {
176
177
  session: AgentSession
178
+ // The session's creation-time thinking level, captured once. An escalated turn
179
+ // moves `session.thinkingLevel` to `high`, so neither turn-driving path (drain
180
+ // loop, no-stream fallback) can use the live getter as the reset target — both
181
+ // read this captured default instead.
182
+ turnThinkingDefault: AgentSession['thinkingLevel']
177
183
  sessionFileId: string
178
184
  origin: SessionOrigin
179
185
  sessionManager: { getSessionFile: () => string | undefined } | undefined
@@ -518,6 +524,7 @@ export function createServer({
518
524
 
519
525
  const state: SessionState = {
520
526
  session,
527
+ turnThinkingDefault: session.thinkingLevel,
521
528
  sessionFileId,
522
529
  origin,
523
530
  sessionManager,
@@ -782,6 +789,7 @@ export function createServer({
782
789
  retrievalContext.results.length > 0
783
790
  ? `${renderTurnTimeAnchor()}\n\n${msg.text}\n\n${retrievalContext.results}`
784
791
  : `${renderTurnTimeAnchor()}\n\n${msg.text}`
792
+ applyTurnThinkingLevel(state.session, msg.text, state.turnThinkingDefault)
785
793
  await state.session.prompt(turnText)
786
794
  send(ws, doneMessage(state))
787
795
  } catch (err) {
@@ -1086,6 +1094,7 @@ async function drain(
1086
1094
  retrievalContext.results.length > 0
1087
1095
  ? `${renderTurnTimeAnchor()}\n\n${item.text}\n\n${retrievalContext.results}`
1088
1096
  : `${renderTurnTimeAnchor()}\n\n${item.text}`
1097
+ applyTurnThinkingLevel(state.session, item.text, state.turnThinkingDefault)
1089
1098
  await state.session.prompt(turnText)
1090
1099
  send(ws, doneMessage(state))
1091
1100
  } catch (err) {