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 +1 -1
- package/package.json +2 -1
- package/src/agent/index.ts +9 -1
- package/src/agent/proactive-next-step-nudge.ts +11 -0
- package/src/agent/session-origin.ts +21 -0
- package/src/agent/subagents.ts +19 -1
- package/src/channels/router.ts +77 -17
- package/src/cli/fuzzy-filter.ts +32 -0
- package/src/cli/init.ts +29 -6
- package/src/cli/model.ts +5 -2
- package/src/cli/provider.ts +8 -3
- package/src/config/providers.ts +5 -2
- package/src/run/index.ts +24 -1
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
|
-
>
|
|
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.
|
|
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"
|
package/src/agent/index.ts
CHANGED
|
@@ -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
|
|
package/src/agent/subagents.ts
CHANGED
|
@@ -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 } : {}),
|
package/src/channels/router.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
package/src/cli/provider.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
package/src/config/providers.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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({
|