typeclaw 0.37.1 → 0.37.3

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/README.md CHANGED
@@ -1,10 +1,11 @@
1
1
  # TypeClaw
2
2
 
3
3
  <p align="center">
4
- <img src="./docs/public/typeclaw.png" alt="TypeClaw logo" width="240" />
4
+ <img src="./docs/public/typeclaw-transparent.png" alt="TypeClaw logo" width="240" />
5
5
  </p>
6
6
 
7
- > The agent for perfectionists — crafted in every detail. It behaves in your team's chat and gets sharper the longer it runs. Sandboxed and self-managing.
7
+ <h3 align="center">The agent for perfectionists</h3>
8
+ <p align="center">Crafted in every detail – it behaves in your team's chat and<br />gets sharper the longer it runs. Sandboxed and self-managing.</p>
8
9
 
9
10
  ## Why?
10
11
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.37.1",
3
+ "version": "0.37.3",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -37,6 +37,7 @@
37
37
  "format:check": "oxfmt --check .",
38
38
  "check": "bun run typecheck && bun run lint && bun run format:check",
39
39
  "test": "bun test --parallel",
40
+ "dev:docs": "cd docs && bun run dev",
40
41
  "generate:schema": "bun run scripts/generate-schema.ts",
41
42
  "debug:prompt": "bun run scripts/dump-system-prompt.ts",
42
43
  "postinstall": "bun run scripts/generate-schema.ts"
@@ -15,7 +15,7 @@ import { loadMemory } from '@/bundled-plugins/memory/load-memory'
15
15
  import type { ChannelRouter } from '@/channels/router'
16
16
  import type { ReactionRef } from '@/channels/types'
17
17
  import { getConfig, resolveModel, resolveProfile } from '@/config'
18
- import { defaultThinkingLevelForRef, providerForModelRef, type ModelRef } from '@/config/providers'
18
+ import { defaultThinkingLevelForRef, isOpenAiFamilyRef, providerForModelRef, type ModelRef } from '@/config/providers'
19
19
  import { renderMcpCatalog } from '@/mcp/catalog'
20
20
  import type { McpManager } from '@/mcp/manager'
21
21
  import { createMcpDispatcherTools, MCP_DISPATCHER_TOOL_NAMES } from '@/mcp/tools'
@@ -47,6 +47,7 @@ import {
47
47
  wrapSystemTool,
48
48
  zodToToolParameters,
49
49
  } from './plugin-tools'
50
+ import { PROACTIVE_NEXT_STEP_NUDGE } from './proactive-next-step-nudge'
50
51
  import { createReloadTool } from './reload-tool'
51
52
  import type { RestartHandoffOrigin } from './restart-handoff'
52
53
  import type { SubagentBashPolicy } from './reviewer-bash-policy'
@@ -277,6 +278,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
277
278
  ...(options.mcpManager !== undefined ? { mcpManager: options.mcpManager } : {}),
278
279
  ...(options.subagentRegistry !== undefined ? { subagentRegistry: options.subagentRegistry } : {}),
279
280
  ...(options.suppressSystemMemory !== undefined ? { suppressSystemMemory: options.suppressSystemMemory } : {}),
281
+ ...(isOpenAiFamilyRef(activeRef) ? { proactiveNextStepNudge: true } : {}),
280
282
  })
281
283
 
282
284
  const getOrigin: () => SessionOrigin | undefined =
@@ -957,6 +959,7 @@ export type CreateResourceLoaderOptions = {
957
959
  // from `memory.vector.enabled` — vector is restart-required, so the boot
958
960
  // snapshot is coherent with the per-turn injection decision.
959
961
  suppressSystemMemory?: boolean
962
+ proactiveNextStepNudge?: boolean
960
963
  }
961
964
 
962
965
  // Origins where the operator-facing DEFAULT_SYSTEM_PROMPT, git-nudge, and the
@@ -1020,6 +1023,7 @@ export type SystemPromptComposition = {
1020
1023
  roleContext?: SessionRoleContext
1021
1024
  mcpCatalog?: string
1022
1025
  gitNudge: string
1026
+ proactiveNextStepNudge?: string
1023
1027
  memorySection: string
1024
1028
  }
1025
1029
 
