typeclaw 0.37.0 → 0.37.2

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
@@ -4,7 +4,7 @@
4
4
  <img src="./docs/public/typeclaw.png" alt="TypeClaw logo" width="240" />
5
5
  </p>
6
6
 
7
- > A TypeScript-native, Bun-powered, Docker-friendly general-purpose agent runtime.
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.
8
8
 
9
9
  ## Why?
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.37.0",
3
+ "version": "0.37.2",
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
 
@@ -1,10 +1,11 @@
1
1
  import type { ToolDefinition } from '@mariozechner/pi-coding-agent'
2
2
  import type { z } from 'zod'
3
3
 
4
+ import type { PermissionService } from '@/permissions'
4
5
  import type { HookBus } from '@/plugin'
5
6
  import type { Stream, Unsubscribe } from '@/stream'
6
7
 
7
- import { type AgentSession, createSession } from './index'
8
+ import { type AgentSession, createSession, type PluginSessionWiring } from './index'
8
9
  import { subscribeProviderErrors } from './provider-error'
9
10
  import type { SubagentBashPolicy } from './reviewer-bash-policy'
10
11
  import type { SessionOrigin } from './session-origin'
@@ -143,6 +144,21 @@ export type CreateSessionForSubagentOptions = {
143
144
  parentSessionId?: string
144
145
  spawnedByRole?: string
145
146
  spawnedByOrigin?: SessionOrigin
147
+ // Plugin hook wiring for the subagent's tools. When present, the subagent's
148
+ // builtin bash/read/edit/write run through the plugin `tool.before`/`tool.after`
149
+ // hooks (security guards AND github-cli-auth GitHub-token injection) exactly
150
+ // like the main and plugin-subagent sessions. Without it, the builtin tools run
151
+ // raw (the prior behavior) — so standalone/test callers stay unaffected. The
152
+ // production runtime always supplies it (src/run/index.ts) so a generic
153
+ // task-spawned subagent's `git push`/`gh` gets a minted token instead of
154
+ // failing with "could not read Username".
155
+ plugins?: PluginSessionWiring
156
+ // The role/permission service that drives builtin-bash sandboxing. It MUST be
157
+ // forwarded alongside `plugins`: buildBuiltinPiToolOverrides only applies
158
+ // applyBashSandbox / applyTmpPathRedirect when `permissions` is present, so
159
+ // wiring hooks without permissions would inject the GitHub token yet leave the
160
+ // sandbox OFF — strictly weaker than the plugin-subagent branch this matches.
161
+ permissions?: PermissionService
146
162
  }
147
163
  export type CreateSessionForSubagent = (
148
164
  subagent: Subagent<any>,
@@ -161,6 +177,8 @@ export const defaultCreateSessionForSubagent: CreateSessionForSubagent = (subage
161
177
  },
162
178
  ...(subagent.tools ? { tools: subagent.tools } : {}),
163
179
  customTools: subagent.customTools ?? [],
180
+ ...(options?.plugins !== undefined ? { plugins: options.plugins } : {}),
181
+ ...(options?.permissions !== undefined ? { permissions: options.permissions } : {}),
164
182
  ...(subagent.profile !== undefined ? { profile: subagent.profile } : {}),
165
183
  ...(subagent.toolResultBudget !== undefined ? { toolResultBudget: subagent.toolResultBudget } : {}),
166
184
  ...(subagent.bashPolicy !== undefined ? { bashPolicy: subagent.bashPolicy } : {}),
@@ -3358,23 +3358,6 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3358
3358
  live.skippedTurn = null
3359
3359
  logger.info(`[channels] ${live.keyId} skip_contested_by_send recovering reply`)
3360
3360
  }
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
3361
  const postEmptyTurnFallback = async (cause: string): Promise<void> => {
3379
3362
  logger.warn(`[channels] ${live.keyId} empty_turn_fallback cause=${cause}`)
3380
3363
  const result = await send(
@@ -3392,6 +3375,49 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3392
3375
  }
3393
3376
  }
3394
3377
 
