typeclaw 0.2.0 → 0.3.1
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 +2 -1
- package/scripts/dump-system-prompt.ts +401 -0
- package/src/agent/index.ts +168 -28
- package/src/agent/provider-error.ts +44 -0
- package/src/agent/session-meta.ts +43 -0
- package/src/agent/subagents.ts +8 -0
- package/src/agent/system-prompt.ts +87 -35
- package/src/agent/tools/channel-send.ts +2 -3
- package/src/bundled-plugins/memory/README.md +8 -8
- package/src/bundled-plugins/memory/append-tool.ts +10 -7
- package/src/bundled-plugins/memory/citations.ts +45 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +30 -18
- package/src/bundled-plugins/memory/dreaming.ts +179 -48
- package/src/bundled-plugins/memory/load-memory.ts +15 -9
- package/src/bundled-plugins/memory/migration.ts +9 -8
- package/src/bundled-plugins/memory/stream-events.ts +30 -0
- package/src/channels/adapters/kakaotalk.ts +7 -6
- package/src/channels/router.ts +28 -2
- package/src/cli/model.ts +51 -19
- package/src/cli/provider.ts +38 -24
- package/src/cli/usage.ts +30 -2
- package/src/config/config.ts +15 -4
- package/src/config/models-mutation.ts +20 -1
- package/src/config/reloadable.ts +22 -4
- package/src/cron/consumer.ts +17 -1
- package/src/run/channel-session-factory.ts +2 -0
- package/src/run/index.ts +15 -1
- package/src/server/index.ts +8 -10
- package/src/skills/typeclaw-memory/SKILL.md +15 -15
- package/src/usage/aggregate.ts +30 -1
- package/src/usage/index.ts +3 -2
- package/src/usage/report.ts +103 -3
- package/src/usage/scan.ts +59 -4
package/src/cli/model.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import { cancel, intro, isCancel, select } from '@clack/prompts'
|
|
1
|
+
import { cancel, intro, isCancel, log, select } from '@clack/prompts'
|
|
2
2
|
import { defineCommand } from 'citty'
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
addProfile,
|
|
6
|
+
listModelProfiles,
|
|
7
|
+
listRegisteredModelRefs,
|
|
8
|
+
removeProfile,
|
|
9
|
+
setProfile,
|
|
10
|
+
} from '@/config/models-mutation'
|
|
5
11
|
import {
|
|
6
12
|
KNOWN_PROVIDERS,
|
|
7
13
|
listKnownModelRefs,
|
|
@@ -11,8 +17,11 @@ import {
|
|
|
11
17
|
} from '@/config/providers'
|
|
12
18
|
import { findAgentDir, isInitialized } from '@/init'
|
|
13
19
|
|
|
20
|
+
import { runProviderAddFlow } from './provider'
|
|
14
21
|
import { c, done, errorLine } from './ui'
|
|
15
22
|
|
|
23
|
+
const ADD_PROVIDER_SENTINEL = '__add-provider__'
|
|
24
|
+
|
|
16
25
|
const setSub = defineCommand({
|
|
17
26
|
meta: {
|
|
18
27
|
name: 'set',
|
|
@@ -38,7 +47,7 @@ const setSub = defineCommand({
|
|
|
38
47
|
async run({ args }) {
|
|
39
48
|
const cwd = ensureAgentDir()
|
|
40
49
|
const profile = args.profile ?? (await pickProfileName())
|
|
41
|
-
const ref = args.ref ?? (await pickModelRef())
|
|
50
|
+
const ref = args.ref ?? (await pickModelRef(cwd))
|
|
42
51
|
|
|
43
52
|
intro(`Setting model profile: ${profile} → ${ref}`)
|
|
44
53
|
|
|
@@ -78,7 +87,7 @@ const addSub = defineCommand({
|
|
|
78
87
|
},
|
|
79
88
|
async run({ args }) {
|
|
80
89
|
const cwd = ensureAgentDir()
|
|
81
|
-
const ref = args.ref ?? (await pickModelRef())
|
|
90
|
+
const ref = args.ref ?? (await pickModelRef(cwd))
|
|
82
91
|
|
|
83
92
|
intro(`Adding model profile: ${args.profile} → ${ref}`)
|
|
84
93
|
|
|
@@ -198,22 +207,45 @@ async function pickProfileName(): Promise<string> {
|
|
|
198
207
|
return choice
|
|
199
208
|
}
|
|
200
209
|
|
|
201
|
-
async function pickModelRef(): Promise<string> {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
210
|
+
async function pickModelRef(cwd: string): Promise<string> {
|
|
211
|
+
while (true) {
|
|
212
|
+
const refs = listRegisteredModelRefs(cwd)
|
|
213
|
+
if (refs.length === 0) {
|
|
214
|
+
log.info("No provider credentials found. Let's add one first.")
|
|
215
|
+
const added = await runProviderAddFlow(cwd, {})
|
|
216
|
+
if (!added.ok) {
|
|
217
|
+
console.error(errorLine(added.reason))
|
|
218
|
+
process.exit(1)
|
|
219
|
+
}
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
const choice = await select<KnownModelRef | typeof ADD_PROVIDER_SENTINEL>({
|
|
223
|
+
message: 'Pick a model',
|
|
224
|
+
options: [
|
|
225
|
+
...refs.map((ref) => ({
|
|
226
|
+
value: ref,
|
|
227
|
+
label: describeRef(ref),
|
|
228
|
+
hint: ref,
|
|
229
|
+
})),
|
|
230
|
+
{
|
|
231
|
+
value: ADD_PROVIDER_SENTINEL,
|
|
232
|
+
label: c.cyan('+ add provider'),
|
|
233
|
+
hint: 'configure a new provider',
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
initialValue: refs[0],
|
|
237
|
+
})
|
|
238
|
+
if (isCancel(choice)) {
|
|
239
|
+
cancel('Aborted.')
|
|
240
|
+
process.exit(0)
|
|
241
|
+
}
|
|
242
|
+
if (choice !== ADD_PROVIDER_SENTINEL) return choice
|
|
243
|
+
const added = await runProviderAddFlow(cwd, {})
|
|
244
|
+
if (!added.ok) {
|
|
245
|
+
console.error(errorLine(added.reason))
|
|
246
|
+
process.exit(1)
|
|
247
|
+
}
|
|
215
248
|
}
|
|
216
|
-
return choice
|
|
217
249
|
}
|
|
218
250
|
|
|
219
251
|
function describeRef(ref: KnownModelRef): string {
|
package/src/cli/provider.ts
CHANGED
|
@@ -48,37 +48,51 @@ const addSub = defineCommand({
|
|
|
48
48
|
},
|
|
49
49
|
async run({ args }) {
|
|
50
50
|
const cwd = ensureAgentDir()
|
|
51
|
-
const
|
|
52
|
-
const provider = KNOWN_PROVIDERS[providerId]
|
|
53
|
-
|
|
54
|
-
intro(`Adding provider: ${provider.name}`)
|
|
55
|
-
|
|
56
|
-
const method = await resolveAuthMethod(provider, args)
|
|
57
|
-
if (method === 'oauth') {
|
|
58
|
-
const result = await runOAuthLogin(cwd, providerId)
|
|
59
|
-
if (!result.ok) {
|
|
60
|
-
console.error(errorLine(`OAuth login failed: ${result.reason}`))
|
|
61
|
-
process.exit(1)
|
|
62
|
-
}
|
|
63
|
-
done({
|
|
64
|
-
title: c.green(`Logged in to ${provider.name}.`),
|
|
65
|
-
hints: nextStepHints({ credentialChanged: true }),
|
|
66
|
-
})
|
|
67
|
-
return
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const credential = await resolveApiKeyInputs(provider, args)
|
|
71
|
-
const result = addProvider(cwd, providerId, credential)
|
|
51
|
+
const result = await runProviderAddFlow(cwd, args)
|
|
72
52
|
if (!result.ok) {
|
|
73
53
|
console.error(errorLine(result.reason))
|
|
74
54
|
process.exit(1)
|
|
75
55
|
}
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
export type ProviderAddFlowArgs = {
|
|
60
|
+
provider?: string | undefined
|
|
61
|
+
key?: string | undefined
|
|
62
|
+
env?: string | undefined
|
|
63
|
+
oauth?: boolean | undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type ProviderAddFlowResult =
|
|
67
|
+
| { ok: true; providerId: KnownProviderId; method: 'api-key' | 'oauth' }
|
|
68
|
+
| { ok: false; reason: string }
|
|
69
|
+
|
|
70
|
+
export async function runProviderAddFlow(cwd: string, args: ProviderAddFlowArgs): Promise<ProviderAddFlowResult> {
|
|
71
|
+
const providerId = await resolveProviderForAdd(args.provider)
|
|
72
|
+
const provider = KNOWN_PROVIDERS[providerId]
|
|
73
|
+
|
|
74
|
+
intro(`Adding provider: ${provider.name}`)
|
|
75
|
+
|
|
76
|
+
const method = await resolveAuthMethod(provider, args)
|
|
77
|
+
if (method === 'oauth') {
|
|
78
|
+
const result = await runOAuthLogin(cwd, providerId)
|
|
79
|
+
if (!result.ok) return { ok: false, reason: `OAuth login failed: ${result.reason}` }
|
|
76
80
|
done({
|
|
77
|
-
title: c.green(`
|
|
81
|
+
title: c.green(`Logged in to ${provider.name}.`),
|
|
78
82
|
hints: nextStepHints({ credentialChanged: true }),
|
|
79
83
|
})
|
|
80
|
-
|
|
81
|
-
}
|
|
84
|
+
return { ok: true, providerId, method: 'oauth' }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const credential = await resolveApiKeyInputs(provider, args)
|
|
88
|
+
const result = addProvider(cwd, providerId, credential)
|
|
89
|
+
if (!result.ok) return { ok: false, reason: result.reason }
|
|
90
|
+
done({
|
|
91
|
+
title: c.green(`Added ${provider.name} credentials to secrets.json.`),
|
|
92
|
+
hints: nextStepHints({ credentialChanged: true }),
|
|
93
|
+
})
|
|
94
|
+
return { ok: true, providerId, method: 'api-key' }
|
|
95
|
+
}
|
|
82
96
|
|
|
83
97
|
const setSub = defineCommand({
|
|
84
98
|
meta: {
|
package/src/cli/usage.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { formatJson, formatReport } from '@/usage/report'
|
|
|
6
6
|
|
|
7
7
|
import { parseSince, parseUntil, USAGE_COMMON_ARGS } from './usage-args'
|
|
8
8
|
|
|
9
|
-
const SUBCOMMANDS = ['daily', 'session', 'models'] as const
|
|
9
|
+
const SUBCOMMANDS = ['daily', 'session', 'models', 'origin'] as const
|
|
10
10
|
type Subcommand = (typeof SUBCOMMANDS)[number]
|
|
11
11
|
type View = 'summary' | Subcommand
|
|
12
12
|
|
|
@@ -18,6 +18,13 @@ const COMMON_ARGS = {
|
|
|
18
18
|
},
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// Captured by the parent's `setup` hook (which citty runs BEFORE the matched
|
|
22
|
+
// subcommand's `run`, with the full parent-level argv parsed). Subcommands
|
|
23
|
+
// read this in their own `run` to recover global options like `--since` that
|
|
24
|
+
// appeared before the subcommand name. Single-instance CLI processes only —
|
|
25
|
+
// no concurrency.
|
|
26
|
+
let parentRunArgs: Record<string, unknown> | undefined
|
|
27
|
+
|
|
21
28
|
const subcommand = (view: View, description: string) =>
|
|
22
29
|
defineCommand({
|
|
23
30
|
meta: { name: view, description },
|
|
@@ -26,7 +33,7 @@ const subcommand = (view: View, description: string) =>
|
|
|
26
33
|
...(view === 'session' ? { limit: { type: 'string' as const, description: 'max sessions (default 20)' } } : {}),
|
|
27
34
|
},
|
|
28
35
|
async run({ args }) {
|
|
29
|
-
await emit(view, args)
|
|
36
|
+
await emit(view, mergeParentArgs(args))
|
|
30
37
|
},
|
|
31
38
|
})
|
|
32
39
|
|
|
@@ -36,10 +43,14 @@ export const usageCommand = defineCommand({
|
|
|
36
43
|
description: 'report LLM token usage and cost for this agent folder',
|
|
37
44
|
},
|
|
38
45
|
args: COMMON_ARGS,
|
|
46
|
+
setup({ args }) {
|
|
47
|
+
parentRunArgs = args as unknown as Record<string, unknown>
|
|
48
|
+
},
|
|
39
49
|
subCommands: {
|
|
40
50
|
daily: subcommand('daily', 'one row per calendar day'),
|
|
41
51
|
session: subcommand('session', 'top sessions by cost'),
|
|
42
52
|
models: subcommand('models', 'one row per provider/model'),
|
|
53
|
+
origin: subcommand('origin', 'one row per session origin (tui/cron/channel/subagent)'),
|
|
43
54
|
},
|
|
44
55
|
async run({ args }) {
|
|
45
56
|
// citty invokes both the matched subcommand's `run` and the parent's
|
|
@@ -50,6 +61,23 @@ export const usageCommand = defineCommand({
|
|
|
50
61
|
},
|
|
51
62
|
})
|
|
52
63
|
|
|
64
|
+
// citty's subcommand `run` only sees args that came AFTER the subcommand
|
|
65
|
+
// name (the child's rawArgs is pre-sliced), so `usage --since=X origin` would
|
|
66
|
+
// silently drop `--since` despite the help text advertising it as a global
|
|
67
|
+
// option. The parent's `setup` runs first with the full parent-level parse
|
|
68
|
+
// (which includes everything: global options + subcommand options merged),
|
|
69
|
+
// so we capture it there and merge it as a fallback under any explicitly-set
|
|
70
|
+
// child arg. Child-wins so `usage --since=A origin --since=B` still honours B.
|
|
71
|
+
function mergeParentArgs(childArgs: Record<string, unknown>): Record<string, unknown> {
|
|
72
|
+
if (parentRunArgs === undefined) return childArgs
|
|
73
|
+
const merged: Record<string, unknown> = { ...parentRunArgs }
|
|
74
|
+
for (const key of Object.keys(childArgs)) {
|
|
75
|
+
const v = childArgs[key]
|
|
76
|
+
if (v !== undefined && v !== '' && v !== false) merged[key] = v
|
|
77
|
+
}
|
|
78
|
+
return merged
|
|
79
|
+
}
|
|
80
|
+
|
|
53
81
|
async function emit(view: View, args: Record<string, unknown>): Promise<void> {
|
|
54
82
|
const cwdArg = typeof args.cwd === 'string' && args.cwd.length > 0 ? args.cwd : process.cwd()
|
|
55
83
|
const agentDir = findAgentDir(cwdArg) ?? cwdArg
|
package/src/config/config.ts
CHANGED
|
@@ -881,7 +881,16 @@ export type ValidateConfigResult = { ok: true } | { ok: false; reason: string }
|
|
|
881
881
|
// confusing path-sharing error (or, on some Linux setups, silently bind-mount
|
|
882
882
|
// an empty auto-created directory). First-failure reporting matches the
|
|
883
883
|
// schema-error path's shape; users fix one and re-run.
|
|
884
|
-
export
|
|
884
|
+
export type ValidateConfigOptions = {
|
|
885
|
+
// Skip the mount-path accessibility check. Host-side callers leave this
|
|
886
|
+
// false (the default) so missing mount directories surface as a precise
|
|
887
|
+
// pre-`docker run` error. Container-side callers (the reload registry)
|
|
888
|
+
// set it true because mount paths in typeclaw.json are host paths and
|
|
889
|
+
// don't resolve inside the container's filesystem.
|
|
890
|
+
skipMounts?: boolean
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
export function validateConfig(cwd: string, options: ValidateConfigOptions = {}): ValidateConfigResult {
|
|
885
894
|
let raw: string
|
|
886
895
|
try {
|
|
887
896
|
raw = readFileSync(join(cwd, CONFIG_FILE), 'utf8')
|
|
@@ -907,9 +916,11 @@ export function validateConfig(cwd: string): ValidateConfigResult {
|
|
|
907
916
|
return { ok: false, reason: `${CONFIG_FILE} is invalid: ${formatZodError(result.error)}` }
|
|
908
917
|
}
|
|
909
918
|
|
|
910
|
-
|
|
911
|
-
const
|
|
912
|
-
|
|
919
|
+
if (!options.skipMounts) {
|
|
920
|
+
for (const mount of result.data.mounts) {
|
|
921
|
+
const check = validateMount(mount, cwd)
|
|
922
|
+
if (!check.ok) return check
|
|
923
|
+
}
|
|
913
924
|
}
|
|
914
925
|
|
|
915
926
|
return { ok: true }
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
type KnownModelRef,
|
|
12
12
|
type KnownProviderId,
|
|
13
13
|
} from './providers'
|
|
14
|
-
import { isProviderConfigured } from './providers-mutation'
|
|
14
|
+
import { isProviderConfigured, listConfiguredProviders } from './providers-mutation'
|
|
15
15
|
|
|
16
16
|
const CONFIG_FILE = 'typeclaw.json'
|
|
17
17
|
|
|
@@ -51,6 +51,25 @@ export function listAvailableModelRefs(): KnownModelRef[] {
|
|
|
51
51
|
return listKnownModelRefs()
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// Subset of `listAvailableModelRefs()` filtered to providers with a usable
|
|
55
|
+
// credential in this agent folder — either a `secrets.json#providers.<id>`
|
|
56
|
+
// entry (api-key OR oauth) or a credential resolvable from the process env
|
|
57
|
+
// via the provider's canonical env-var name. Used by `typeclaw model set`'s
|
|
58
|
+
// interactive picker so users only see models they can actually run; the
|
|
59
|
+
// CLI surfaces an explicit "add provider" sentinel when the result is empty
|
|
60
|
+
// or when the user wants to wire a new one.
|
|
61
|
+
//
|
|
62
|
+
// Ordering preserves `listKnownModelRefs()` (provider-table declaration
|
|
63
|
+
// order, then per-provider model order) so the picker reads stably across
|
|
64
|
+
// invocations.
|
|
65
|
+
export function listRegisteredModelRefs(cwd: string, env: NodeJS.ProcessEnv = process.env): KnownModelRef[] {
|
|
66
|
+
const registered = new Set<KnownProviderId>()
|
|
67
|
+
for (const entry of listConfiguredProviders(cwd, env)) {
|
|
68
|
+
if (entry.known) registered.add(entry.id as KnownProviderId)
|
|
69
|
+
}
|
|
70
|
+
return listKnownModelRefs().filter((ref) => registered.has(providerForModelRef(ref)))
|
|
71
|
+
}
|
|
72
|
+
|
|
54
73
|
export function isKnownModelRef(value: string): value is KnownModelRef {
|
|
55
74
|
return (listKnownModelRefs() as ReadonlyArray<string>).includes(value)
|
|
56
75
|
}
|
package/src/config/reloadable.ts
CHANGED
|
@@ -11,24 +11,42 @@ export type CreateConfigReloadableOptions = {
|
|
|
11
11
|
// hand-edits) take effect without a container restart. `roles.<name>.permissions`
|
|
12
12
|
// changes still require a restart — see FIELD_EFFECTS in config.ts.
|
|
13
13
|
permissions?: PermissionService
|
|
14
|
+
// Skip the mount-path accessibility check inside validateConfig. Mount paths
|
|
15
|
+
// in typeclaw.json are host paths — they don't resolve inside the container,
|
|
16
|
+
// so the check would always fail on any agent that declares mounts. `mounts`
|
|
17
|
+
// is `restart-required` anyway, so reload never applies mount changes. Set
|
|
18
|
+
// this when wiring the reloadable from a container-stage context.
|
|
19
|
+
skipMountValidation?: boolean
|
|
14
20
|
}
|
|
15
21
|
|
|
16
|
-
export function createConfigReloadable({
|
|
22
|
+
export function createConfigReloadable({
|
|
23
|
+
cwd,
|
|
24
|
+
permissions,
|
|
25
|
+
skipMountValidation = false,
|
|
26
|
+
}: CreateConfigReloadableOptions): Reloadable {
|
|
17
27
|
return {
|
|
18
28
|
scope: 'config',
|
|
19
29
|
description: 'typeclaw.json runtime config',
|
|
20
|
-
reload: async () => doReload(cwd, permissions),
|
|
30
|
+
reload: async () => doReload(cwd, permissions, skipMountValidation),
|
|
21
31
|
}
|
|
22
32
|
}
|
|
23
33
|
|
|
24
|
-
async function doReload(
|
|
34
|
+
async function doReload(
|
|
35
|
+
cwd: string,
|
|
36
|
+
permissions: PermissionService | undefined,
|
|
37
|
+
skipMountValidation: boolean,
|
|
38
|
+
): Promise<ReloadResult> {
|
|
25
39
|
// Mount accessibility belongs to the validation surface, not loadConfigSync —
|
|
26
40
|
// validateConfig is the single gate that every host-side caller goes through.
|
|
27
41
|
// Run it before swapping the live config pointer so a mount that vanished
|
|
28
42
|
// between starts surfaces as a reload failure (`mounts` is restart-required
|
|
29
43
|
// anyway, so the user has to restart to pick up changes; better to flag the
|
|
30
44
|
// problem now than to let restart fail later).
|
|
31
|
-
|
|
45
|
+
//
|
|
46
|
+
// Container-side reload skips mount validation: mounts are host paths and
|
|
47
|
+
// statSync against them inside the container always fails. The host-side
|
|
48
|
+
// `start` / `restart` / doctor paths still gate on the full validateConfig.
|
|
49
|
+
const validated = validateConfig(cwd, { skipMounts: skipMountValidation })
|
|
32
50
|
if (!validated.ok) {
|
|
33
51
|
return { scope: 'config', ok: false, reason: validated.reason }
|
|
34
52
|
}
|
package/src/cron/consumer.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { AgentSession } from '@/agent'
|
|
2
|
+
import { subscribeProviderErrors } from '@/agent/provider-error'
|
|
1
3
|
import type { SessionOrigin } from '@/agent/session-origin'
|
|
2
4
|
import type { HookBus } from '@/plugin'
|
|
3
5
|
import type { Stream, Unsubscribe } from '@/stream'
|
|
@@ -20,6 +22,12 @@ export type CronSession = {
|
|
|
20
22
|
agentDir?: string
|
|
21
23
|
getTranscriptPath?: () => string | undefined
|
|
22
24
|
origin?: SessionOrigin
|
|
25
|
+
// Underlying agent session, exposed so the consumer can subscribe to
|
|
26
|
+
// `message_end` events and surface soft provider errors (billing, rate
|
|
27
|
+
// limit, network — pi-coding-agent encodes these in the assistant message
|
|
28
|
+
// instead of throwing, so the outer try/catch never sees them). Optional
|
|
29
|
+
// so existing test fakes that only need `prompt` keep working.
|
|
30
|
+
session?: AgentSession
|
|
23
31
|
}
|
|
24
32
|
|
|
25
33
|
export type CronConsumerLogger = {
|
|
@@ -72,7 +80,7 @@ export function createCronConsumer({
|
|
|
72
80
|
inFlight.add(job.id)
|
|
73
81
|
try {
|
|
74
82
|
if (job.kind === 'prompt') {
|
|
75
|
-
await runPrompt(job, createSessionForCron, stream)
|
|
83
|
+
await runPrompt(job, createSessionForCron, stream, logger)
|
|
76
84
|
} else {
|
|
77
85
|
await runExec(job, cwd)
|
|
78
86
|
}
|
|
@@ -98,6 +106,7 @@ async function runPrompt(
|
|
|
98
106
|
job: PromptJob,
|
|
99
107
|
createSessionForCron: (job: PromptJob) => Promise<CronSession>,
|
|
100
108
|
stream: Stream,
|
|
109
|
+
logger: CronConsumerLogger,
|
|
101
110
|
): Promise<void> {
|
|
102
111
|
if (job.subagent !== undefined) {
|
|
103
112
|
// Propagate the cron job's role and origin into the spawned subagent.
|
|
@@ -123,6 +132,12 @@ async function runPrompt(
|
|
|
123
132
|
return
|
|
124
133
|
}
|
|
125
134
|
const session = await createSessionForCron(job)
|
|
135
|
+
const unsubProviderErrors =
|
|
136
|
+
session.session !== undefined
|
|
137
|
+
? subscribeProviderErrors(session.session, (err) => {
|
|
138
|
+
logger.error(`[cron] ${job.id}: LLM call failed: ${err.message}`)
|
|
139
|
+
})
|
|
140
|
+
: null
|
|
126
141
|
const turnEvent =
|
|
127
142
|
session.hooks && session.sessionId !== undefined && session.agentDir !== undefined
|
|
128
143
|
? {
|
|
@@ -151,6 +166,7 @@ async function runPrompt(
|
|
|
151
166
|
})
|
|
152
167
|
}
|
|
153
168
|
} finally {
|
|
169
|
+
unsubProviderErrors?.()
|
|
154
170
|
if (session.hooks && session.sessionId !== undefined) {
|
|
155
171
|
await session.hooks.runSessionEnd({
|
|
156
172
|
sessionId: session.sessionId,
|
|
@@ -33,6 +33,7 @@ export type BuildChannelSessionFactoryDeps = {
|
|
|
33
33
|
// their inbound messages came from.
|
|
34
34
|
getChannelRouter: () => ChannelRouter
|
|
35
35
|
containerName?: string
|
|
36
|
+
runtimeVersion?: string
|
|
36
37
|
// When set, rehydrating a session JSONL caps oversized tool results in the
|
|
37
38
|
// file before pi-coding-agent reads it. `null` disables the load-time pass
|
|
38
39
|
// (tool-result-cap.enabled=false in config, or no plugin block at all).
|
|
@@ -105,6 +106,7 @@ export function buildChannelSessionFactory(deps: BuildChannelSessionFactoryDeps)
|
|
|
105
106
|
}
|
|
106
107
|
: {}),
|
|
107
108
|
...(deps.containerName !== undefined ? { containerName: deps.containerName } : {}),
|
|
109
|
+
...(deps.runtimeVersion !== undefined ? { runtimeVersion: deps.runtimeVersion } : {}),
|
|
108
110
|
...(deps.permissions !== undefined ? { permissions: deps.permissions } : {}),
|
|
109
111
|
})
|
|
110
112
|
|
package/src/run/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
loadCron as loadCronDefault,
|
|
25
25
|
type Scheduler,
|
|
26
26
|
} from '@/cron'
|
|
27
|
+
import { CLI_VERSION } from '@/init/cli-version'
|
|
27
28
|
import { loadPlugins, type LoadPluginsResult, pluginCronJobs, type PluginRegistry, summarizeLoaded } from '@/plugin'
|
|
28
29
|
import { createContainerBroker, publishForwardResult } from '@/portbroker'
|
|
29
30
|
import { ReloadRegistry } from '@/reload'
|
|
@@ -90,6 +91,7 @@ export async function startAgent({
|
|
|
90
91
|
// which is what we want, since there is no host daemon to honor it anyway.
|
|
91
92
|
const containerName = process.env.TYPECLAW_CONTAINER_NAME
|
|
92
93
|
const containerNameOpt = containerName !== undefined ? { containerName } : {}
|
|
94
|
+
const runtimeVersionOpt = { runtimeVersion: CLI_VERSION }
|
|
93
95
|
const tuiToken = process.env.TYPECLAW_TUI_TOKEN
|
|
94
96
|
const tuiTokenOpt = tuiToken !== undefined && tuiToken !== '' ? { tuiToken } : {}
|
|
95
97
|
|
|
@@ -103,7 +105,13 @@ export async function startAgent({
|
|
|
103
105
|
...(cwdConfig.roles !== undefined ? { roles: cwdConfig.roles } : {}),
|
|
104
106
|
})
|
|
105
107
|
|
|
106
|
-
reloadRegistry.register(
|
|
108
|
+
reloadRegistry.register(
|
|
109
|
+
createConfigReloadable({
|
|
110
|
+
cwd,
|
|
111
|
+
permissions: pluginsLoaded.permissions,
|
|
112
|
+
skipMountValidation: containerName !== undefined,
|
|
113
|
+
}),
|
|
114
|
+
)
|
|
107
115
|
const pluginRegistry = pluginsLoaded.registry
|
|
108
116
|
const pluginHooks = pluginsLoaded.hooks
|
|
109
117
|
|
|
@@ -155,6 +163,7 @@ export async function startAgent({
|
|
|
155
163
|
rehydrateCapOptions: resolveCapOptionsFromConfig(pluginConfigsByName['tool-result-cap']),
|
|
156
164
|
permissions: pluginsLoaded.permissions,
|
|
157
165
|
...containerNameOpt,
|
|
166
|
+
...runtimeVersionOpt,
|
|
158
167
|
}),
|
|
159
168
|
permissions: pluginsLoaded.permissions,
|
|
160
169
|
claimHandler: claimController.claimHandler,
|
|
@@ -198,6 +207,7 @@ export async function startAgent({
|
|
|
198
207
|
...(entry.pluginSubagent.toolResultBudget !== undefined
|
|
199
208
|
? { toolResultBudget: entry.pluginSubagent.toolResultBudget }
|
|
200
209
|
: {}),
|
|
210
|
+
...runtimeVersionOpt,
|
|
201
211
|
})
|
|
202
212
|
return {
|
|
203
213
|
...created,
|
|
@@ -267,6 +277,7 @@ export async function startAgent({
|
|
|
267
277
|
}
|
|
268
278
|
: {}),
|
|
269
279
|
...containerNameOpt,
|
|
280
|
+
...runtimeVersionOpt,
|
|
270
281
|
})
|
|
271
282
|
return {
|
|
272
283
|
prompt: (text) => session.prompt(text),
|
|
@@ -274,6 +285,7 @@ export async function startAgent({
|
|
|
274
285
|
sessionId,
|
|
275
286
|
agentDir: cwd,
|
|
276
287
|
origin: cronOrigin,
|
|
288
|
+
session,
|
|
277
289
|
...(snap.hasAnyPluginContent ? { hooks: snap.hooks } : {}),
|
|
278
290
|
getTranscriptPath: () => sessionManager.getSessionFile(),
|
|
279
291
|
}
|
|
@@ -316,6 +328,7 @@ export async function startAgent({
|
|
|
316
328
|
agentDir: cwd,
|
|
317
329
|
userPrompt: '',
|
|
318
330
|
payload,
|
|
331
|
+
onProviderError: (message) => console.error(`[subagent] ${name}: LLM call failed: ${message}`),
|
|
319
332
|
...(options?.parentSessionId !== undefined ? { parentSessionId: options.parentSessionId } : {}),
|
|
320
333
|
...(spawnedByRole !== undefined ? { spawnedByRole } : {}),
|
|
321
334
|
...(options?.spawnedByOrigin !== undefined ? { spawnedByOrigin: options.spawnedByOrigin } : {}),
|
|
@@ -363,6 +376,7 @@ export async function startAgent({
|
|
|
363
376
|
pluginRuntime,
|
|
364
377
|
claimController,
|
|
365
378
|
...containerNameOpt,
|
|
379
|
+
...runtimeVersionOpt,
|
|
366
380
|
...tuiTokenOpt,
|
|
367
381
|
...containerBrokerOpt,
|
|
368
382
|
}).start()
|
package/src/server/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type CreateSessionResult,
|
|
8
8
|
} from '@/agent'
|
|
9
9
|
import { runPluginDoctorChecks, runPluginDoctorFix } from '@/agent/doctor'
|
|
10
|
+
import { detectProviderError } from '@/agent/provider-error'
|
|
10
11
|
import type { SessionOrigin } from '@/agent/session-origin'
|
|
11
12
|
import type { ChannelRouter } from '@/channels/router'
|
|
12
13
|
import type { HookBus } from '@/plugin'
|
|
@@ -38,6 +39,7 @@ export type ServerOptions = {
|
|
|
38
39
|
agentDir?: string
|
|
39
40
|
pluginRuntime?: PluginRuntime
|
|
40
41
|
containerName?: string
|
|
42
|
+
runtimeVersion?: string
|
|
41
43
|
tuiToken?: string
|
|
42
44
|
// Optional in-process portbroker handler. When provided, requests to the
|
|
43
45
|
// /portbroker WS path are routed to it instead of being treated as TUI
|
|
@@ -108,6 +110,7 @@ export function createServer({
|
|
|
108
110
|
agentDir,
|
|
109
111
|
pluginRuntime,
|
|
110
112
|
containerName,
|
|
113
|
+
runtimeVersion,
|
|
111
114
|
tuiToken,
|
|
112
115
|
containerBroker,
|
|
113
116
|
logger = consoleLogger,
|
|
@@ -167,6 +170,7 @@ export function createServer({
|
|
|
167
170
|
...(channelRouter ? { channelRouter } : {}),
|
|
168
171
|
...(pluginsWiring ? { plugins: pluginsWiring } : {}),
|
|
169
172
|
...(containerName !== undefined ? { containerName } : {}),
|
|
173
|
+
...(runtimeVersion !== undefined ? { runtimeVersion } : {}),
|
|
170
174
|
})
|
|
171
175
|
const session = 'session' in result ? result.session : result
|
|
172
176
|
const dispose = 'session' in result && result.dispose ? result.dispose : async () => {}
|
|
@@ -455,16 +459,10 @@ function forwardSessionEvents(ws: Ws, session: AgentSession, logger: ServerLogge
|
|
|
455
459
|
}
|
|
456
460
|
|
|
457
461
|
function forwardAssistantError(ws: Ws, message: unknown, logger: ServerLogger, sessionFileId: string): void {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
// 'aborted' is fired when the user hits Escape — don't surface it as an
|
|
463
|
-
// error message because the TUI already shows abort feedback elsewhere.
|
|
464
|
-
if (m.stopReason === 'aborted') return
|
|
465
|
-
const text = typeof m.errorMessage === 'string' && m.errorMessage.length > 0 ? m.errorMessage : 'LLM call failed'
|
|
466
|
-
logger.error(`[server] ${sessionFileId}: LLM call failed: ${text}`)
|
|
467
|
-
send(ws, { type: 'error', message: text })
|
|
462
|
+
const detected = detectProviderError(message)
|
|
463
|
+
if (detected === null) return
|
|
464
|
+
logger.error(`[server] ${sessionFileId}: LLM call failed: ${detected.message}`)
|
|
465
|
+
send(ws, { type: 'error', message: detected.message })
|
|
468
466
|
}
|
|
469
467
|
|
|
470
468
|
function enqueuePrompt(
|