@@ -1065,6 +1069,9 @@ export function composeSystemPrompt(parts: SystemPromptComposition): string {
1065
1069
  if (parts.gitNudge !== '') {
1066
1070
  prompt = `${prompt}\n\n${parts.gitNudge}`
1067
1071
  }
1072
+ if (parts.proactiveNextStepNudge !== undefined && parts.proactiveNextStepNudge !== '') {
1073
+ prompt = `${prompt}\n\n${parts.proactiveNextStepNudge}`
1074
+ }
1068
1075
  if (parts.memorySection !== '') {
1069
1076
  prompt = `${prompt}\n\n${parts.memorySection}`
1070
1077
  }
@@ -1164,6 +1171,7 @@ export async function createResourceLoader(options: CreateResourceLoaderOptions
1164
1171
  ? { mcpCatalog: renderMcpCatalog(options.mcpManager.listServers()) }
1165
1172
  : {}),
1166
1173
  gitNudge,
1174
+ ...(options.proactiveNextStepNudge === true ? { proactiveNextStepNudge: PROACTIVE_NEXT_STEP_NUDGE } : {}),
1167
1175
  memorySection,
1168
1176
  })
1169
1177
 
@@ -0,0 +1,11 @@
1
+ export const PROACTIVE_NEXT_STEP_NUDGE_TITLE = '## Proactive and requested next-step guidance'
2
+
3
+ // GPT-only prompt text is intentionally absent from scripts/dump-system-prompt.ts
4
+ // token accounting because the dump tooling renders with non-GPT placeholders.
5
+ export const PROACTIVE_NEXT_STEP_NUDGE = [
6
+ PROACTIVE_NEXT_STEP_NUDGE_TITLE,
7
+ '',
8
+ 'GPT/OpenAI-family behavior nudge: when the user asks for work and a reasonable or necessary next step is obvious, do not ask for permission or confirmation before doing it. Do the next step when it makes sense, especially when it is necessary to complete the task well. Avoid empty optional follow-up CTAs such as “if you want, I can also …”; either take the useful next action or end with the completed result.',
9
+ '',
10
+ 'When the user explicitly asks for suggestions, options, alternatives, or what to do next, answer that request directly with concrete next-step suggestions instead of treating suggestions as an unwanted follow-up CTA.',
11
+ ].join('\n')
@@ -384,6 +384,27 @@ function renderChannelOrigin(
384
384
  )
385
385
  }
386
386
 
387
+ // Discord renders no GFM tables — a raw `| a | b |` block shows as literal
388
+ // pipes. The discord-bot adapter rewrites BARE pipe tables into aligned
389
+ // inline-code rows for readability, but it skips any table inside a ``` /
390
+ // ~~~ fence (a fenced table is literal text by CommonMark). Models that have
391
+ // "learned" Discord mangles tables defensively wrap them in a fence, which is
392
+ // exactly what disables the auto-conversion — so the table renders ragged
393
+ // anyway. Tell the model to emit tables bare and let the adapter format them.
394
+ if (origin.adapter === 'discord-bot') {
395
+ lines.push(
396
+ '',
397
+ '**Emit Markdown tables as bare `| a | b |` blocks — never inside a code',
398
+ 'fence.** Discord does not render Markdown tables, so this session',
399
+ 'auto-reformats a bare pipe table (a `|`-row followed by a `|---|`',
400
+ 'alignment row) into aligned, readable columns before it sends. That',
401
+ 'reformatting only fires on raw Markdown: the moment you wrap the table in',
402
+ 'a ``` or ~~~ fence it is treated as literal text and lands as ragged pipes.',
403
+ 'So write the table directly in your reply with no surrounding fence. Use',
404
+ 'fences only for actual code or output you want shown verbatim.',
405
+ )
406
+ }
407
+
387
408
  const conversationLine = renderConversationLine(origin)
388
409
  if (conversationLine !== null) lines.push('', conversationLine)
389
410
 
@@ -48,8 +48,20 @@ const EN_PHRASES: readonly string[] = [
48
48
  'looking into it now',
49
49
  'working on it now',
50
50
  'on it now',
51
+ "i'm on it",
51
52
  'give me a moment',
52
53
  'give me a sec',
54
+ // Parity additions for common first-person-future acks: "investigate / look up
55
+ // / pull up" are work-verb siblings of the "look into / dig in" entries above,
56
+ // and "lemme" is the contracted "let me" that chat models routinely emit.
57
+ "i'll investigate",
58
+ "i'll look it up",
59
+ "i'll pull that up",
60
+ "i'll pull it up",
61
+ 'let me pull',
62
+ 'lemme check',
63
+ 'lemme look',
64
+ 'lemme take a look',
53
65
  ]