3378
+ // A send landed this turn, but the model may have posted a `continue: true`
3379
+ // progress reply, kept working, then ENDED with its final answer as plain
3380
+ // prose — never calling a channel tool again. The terminal-reply abort fires
3381
+ // only for a `channel_reply` WITHOUT `continue: true`, so that `stopReason:
3382
+ // 'stop'` text leaf is left undelivered and unguarded (the false-receipt
3383
+ // guard is github-only). The discriminator is leaf IDENTITY: only when the
3384
+ // turn-end `stop` leaf is a DIFFERENT entry than the one in place at the last
3385
+ // send did the model produce fresh post-reply prose. A leaf unchanged since
3386
+ // the send is narration the model emitted with/before the reply that already
3387
+ // landed — suppress it, as before.
3388
+ if (live.successfulChannelSends > successfulSendsBeforePrompt) {
3389
+ maybeNudgeContinuationWillingness(live)
3390
+ const trailing = recoverableAssistantText(live.session)
3391
+ if (trailing === null || trailing.source !== 'leaf') {
3392
+ // A `continue: true` status reply landed, then the turn stranded on an
3393
+ // unanswered `toolUse` (the post-tool follow-up never produced an
3394
+ // assistant message — aborted loop / cancelled stream). The promised
3395
+ // work never finished, so the user is left with a bare "checking now…"
3396
+ // and nothing after it. Re-prompt the same logical turn so the model
3397
+ // completes its investigation and actually replies, instead of ending
3398
+ // in silence. On retry-exhaustion post the fallback rather than
3399
+ // returning silently — a retry turn that re-sends a status and re-strands
3400
+ // on the same no-prose shape must not deadair the user. Any postable
3401
+ // pre-tool/mid-turn prose is suppressed here as before (it was narration
3402
+ // that accompanied the already-landed reply); only the no-prose strand
3403
+ // gets a retry-or-fallback.
3404
+ if (leafIsStrandedToolUse(live.session) && live.currentTurnAuthorId !== null) {
3405
+ if (live.emptyTurnRetries < MAX_EMPTY_TURN_RETRIES) {
3406
+ live.emptyTurnRetries++
3407
+ logger.warn(
3408
+ `[channels] ${live.keyId} empty_turn_retry attempt=${live.emptyTurnRetries}/${MAX_EMPTY_TURN_RETRIES} ` +
3409
+ `cause=stranded_toolUse_after_send`,
3410
+ )
3411
+ live.pendingSystemReminders.push(EMPTY_TURN_RETRY_NUDGE)
3412
+ } else {
3413
+ await postEmptyTurnFallback('stranded_toolUse_retries_exhausted')
3414
+ }
3415
+ }
3416
+ return
3417
+ }
3418
+ if (live.session.sessionManager.getLeafEntry()?.id === live.lastSendLeafId) return
3419
+ }
3420
+
3395
3421
  let candidate = recoverableAssistantText(live.session)
3396
3422
  // A `length` leaf is recovered ONLY when stripping leaked `<think>…</think>`
3397
3423
  // spans actually removed something AND leaves a postable reply. The removal
@@ -4961,6 +4987,40 @@ function assistantLeafStopReason(session: AgentSession): 'length' | 'error' | 'a
4961
4987
  return undefined
4962
4988
  }
4963
4989
 