54
66
 
55
67
  // Korean: -ㄹ게요 / -겠습니다 future-volitional endings on check/look/continue/
@@ -81,6 +93,27 @@ const KO_PHRASES: readonly string[] = [
81
93
  '잠시만요',
82
94
  '잠깐만요',
83
95
  '곧 알려',
96
+ // Bare first-person-volitional verb endings: the -ㄹ게요/-겠습니다 ending is
97
+ // self-directed regardless of the preceding adverb, so the "바로 …" prefix in
98
+ // the entries above is not load-bearing. "볼게요" alone (and "먼저/한번/지금 볼게요"
99
+ // by substring) is the exact production miss — the ack "…먼저 볼게요" did not
100
+ // match because only the "바로 볼게요" compound was listed. Common work verbs
101
+ // (검토/조회/찾아/알아/처리) in the same volitional form join here for parity with
102
+ // "확인/살펴" above; "볼게여" is the casual -여 variant seen in chat.
103
+ '볼게요',
104
+ '볼게여',
105
+ '확인할게여',
106
+ '검토할게요',
107
+ '검토해볼게요',
108
+ '검토하겠습니다',
109
+ '조회해볼게요',
110
+ '조회하겠습니다',
111
+ '찾아볼게요',
112
+ '찾아보겠습니다',
113
+ '알아볼게요',
114
+ '알아보겠습니다',
115
+ '처리할게요',
116
+ '처리하겠습니다',
84
117
  ]
85
118
 
86
119
  // The remaining languages mirror the precision-first selection above: every
@@ -106,6 +139,12 @@ const ES_PHRASES: readonly string[] = [
106
139
  'déjame comprobar',
107
140
  'déjame verificar',
108
141
  'déjame mirar',
142
+ 'voy a echar un vistazo',
143
+ 'déjame echar un vistazo',
144
+ 'ahora lo reviso',
145
+ 'ahora reviso',
146
+ 'ahora lo verifico',
147
+ 'ahora mismo lo reviso',
109
148
  'lo reviso enseguida',
110
149
  'lo verifico enseguida',
111
150
  'enseguida lo reviso',
@@ -123,10 +162,15 @@ const FR_PHRASES: readonly string[] = [
123
162
  'je vais poursuivre',
124
163
  'je vais voir',
125
164
  'je vais contrôler',
165
+ 'je vais creuser',
166
+ 'je vais jeter un œil',
126
167
  'laisse-moi vérifier',
127
168
  'laisse-moi regarder',
169
+ 'laisse-moi jeter un œil',
128
170
  'je vérifie tout de suite',
129
171
  'je regarde tout de suite',
172
+ 'je regarde ça tout de suite',
173
+ 'je regarde ça',
130
174
  'un instant',
131
175
  'donne-moi un instant',
132
176
  'donne-moi une seconde',
@@ -137,12 +181,16 @@ const IT_PHRASES: readonly string[] = [
137
181
  'vado a controllare',
138
182
  'vado a verificare',
139
183
  'vado a guardare',
184
+ "vado a dare un'occhiata",
140
185
  'fammi controllare',
141
186
  'fammi verificare',
142
187
  'fammi guardare',
188
+ "fammi dare un'occhiata",
189
+ "do un'occhiata",
143
190
  'controllo subito',
144
191
  'verifico subito',
145
192
  'continuo subito',
193
+ 'guardo subito',
146
194
  'un momento',
147
195
  'dammi un momento',
148
196
  'dammi un secondo',
@@ -156,9 +204,11 @@ const PT_PHRASES: readonly string[] = [
156
204
  'vou olhar',
157
205
  'vou continuar',
158
206
  'vou prosseguir',
207
+ 'vou dar uma olhada',
159
208
  'deixa eu verificar',
160
209
  'deixa eu conferir',
161
210
  'deixa eu olhar',
211
+ 'deixa eu dar uma olhada',
162
212
  'verifico já',
163
213
  'já verifico',
164
214
  'um momento',
@@ -175,8 +225,13 @@ const DE_PHRASES: readonly string[] = [
175
225
  'ich werde fortfahren',
176
226
  'lass mich prüfen',
177
227
  'lass mich nachsehen',
228
+ 'lass mich schauen',
178
229
  'ich schaue gleich',
230
+ 'ich schaue mir das an',
231
+ 'ich schaue mir das mal an',
179
232
  'ich prüfe gleich',
233
+ 'ich prüfe das gleich',
234
+ 'ich sehe gleich nach',
180
235
  'gleich prüfen',
181
236
  'gleich überprüfen',
182
237
  'gleich nachsehen',
@@ -194,6 +249,8 @@ const RU_PHRASES: readonly string[] = [
194
249
  'я продолжу',
195
250
  'продолжу проверку',
196
251
  'сейчас посмотрю',
252
+ 'дай мне проверить',
253
+ 'дайте мне проверить',
197
254
  'дайте мне минуту',
198
255
  'одну секунду',
199
256
  'минутку',
@@ -214,6 +271,10 @@ const ZH_PHRASES: readonly string[] = [
214
271
  '我马上确认',
215
272
  '我马上检查',
216
273
  '我马上看',
274
+ '让我看看',
275
+ '让我查一下',
276
+ '让我确认一下',
277
+ '让我检查一下',
217
278
  '稍等一下',
218
279
  '我看一下',
219
280
  ]
@@ -265,6 +326,9 @@ const TR_PHRASES: readonly string[] = [
265
326
  'kontrol edeceğim',
266
327
  'kontrol ediyorum',
267
328
  'bakacağım',
329
+ 'bir bakayım',
330
+ 'bir kontrol edeyim',
331
+ 'kontrol edeyim',
268
332
  'inceleyeceğim',
269
333
  'devam edeceğim',
270
334
  'hemen kontrol ediyorum',
@@ -279,6 +279,27 @@ export const WILLINGNESS_NUDGE = [
279
279
  '',
280
280
  '---',
281
281
  ].join('\n')
282
+ // Injected when a `channel_send` ack tripped continuation-willingness, the model
283
+ // did fresh work after it, then ended on an EMPTY `stop` leaf — the answer was
284
+ // computed but never sent (the Kimi/Fireworks empty-completion flake). Distinct
285
+ // from WILLINGNESS_NUDGE: that path is a `channel_reply` that ended the turn and
286
+ // needs `continue: true`; this path is a `channel_send` (which never ends the
287
+ // turn) whose follow-up degenerated, so the model just needs to emit the reply it
288
+ // already worked out. Shares MAX_WILLINGNESS_NUDGES so a turn can't double-nudge.
289
+ export const SEND_WILLINGNESS_NUDGE = [
290
+ '---',
291
+ '**[SYSTEM MESSAGE — not from a human]**',
292
+ '',
293
+ 'You said you would keep working this turn and did the work, but the turn ended',
294
+ 'without sending the result — nothing reached the channel after your last',
295
+ 'message. This is an automated signal from the channel router, not a message',
296
+ 'from anyone in the chat. **Do not acknowledge or reply to this notice itself.**',
297
+ '',
298
+ 'Send the answer you just worked out now via your channel send tool. If you',
299
+ 'genuinely have nothing to report, reply with `NO_REPLY`.',
300
+ '',
301
+ '---',
302
+ ].join('\n')
282
303
  // Rolling window for outbound send-rate telemetry. 5s matches Discord's
283
304
  // rate-limit shape (5 msg / 5 s / channel) and comfortably covers Slack's
284
305
  // 1 msg/s sustained. The window is observational; exceeding the burst
@@ -3358,23 +3379,6 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3358
3379
  live.skippedTurn = null
3359
3380
  logger.info(`[channels] ${live.keyId} skip_contested_by_send recovering reply`)
3360
3381
  }
3361
- // A send landed this turn, but the model may have posted a `continue: true`
3362
- // progress reply, kept working, then ENDED with its final answer as plain
3363
- // prose — never calling a channel tool again. The terminal-reply abort fires
3364
- // only for a `channel_reply` WITHOUT `continue: true`, so that `stopReason:
3365
- // 'stop'` text leaf is left undelivered and unguarded (the false-receipt
3366
- // guard is github-only). The discriminator is leaf IDENTITY: only when the
3367
- // turn-end `stop` leaf is a DIFFERENT entry than the one in place at the last
3368
- // send did the model produce fresh post-reply prose. A leaf unchanged since
3369
- // the send is narration the model emitted with/before the reply that already
3370
- // landed — suppress it, as before.
3371
- if (live.successfulChannelSends > successfulSendsBeforePrompt) {
3372
- maybeNudgeContinuationWillingness(live)
3373
- const trailing = recoverableAssistantText(live.session)
3374
- if (trailing === null || trailing.source !== 'leaf') return
3375
- if (live.session.sessionManager.getLeafEntry()?.id === live.lastSendLeafId) return
3376
- }
3377
-
3378
3382
  const postEmptyTurnFallback = async (cause: string): Promise<void> => {
3379
3383
  logger.warn(`[channels] ${live.keyId} empty_turn_fallback cause=${cause}`)
3380
3384
  const result = await send(
@@ -3392,6 +3396,86 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3392
3396
  }
3393
3397
  }
3394
3398
 
3399
+ // A send landed this turn, but the model may have posted a `continue: true`
3400
+ // progress reply, kept working, then ENDED with its final answer as plain
3401
+ // prose — never calling a channel tool again. The terminal-reply abort fires
3402
+ // only for a `channel_reply` WITHOUT `continue: true`, so that `stopReason:
3403
+ // 'stop'` text leaf is left undelivered and unguarded (the false-receipt
3404
+ // guard is github-only). The discriminator is leaf IDENTITY: only when the
3405
+ // turn-end `stop` leaf is a DIFFERENT entry than the one in place at the last
3406
+ // send did the model produce fresh post-reply prose. A leaf unchanged since
3407
+ // the send is narration the model emitted with/before the reply that already
3408
+ // landed — suppress it, as before.
3409
+ if (live.successfulChannelSends > successfulSendsBeforePrompt) {
3410
+ maybeNudgeContinuationWillingness(live)
3411
+
3412
+ // A `channel_send` ack that promised to keep working, fresh post-ack work,
3413
+ // then an EMPTY `stop` leaf: the model computed the answer in its reasoning
3414
+ // / tool results but never sent it (the Kimi/Fireworks empty-completion
3415
+ // flake). `maybeNudgeContinuationWillingness` above can't catch this — it
3416
+ // reads `lastTerminalReplyAbort`, which only a `channel_reply` sets;
3417
+ // `channel_send` keeps the turn alive and stamps nothing. And the
3418
+ // stranded-toolUse retry below requires `source !== 'leaf'`, but an empty
3419
+ // `stop` leaf recovers as `source: 'leaf'`, so this shape would otherwise
3420
+ // fall straight through to the `endsWithNoReplySignal('')` → `no_reply`
3421
+ // classification. Discriminator (all on existing state, zero false positives
3422
+ // measured across the session corpus): a send landed AND the just-sent text
3423
+ // trips the precision-tuned willingness detector AND the turn-end leaf is a
3424
+ // FRESH empty `stop` (different entry than the ack's leaf — so the model did
3425
+ // post-ack work, not an ack-then-await-user stop). Bounded by
3426
+ // MAX_WILLINGNESS_NUDGES (shared with the reply path); on exhaustion post the
3427
+ // fallback rather than going silent, mirroring the stranded-toolUse path.
3428
+ // Gated on an empty `promptQueue` (like maybeNudgeContinuationWillingness): a
3429
+ // real inbound that coalesced into the just-finished prompt will be answered
3430
+ // by the next drain pass, and drain() splices pending reminders into that
3431
+ // batch — so injecting a stale recovery nudge would prepend it to a live user
3432
+ // message. Skip the nudge AND the fallback in that case and let the trailing
3433
+ // recovery below run; the queued inbound supersedes this turn's silence.
3434
+ if (live.promptQueue.length === 0 && live.currentTurnAuthorId !== null && isEmptyStopAfterWillingnessAck(live)) {
3435
+ if (live.willingnessNudges < MAX_WILLINGNESS_NUDGES) {
3436
+ live.willingnessNudges++
3437
+ logger.warn(
3438
+ `[channels] ${live.keyId} send_willingness_nudge attempt=${live.willingnessNudges}/${MAX_WILLINGNESS_NUDGES} ` +
3439
+ `cause=empty_stop_after_send_ack`,
3440
+ )
3441
+ live.pendingSystemReminders.push(SEND_WILLINGNESS_NUDGE)
3442
+ } else {
3443
+ await postEmptyTurnFallback('empty_stop_after_send_ack_nudges_exhausted')
3444
+ }
3445
+ return
3446
+ }
3447
+
3448
+ const trailing = recoverableAssistantText(live.session)
3449
+ if (trailing === null || trailing.source !== 'leaf') {
3450
+ // A `continue: true` status reply landed, then the turn stranded on an
3451
+ // unanswered `toolUse` (the post-tool follow-up never produced an
3452
+ // assistant message — aborted loop / cancelled stream). The promised
3453
+ // work never finished, so the user is left with a bare "checking now…"
3454
+ // and nothing after it. Re-prompt the same logical turn so the model
3455
+ // completes its investigation and actually replies, instead of ending
3456
+ // in silence. On retry-exhaustion post the fallback rather than
3457
+ // returning silently — a retry turn that re-sends a status and re-strands
3458
+ // on the same no-prose shape must not deadair the user. Any postable
3459
+ // pre-tool/mid-turn prose is suppressed here as before (it was narration
3460
+ // that accompanied the already-landed reply); only the no-prose strand
3461
+ // gets a retry-or-fallback.
3462
+ if (leafIsStrandedToolUse(live.session) && live.currentTurnAuthorId !== null) {
3463
+ if (live.emptyTurnRetries < MAX_EMPTY_TURN_RETRIES) {
3464
+ live.emptyTurnRetries++
3465
+ logger.warn(
3466
+ `[channels] ${live.keyId} empty_turn_retry attempt=${live.emptyTurnRetries}/${MAX_EMPTY_TURN_RETRIES} ` +
3467
+ `cause=stranded_toolUse_after_send`,
3468
+ )
3469
+ live.pendingSystemReminders.push(EMPTY_TURN_RETRY_NUDGE)
3470
+ } else {
3471
+ await postEmptyTurnFallback('stranded_toolUse_retries_exhausted')
3472
+ }
3473
+ }
3474
+ return
3475
+ }
3476
+ if (live.session.sessionManager.getLeafEntry()?.id === live.lastSendLeafId) return
3477
+ }
3478
+
3395
3479
  let candidate = recoverableAssistantText(live.session)
3396
3480
  // A `length` leaf is recovered ONLY when stripping leaked `<think>…</think>`
3397
3481
  // spans actually removed something AND leaves a postable reply. The removal
@@ -4961,6 +5045,58 @@ function assistantLeafStopReason(session: AgentSession): 'length' | 'error' | 'a
4961
5045
  return undefined
4962
5046
  }
4963
5047
 
5048
+ // True when the branch ends on an UNANSWERED `toolUse` that left NO postable
5049
+ // prose — the model called a tool and the upstream pi-agent-core post-tool
5050
+ // follow-up never produced an assistant message (the loop was aborted, or the
5051
+ // follow-up stream cancelled). Two leaf shapes carry this signature: the leaf
5052
+ // IS a `toolUse` assistant, or the leaf is a `toolResult` whose nearest
5053
+ // assistant ancestor (reached before any user message) is `toolUse`. The
5054
+ // no-prose requirement is the discriminator from a model that narrated a reply
5055
+ // alongside its tool call and DID land a real send this turn (that trailing
5056
+ // `toolUse` is delivered narration, not a stranded promise — leave it alone).
5057
+ // Keys on the model having INTENDED to keep working with nothing yet said; used
5058
+ // to re-prompt a turn that strands mid-work after a `continue: true` status
5059
+ // reply instead of ending in silence.
5060
+ function leafIsStrandedToolUse(session: AgentSession): boolean {
5061
+ const leaf = session.sessionManager.getLeafEntry()
5062
+ if (!leaf || leaf.type !== 'message') return false
5063
+ if (leaf.message.role === 'assistant') {
5064
+ return leaf.message.stopReason === 'toolUse' && visibleAssistantText(leaf.message).trim() === ''
5065
+ }
5066
+ if (leaf.message.role !== 'toolResult') return false
5067
+ let cursor: { parentId: string | null } | undefined = leaf
5068
+ for (let depth = 0; depth < 32 && cursor?.parentId; depth++) {
5069
+ const parent = session.sessionManager.getEntry(cursor.parentId)
5070
+ if (!parent) return false
5071
+ if (parent.type === 'message') {
5072
+ if (parent.message.role === 'assistant') {
5073
+ return parent.message.stopReason === 'toolUse' && visibleAssistantText(parent.message).trim() === ''
5074
+ }
5075
+ if (parent.message.role === 'user') return false
5076
+ }
5077
+ cursor = parent
5078
+ }
5079
+ return false
5080
+ }
5081
+
5082
+ // True when the turn-end leaf is a FRESH empty `stop` (no text, no tool call,
5083
+ // distinct from the leaf in place at the last successful send) AND the most
5084
+ // recent send to this target was a continuation-willingness ack. This is the
5085
+ // `channel_send` analogue of the `channel_reply` willingness path: the model
5086
+ // acked "I'll check…", did post-ack work, then the follow-up came back as a
5087
+ // clean empty completion that would otherwise be read as a deliberate `NO_REPLY`.
5088
+ // The fresh-leaf check (`!== lastSendLeafId`) is what separates this degeneration
5089
+ // from a legitimate ack-then-stop where the model meant to wait for the user.
5090
+ function isEmptyStopAfterWillingnessAck(live: LiveSession): boolean {
5091
+ const leaf = live.session.sessionManager.getLeafEntry()
5092
+ if (!leaf || leaf.type !== 'message' || leaf.message.role !== 'assistant') return false
5093
+ if (leaf.message.stopReason !== 'stop') return false
5094
+ if (hasToolCall(leaf.message) || visibleAssistantText(leaf.message).trim() !== '') return false
5095
+ if (leaf.id === live.lastSendLeafId) return false
5096
+ const ackText = live.lastSentText.get(consecutiveSendKey(live.key.chat, live.key.thread))
5097
+ return ackText !== undefined && detectContinuationWillingness(ackText)
5098
+ }
5099
+
4964
5100
  function visibleAssistantText(message: AssistantMessage): string {
4965
5101
  return message.content
4966
5102
  .filter((block) => block.type === 'text')
@@ -0,0 +1,32 @@
1
+ import type { Option } from '@clack/prompts'
2
+
3
+ type FuzzyFilter<Value> = (search: string, option: Option<Value>) => boolean
4
+
5
+ function optionHaystack<Value>(option: Option<Value>): string {
6
+ const label = option.label ?? String(option.value)
7
+ const hint = option.hint ?? ''
8
+ return `${label} ${String(option.value)} ${hint}`.toLowerCase()
9
+ }
10
+
11
+ function isSubsequence(query: string, haystack: string): boolean {
12
+ let i = 0
13
+ for (let j = 0; j < haystack.length && i < query.length; j++) {
14
+ if (haystack[j] === query[i]) i++
15
+ }
16
+ return i === query.length
17
+ }
18
+
19
+ // Splitting the query on whitespace lets "gpt 5.5" match "GPT-5.5 Turbo": each
20
+ // token is matched independently as a subsequence, so the "-" inside "GPT-5.5"
21
+ // no longer breaks the search the way a plain substring "gpt 5.5" would. Tokens
22
+ // are order-independent so "turbo gpt" finds "GPT Turbo" too.
23
+ export function fuzzyMatch<Value>(search: string, option: Option<Value>): boolean {
24
+ const tokens = search.toLowerCase().split(/\s+/).filter(Boolean)
25
+ if (tokens.length === 0) return true
26
+ const haystack = optionHaystack(option)
27
+ return tokens.every((token) => isSubsequence(token, haystack))
28
+ }
29
+
30
+ export const fuzzyFilter: FuzzyFilter<unknown> = fuzzyMatch
31
+
32
+ export type { FuzzyFilter }
package/src/cli/init.ts CHANGED
@@ -66,6 +66,7 @@ import {
66
66
  type KeyValidationResult,
67
67
  } from '@/init/validate-api-key'
68
68
 
69
+ import { fuzzyMatch } from './fuzzy-filter'
69
70
  import { buildOAuthCallbacks } from './oauth-callbacks'
70
71
  import { CANCEL_SYMBOL, promptPrivateKeyPem } from './prompt-pem'
71
72
  import {
@@ -1098,6 +1099,7 @@ async function pickVendor(
1098
1099
  const choice = await autocomplete({
1099
1100
  message: 'Pick an LLM provider',
1100
1101
  placeholder: 'Type to search…',
1102
+ filter: fuzzyMatch,
1101
1103
  options: vendors.map((id) => ({
1102
1104
  value: id,
1103
1105
  label: KNOWN_PROVIDER_VENDORS[id].name,
@@ -1120,6 +1122,7 @@ async function pickProviderVariant(
1120
1122
  const choice = await autocomplete<KnownProviderId>({
1121
1123
  message: `Pick a ${KNOWN_PROVIDER_VENDORS[vendorId].name} option`,
1122
1124
  placeholder: 'Type to search…',
1125
+ filter: fuzzyMatch,
1123
1126
  options: variants.map((id) => {
1124
1127
  const hint = variantHint(vendorId, id)
1125
1128
  return hint !== undefined
@@ -1145,6 +1148,7 @@ async function pickModelForProvider(
1145
1148
  const choice = await autocomplete<string>({
1146
1149
  message: `Pick a ${KNOWN_PROVIDERS[providerId].name} model`,
1147
1150
  placeholder: 'Type to search…',
1151
+ filter: fuzzyMatch,
1148
1152
  options: candidates.map((o) => ({
1149
1153
  value: o.ref,
1150
1154
  label: formatModelLabel(o),
@@ -1191,6 +1195,7 @@ async function pickVisionVendor(
1191
1195
  const choice = await autocomplete<KnownProviderVendorId | 'skip'>({
1192
1196
  message: 'Your model is text-only. Pick a provider for the `vision` profile (used for image input)',
1193
1197
  placeholder: 'Type to search…',
1198
+ filter: fuzzyMatch,
1194
1199
  options: [
1195
1200
  ...vendors.map((id) => ({
1196
1201
  value: id as KnownProviderVendorId | 'skip',
@@ -1223,6 +1228,7 @@ async function pickVisionModel(
1223
1228
  const choice = await autocomplete<string>({
1224
1229
  message: `Pick a vision-capable ${KNOWN_PROVIDERS[providerId].name} model`,
1225
1230
  placeholder: 'Type to search…',
1231
+ filter: fuzzyMatch,
1226
1232
  options: candidates.map((o) => ({
1227
1233
  value: o.ref,
1228
1234
  label: formatModelLabel(o),
package/src/cli/model.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  import { findAgentDir, isInitialized } from '@/init'
20
20
  import { customModelMetaFromOption, fetchModelOptions, type ModelOption } from '@/init/models-dev'
21
21
 
22
+ import { fuzzyMatch } from './fuzzy-filter'
22
23
  import { runProviderAddFlow } from './provider'
23
24
  import { c, done, errorLine } from './ui'
24
25
 
@@ -251,6 +252,7 @@ async function pickModelRef(cwd: string): Promise<PickedModelRef> {
251
252
  const choice = await autocomplete<string>({
252
253
  message: 'Pick a model',
253
254
  placeholder: 'Type to search…',
255
+ filter: fuzzyMatch,
254
256
  options: [
255
257
  ...modelOptions.map((option) => ({
256
258
  value: option.ref,
@@ -23,6 +23,7 @@ import {
23
23
  import { findAgentDir, isInitialized } from '@/init'
24
24
  import { makeOAuthLoginRunner } from '@/init/oauth-login'
25
25
 
26
+ import { fuzzyMatch } from './fuzzy-filter'
26
27
  import { buildOAuthCallbacks } from './oauth-callbacks'
27
28
  import { c, done, errorLine } from './ui'
28
29
 
@@ -285,6 +286,7 @@ async function pickVendorToAdd(): Promise<KnownProviderVendorId> {
285
286
  const choice = await autocomplete<KnownProviderVendorId>({
286
287
  message: 'Pick a provider to add',
287
288
  placeholder: 'Type to search…',
289
+ filter: fuzzyMatch,
288
290
  options: vendorIds.map((id) => ({
289
291
  value: id,
290
292
  label: KNOWN_PROVIDER_VENDORS[id].name,
@@ -305,6 +307,7 @@ async function pickVariantToAdd(vendorId: KnownProviderVendorId): Promise<KnownP
305
307
  const choice = await autocomplete<KnownProviderId>({
306
308
  message: `Pick a ${KNOWN_PROVIDER_VENDORS[vendorId].name} option`,
307
309
  placeholder: 'Type to search…',
310
+ filter: fuzzyMatch,
308
311
  options: variants.map((id) => {
309
312
  const hint = variantHint(vendorId, id)
310
313
  return hint !== undefined
@@ -991,9 +991,12 @@ function knownProviderForModelRef(ref: string): KnownProviderId | null {
991
991
  //
992
992
  // Anthropic, GLM, and Kimi don't share the padding behavior, so they keep the
993
993
  // SDK default.
994
+ export function isOpenAiFamilyRef(ref: KnownModelRef | ModelRef | string): boolean {
995
+ return vendorForProviderId(providerForModelRef(ref)) === 'openai'
996
+ }
997
+
994
998
  export function defaultThinkingLevelForRef(ref: KnownModelRef | ModelRef | string): 'low' | undefined {
995
- const providerId = providerForModelRef(ref)
996
- if (providerId === 'openai' || providerId === 'openai-codex') return 'low'
999
+ if (isOpenAiFamilyRef(ref)) return 'low'
997
1000
  return undefined
998
1001
  }
999
1002