4990
+ // True when the branch ends on an UNANSWERED `toolUse` that left NO postable
4991
+ // prose — the model called a tool and the upstream pi-agent-core post-tool
4992
+ // follow-up never produced an assistant message (the loop was aborted, or the
4993
+ // follow-up stream cancelled). Two leaf shapes carry this signature: the leaf
4994
+ // IS a `toolUse` assistant, or the leaf is a `toolResult` whose nearest
4995
+ // assistant ancestor (reached before any user message) is `toolUse`. The
4996
+ // no-prose requirement is the discriminator from a model that narrated a reply
4997
+ // alongside its tool call and DID land a real send this turn (that trailing
4998
+ // `toolUse` is delivered narration, not a stranded promise — leave it alone).
4999
+ // Keys on the model having INTENDED to keep working with nothing yet said; used
5000
+ // to re-prompt a turn that strands mid-work after a `continue: true` status
5001
+ // reply instead of ending in silence.
5002
+ function leafIsStrandedToolUse(session: AgentSession): boolean {
5003
+ const leaf = session.sessionManager.getLeafEntry()
5004
+ if (!leaf || leaf.type !== 'message') return false
5005
+ if (leaf.message.role === 'assistant') {
5006
+ return leaf.message.stopReason === 'toolUse' && visibleAssistantText(leaf.message).trim() === ''
5007
+ }
5008
+ if (leaf.message.role !== 'toolResult') return false
5009
+ let cursor: { parentId: string | null } | undefined = leaf
5010
+ for (let depth = 0; depth < 32 && cursor?.parentId; depth++) {
5011
+ const parent = session.sessionManager.getEntry(cursor.parentId)
5012
+ if (!parent) return false
5013
+ if (parent.type === 'message') {
5014
+ if (parent.message.role === 'assistant') {
5015
+ return parent.message.stopReason === 'toolUse' && visibleAssistantText(parent.message).trim() === ''
5016
+ }
5017
+ if (parent.message.role === 'user') return false
5018
+ }
5019
+ cursor = parent
5020
+ }
5021
+ return false
5022
+ }
5023
+
4964
5024
  function visibleAssistantText(message: AssistantMessage): string {
4965
5025
  return message.content
4966
5026
  .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
@@ -1,6 +1,18 @@
1
1
  import { randomBytes } from 'node:crypto'
2
2
 
3
- import { cancel, confirm, intro, isCancel, log, note, password, select, spinner, text } from '@clack/prompts'
3
+ import {
4
+ autocomplete,
5
+ cancel,
6
+ confirm,
7
+ intro,
8
+ isCancel,
9
+ log,
10
+ note,
11
+ password,
12
+ select,
13
+ spinner,
14
+ text,
15
+ } from '@clack/prompts'
4
16
  import { defineCommand } from 'citty'
5
17
 
6
18
  import {
@@ -54,6 +66,7 @@ import {
54
66
  type KeyValidationResult,
55
67
  } from '@/init/validate-api-key'
56
68
 
69
+ import { fuzzyMatch } from './fuzzy-filter'
57
70
  import { buildOAuthCallbacks } from './oauth-callbacks'
58
71
  import { CANCEL_SYMBOL, promptPrivateKeyPem } from './prompt-pem'
59
72
  import {
@@ -1083,8 +1096,10 @@ async function pickVendor(
1083
1096
  initial: KnownProviderVendorId | undefined,
1084
1097
  ): Promise<StepResult<KnownProviderVendorId>> {
1085
1098
  const vendors = uniqueVendors(options)
1086
- const choice = await select({
1099
+ const choice = await autocomplete({
1087
1100
  message: 'Pick an LLM provider',
1101
+ placeholder: 'Type to search…',
1102
+ filter: fuzzyMatch,
1088
1103
  options: vendors.map((id) => ({
1089
1104
  value: id,
1090
1105
  label: KNOWN_PROVIDER_VENDORS[id].name,
@@ -1104,8 +1119,10 @@ async function pickProviderVariant(
1104
1119
  const variants = providersForVendorInCatalog(vendorId, options)
1105
1120
  if (variants.length === 0) throw new Error(`Internal error: vendor ${vendorId} has no providers in the catalog`)
1106
1121
  if (variants.length === 1) return autoValue(variants[0]!)
1107
- const choice = await select<KnownProviderId>({
1122
+ const choice = await autocomplete<KnownProviderId>({
1108
1123
  message: `Pick a ${KNOWN_PROVIDER_VENDORS[vendorId].name} option`,
1124
+ placeholder: 'Type to search…',
1125
+ filter: fuzzyMatch,
1109
1126
  options: variants.map((id) => {
1110
1127
  const hint = variantHint(vendorId, id)
1111
1128
  return hint !== undefined
@@ -1128,8 +1145,10 @@ async function pickModelForProvider(
1128
1145
  // distributive conditional type, so a large KnownModelRef union explodes into
1129
1146
  // a per-literal option union that no longer accepts `value: ref`. The runtime
1130
1147
  // value is the ref string and is re-narrowed via `candidates.find` below.
1131
- const choice = await select<string>({
1148
+ const choice = await autocomplete<string>({
1132
1149
  message: `Pick a ${KNOWN_PROVIDERS[providerId].name} model`,
1150
+ placeholder: 'Type to search…',
1151
+ filter: fuzzyMatch,
1133
1152
  options: candidates.map((o) => ({
1134
1153
  value: o.ref,
1135
1154
  label: formatModelLabel(o),
@@ -1173,8 +1192,10 @@ async function pickVisionVendor(
1173
1192
  log.warn('No vision-capable models available; skipping vision profile.')
1174
1193
  return autoValue('skip')
1175
1194
  }
1176
- const choice = await select<KnownProviderVendorId | 'skip'>({
1195
+ const choice = await autocomplete<KnownProviderVendorId | 'skip'>({
1177
1196
  message: 'Your model is text-only. Pick a provider for the `vision` profile (used for image input)',
1197
+ placeholder: 'Type to search…',
1198
+ filter: fuzzyMatch,
1178
1199
  options: [
1179
1200
  ...vendors.map((id) => ({
1180
1201
  value: id as KnownProviderVendorId | 'skip',
@@ -1204,8 +1225,10 @@ async function pickVisionModel(
1204
1225
  ): Promise<StepResult<ModelOption>> {
1205
1226
  const candidates = sortRecommendedFirst(options.filter((o) => o.providerId === providerId))
1206
1227
  // select<string> for the same distributive-Option reason as pickModelForProvider.
1207
- const choice = await select<string>({
1228
+ const choice = await autocomplete<string>({
1208
1229
  message: `Pick a vision-capable ${KNOWN_PROVIDERS[providerId].name} model`,
1230
+ placeholder: 'Type to search…',
1231
+ filter: fuzzyMatch,
1209
1232
  options: candidates.map((o) => ({
1210
1233
  value: o.ref,
1211
1234
  label: formatModelLabel(o),
package/src/cli/model.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { cancel, intro, isCancel, log, select } from '@clack/prompts'
1
+ import { autocomplete, cancel, intro, isCancel, log, select } from '@clack/prompts'
2
2
  import { defineCommand } from 'citty'
3
3
 
4
4
  import type { CustomModelMeta } from '@/config'
@@ -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
 
@@ -248,8 +249,10 @@ async function pickModelRef(cwd: string): Promise<PickedModelRef> {
248
249
  // assignability. Values are ref strings (+ the sentinel) and stay correct
249
250
  // at runtime — the sentinel check and `return choice` below are unaffected.
250
251
  const modelOptions = await listCredentialedModelOptions(refs)
251
- const choice = await select<string>({
252
+ const choice = await autocomplete<string>({
252
253
  message: 'Pick a model',
254
+ placeholder: 'Type to search…',
255
+ filter: fuzzyMatch,
253
256
  options: [
254
257
  ...modelOptions.map((option) => ({
255
258
  value: option.ref,
@@ -1,4 +1,4 @@
1
- import { cancel, intro, isCancel, log, password, select } from '@clack/prompts'
1
+ import { autocomplete, cancel, intro, isCancel, log, password, select } from '@clack/prompts'
2
2
  import { defineCommand } from 'citty'
3
3
 
4
4
  import {
@@ -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
 
@@ -282,8 +283,10 @@ async function resolveProviderForAdd(input: string | undefined): Promise<KnownPr
282
283
 
283
284
  async function pickVendorToAdd(): Promise<KnownProviderVendorId> {
284
285
  const vendorIds = listKnownProviderVendorIds()
285
- const choice = await select<KnownProviderVendorId>({
286
+ const choice = await autocomplete<KnownProviderVendorId>({
286
287
  message: 'Pick a provider to add',
288
+ placeholder: 'Type to search…',
289
+ filter: fuzzyMatch,
287
290
  options: vendorIds.map((id) => ({
288
291
  value: id,
289
292
  label: KNOWN_PROVIDER_VENDORS[id].name,
@@ -301,8 +304,10 @@ async function pickVendorToAdd(): Promise<KnownProviderVendorId> {
301
304
  async function pickVariantToAdd(vendorId: KnownProviderVendorId): Promise<KnownProviderId> {
302
305
  const variants = providerIdsForVendor(vendorId)
303
306
  if (variants.length === 1) return variants[0]!
304
- const choice = await select<KnownProviderId>({
307
+ const choice = await autocomplete<KnownProviderId>({
305
308
  message: `Pick a ${KNOWN_PROVIDER_VENDORS[vendorId].name} option`,
309
+ placeholder: 'Type to search…',
310
+ filter: fuzzyMatch,
306
311
  options: variants.map((id) => {
307
312
  const hint = variantHint(vendorId, id)
308
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
 
package/src/run/index.ts CHANGED
@@ -439,7 +439,30 @@ export async function startAgent({
439
439
  : {}),
440
440
  }
441
441
  }
442
- return defaultCreateSessionForSubagent(subagent, subagentOptions)
442
+ // Non-plugin (built-in) subagents — general/explore/scout/memory-logger/
443
+ // dreaming and anything spawned through the generic task path. They used to
444
+ // run with NO plugin tool.before/tool.after coverage, so their bash skipped
445
+ // the security guards AND the github-cli-auth GitHub-token injection — a
446
+ // generic subagent's `git push` got no minted token and died with "could
447
+ // not read Username" even when a GitHub App was configured. Thread the same
448
+ // hook bus the plugin-subagent branch uses, against a freshly allocated
449
+ // subagent session id (never the parent's, so hooks/audit/permission
450
+ // attribution stay per-session).
451
+ const sessionManager = SessionManager.create(cwd, sessionFactory.sessionDir())
452
+ return defaultCreateSessionForSubagent(subagent, {
453
+ ...subagentOptions,
454
+ plugins: {
455
+ registry: snap.registry,
456
+ hooks: snap.hooks,
457
+ sessionId: sessionManager.getSessionId(),
458
+ agentDir: cwd,
459
+ },
460
+ // Pass permissions alongside plugins (same as the plugin-subagent branch
461
+ // at line 384): without it the builtin-bash sandbox (applyBashSandbox /
462
+ // applyTmpPathRedirect) stays off and the subagent would get the injected
463
+ // token but no role-derived sandboxing.
464
+ permissions: pluginsLoaded.permissions,
465
+ })
443
466
  }
444
467
 
445
468
  const subagentConsumer = createSubagentConsumer({