typeclaw 0.7.0 → 0.9.0
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 +15 -9
- package/package.json +5 -3
- package/scripts/dump-system-prompt.ts +12 -1
- package/scripts/require-parallel.ts +41 -0
- package/src/agent/auth.ts +3 -3
- package/src/agent/index.ts +116 -14
- package/src/agent/live-sessions.ts +34 -0
- package/src/agent/multimodal/read-redirect.ts +43 -0
- package/src/agent/plugin-tools.ts +97 -13
- package/src/agent/session-meta.ts +21 -2
- package/src/agent/session-origin.ts +6 -13
- package/src/agent/subagent-completion-reminder.ts +89 -0
- package/src/agent/subagents.ts +3 -2
- package/src/agent/system-prompt.ts +49 -15
- package/src/bundled-plugins/explorer/explorer.ts +2 -2
- package/src/bundled-plugins/guard/index.ts +14 -1
- package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
- package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
- package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
- package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
- package/src/bundled-plugins/guard/policy.ts +7 -0
- package/src/bundled-plugins/memory/README.md +76 -62
- package/src/bundled-plugins/memory/append-tool.ts +3 -2
- package/src/bundled-plugins/memory/citation-superset.ts +49 -11
- package/src/bundled-plugins/memory/citations.ts +19 -8
- package/src/bundled-plugins/memory/delete-tool.ts +57 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
- package/src/bundled-plugins/memory/dreaming.ts +364 -146
- package/src/bundled-plugins/memory/frontmatter.ts +165 -0
- package/src/bundled-plugins/memory/index.ts +236 -16
- package/src/bundled-plugins/memory/injection-plan.ts +15 -0
- package/src/bundled-plugins/memory/load-memory.ts +102 -103
- package/src/bundled-plugins/memory/load-shards.ts +156 -0
- package/src/bundled-plugins/memory/memory-logger.ts +16 -15
- package/src/bundled-plugins/memory/memory-retrieval.ts +105 -0
- package/src/bundled-plugins/memory/migration.ts +282 -1
- package/src/bundled-plugins/memory/paths.ts +42 -0
- package/src/bundled-plugins/memory/search-tool.ts +232 -0
- package/src/bundled-plugins/memory/secret-detector.ts +2 -2
- package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
- package/src/bundled-plugins/memory/slug.ts +59 -0
- package/src/bundled-plugins/memory/stream-io.ts +110 -1
- package/src/bundled-plugins/memory/strength.ts +3 -3
- package/src/bundled-plugins/memory/topics.ts +70 -16
- package/src/bundled-plugins/security/index.ts +24 -0
- package/src/bundled-plugins/security/permissions.ts +4 -0
- package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
- package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
- package/src/channels/adapters/discord-bot-slash-commands.ts +186 -0
- package/src/channels/adapters/discord-bot.ts +163 -1
- package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
- package/src/channels/adapters/kakaotalk.ts +64 -37
- package/src/channels/adapters/slack-bot-classify.ts +2 -27
- package/src/channels/adapters/slack-bot-slash-commands.ts +82 -0
- package/src/channels/adapters/slack-bot.ts +139 -1
- package/src/channels/index.ts +5 -0
- package/src/channels/router.ts +328 -18
- package/src/channels/subagent-completion-bridge.ts +84 -0
- package/src/cli/builtins.ts +1 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/init.ts +122 -14
- package/src/cli/inspect.ts +151 -0
- package/src/cli/role.ts +7 -2
- package/src/cli/tunnel.ts +13 -1
- package/src/cli/ui.ts +25 -1
- package/src/config/index.ts +1 -0
- package/src/config/models-mutation.ts +10 -2
- package/src/cron/consumer.ts +1 -1
- package/src/init/dockerfile.ts +353 -2
- package/src/init/hatching.ts +5 -6
- package/src/init/kakaotalk-auth.ts +6 -47
- package/src/init/validate-api-key.ts +121 -0
- package/src/inspect/index.ts +213 -0
- package/src/inspect/label.ts +50 -0
- package/src/inspect/live.ts +221 -0
- package/src/inspect/render.ts +163 -0
- package/src/inspect/replay.ts +265 -0
- package/src/inspect/session-list.ts +160 -0
- package/src/inspect/types.ts +110 -0
- package/src/plugin/hooks.ts +23 -1
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +1 -1
- package/src/plugin/registry.ts +1 -1
- package/src/plugin/types.ts +10 -0
- package/src/run/channel-session-factory.ts +7 -1
- package/src/run/index.ts +87 -21
- package/src/secrets/kakao-renewal.ts +3 -47
- package/src/server/index.ts +241 -60
- package/src/shared/index.ts +4 -1
- package/src/shared/local-time.ts +17 -0
- package/src/shared/protocol.ts +49 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
- package/src/skills/typeclaw-claude-code/SKILL.md +83 -40
- package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
- package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
- package/src/skills/typeclaw-config/SKILL.md +38 -33
- package/src/skills/typeclaw-cron/SKILL.md +1 -1
- package/src/skills/typeclaw-git/SKILL.md +2 -2
- package/src/skills/typeclaw-memory/SKILL.md +16 -163
- package/src/skills/typeclaw-permissions/SKILL.md +2 -2
- package/src/skills/typeclaw-plugins/SKILL.md +26 -15
- package/src/test-helpers/wait-for.ts +7 -1
- package/typeclaw.schema.json +7 -0
package/src/cli/init.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { defineCommand } from 'citty'
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
KNOWN_PROVIDERS,
|
|
9
|
+
providerForModelRef,
|
|
9
10
|
supportsApiKey as providerSupportsApiKey,
|
|
10
11
|
supportsOAuth as providerSupportsOAuth,
|
|
11
12
|
type KnownModelRef,
|
|
@@ -30,6 +31,7 @@ import {
|
|
|
30
31
|
import { runKakaotalkBootstrap } from '@/init/kakaotalk-auth'
|
|
31
32
|
import { fetchModelOptions, type ModelOption } from '@/init/models-dev'
|
|
32
33
|
import { makeOAuthLoginRunner, type OAuthLoginResult } from '@/init/oauth-login'
|
|
34
|
+
import { API_KEY_DASHBOARD_URL, validateApiKey, type KeyValidationResult } from '@/init/validate-api-key'
|
|
33
35
|
|
|
34
36
|
import { buildOAuthCallbacks } from './oauth-callbacks'
|
|
35
37
|
import { c, done, errorLine, printSlackAppManifestSetup } from './ui'
|
|
@@ -237,14 +239,29 @@ export const init = defineCommand({
|
|
|
237
239
|
}
|
|
238
240
|
|
|
239
241
|
if (hatchingOk) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
242
|
+
const claimableChannel =
|
|
243
|
+
channelChoice !== 'none' && channelChoice !== 'github' ? channelDisplayName(channelChoice) : null
|
|
244
|
+
const hints: Array<{ label: string; command: string }> = []
|
|
245
|
+
if (claimableChannel !== null) {
|
|
246
|
+
hints.push({ label: 'Claim your agent:', command: 'typeclaw role claim' })
|
|
247
|
+
}
|
|
248
|
+
hints.push(
|
|
249
|
+
{ label: 'Attach TUI:', command: 'typeclaw tui' },
|
|
250
|
+
{ label: 'Follow logs:', command: 'typeclaw logs -f' },
|
|
251
|
+
{ label: 'Stop:', command: 'typeclaw stop' },
|
|
252
|
+
{ label: 'Diagnose issues:', command: 'typeclaw doctor' },
|
|
253
|
+
)
|
|
254
|
+
if (claimableChannel !== null) {
|
|
255
|
+
note(
|
|
256
|
+
[
|
|
257
|
+
`Your agent will not respond on ${claimableChannel} until you claim ownership.`,
|
|
258
|
+
`This prevents strangers from talking to it.`,
|
|
259
|
+
`Run \`typeclaw role claim\` to finish setup.`,
|
|
260
|
+
].join('\n'),
|
|
261
|
+
'Claim ownership before chatting',
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
done({ title: c.green('Hatched. Your agent is ready.'), hints })
|
|
248
265
|
}
|
|
249
266
|
},
|
|
250
267
|
})
|
|
@@ -329,6 +346,7 @@ export interface WizardPrompts {
|
|
|
329
346
|
initial: 'api-key' | 'oauth' | undefined,
|
|
330
347
|
) => Promise<StepResult<'api-key' | 'oauth'>>
|
|
331
348
|
askApiKey: (provider: (typeof KNOWN_PROVIDERS)[KnownProviderId]) => Promise<StepResult<string>>
|
|
349
|
+
validateApiKey: (providerId: KnownProviderId, key: string) => Promise<KeyValidationResult>
|
|
332
350
|
pickVisionProvider: (
|
|
333
351
|
options: ModelOption[],
|
|
334
352
|
initial: KnownProviderId | undefined,
|
|
@@ -368,6 +386,7 @@ export const defaultWizardPrompts: WizardPrompts = {
|
|
|
368
386
|
askReuseExistingKey,
|
|
369
387
|
pickAuthMethod,
|
|
370
388
|
askApiKey,
|
|
389
|
+
validateApiKey,
|
|
371
390
|
pickVisionProvider,
|
|
372
391
|
pickVisionModel,
|
|
373
392
|
pickChannel,
|
|
@@ -502,12 +521,18 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
|
|
|
502
521
|
}
|
|
503
522
|
|
|
504
523
|
case 'enter-api-key': {
|
|
505
|
-
const
|
|
524
|
+
const providerId = state.providerId!
|
|
525
|
+
const provider = KNOWN_PROVIDERS[providerId]
|
|
506
526
|
const result = onResult(step, await prompts.askApiKey(provider))
|
|
507
527
|
if (result.kind === 'back') {
|
|
508
528
|
step = 'pick-auth-method'
|
|
509
529
|
break
|
|
510
530
|
}
|
|
531
|
+
const verdict = await runApiKeyValidation(prompts, providerId, result.value)
|
|
532
|
+
if (verdict === 'retry') {
|
|
533
|
+
step = 'enter-api-key'
|
|
534
|
+
break
|
|
535
|
+
}
|
|
511
536
|
state.llmAuth = { kind: 'api-key', apiKey: result.value }
|
|
512
537
|
step = stepAfterDefaultAuth(state)
|
|
513
538
|
break
|
|
@@ -608,12 +633,18 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
|
|
|
608
633
|
}
|
|
609
634
|
|
|
610
635
|
case 'enter-vision-api-key': {
|
|
611
|
-
const
|
|
636
|
+
const providerId = state.visionProviderId!
|
|
637
|
+
const provider = KNOWN_PROVIDERS[providerId]
|
|
612
638
|
const result = onResult(step, await prompts.askApiKey(provider))
|
|
613
639
|
if (result.kind === 'back') {
|
|
614
640
|
step = 'pick-vision-auth-method'
|
|
615
641
|
break
|
|
616
642
|
}
|
|
643
|
+
const verdict = await runApiKeyValidation(prompts, providerId, result.value)
|
|
644
|
+
if (verdict === 'retry') {
|
|
645
|
+
step = 'enter-vision-api-key'
|
|
646
|
+
break
|
|
647
|
+
}
|
|
617
648
|
state.visionLlmAuth = { kind: 'api-key', apiKey: result.value }
|
|
618
649
|
step = 'pick-channel'
|
|
619
650
|
break
|
|
@@ -771,12 +802,12 @@ async function pickModelForProvider(
|
|
|
771
802
|
providerId: KnownProviderId,
|
|
772
803
|
initial: KnownModelRef | undefined,
|
|
773
804
|
): Promise<StepResult<ModelOption>> {
|
|
774
|
-
const candidates = options.filter((o) => o.providerId === providerId)
|
|
805
|
+
const candidates = sortRecommendedFirst(options.filter((o) => o.providerId === providerId))
|
|
775
806
|
const choice = await select<KnownModelRef>({
|
|
776
807
|
message: `Pick a ${KNOWN_PROVIDERS[providerId].name} model`,
|
|
777
808
|
options: candidates.map((o) => ({
|
|
778
809
|
value: o.ref,
|
|
779
|
-
label: o
|
|
810
|
+
label: formatModelLabel(o),
|
|
780
811
|
hint: formatModelHint(o),
|
|
781
812
|
})),
|
|
782
813
|
initialValue: initial ?? candidates[0]?.ref,
|
|
@@ -863,12 +894,12 @@ async function pickVisionModel(
|
|
|
863
894
|
providerId: KnownProviderId,
|
|
864
895
|
initial: KnownModelRef | undefined,
|
|
865
896
|
): Promise<StepResult<ModelOption>> {
|
|
866
|
-
const candidates = options.filter((o) => o.providerId === providerId)
|
|
897
|
+
const candidates = sortRecommendedFirst(options.filter((o) => o.providerId === providerId))
|
|
867
898
|
const choice = await select<KnownModelRef>({
|
|
868
899
|
message: `Pick a vision-capable ${KNOWN_PROVIDERS[providerId].name} model`,
|
|
869
900
|
options: candidates.map((o) => ({
|
|
870
901
|
value: o.ref,
|
|
871
|
-
label: o
|
|
902
|
+
label: formatModelLabel(o),
|
|
872
903
|
hint: formatModelHint(o),
|
|
873
904
|
})),
|
|
874
905
|
initialValue: initial ?? candidates[0]?.ref,
|
|
@@ -879,7 +910,60 @@ async function pickVisionModel(
|
|
|
879
910
|
return value(picked)
|
|
880
911
|
}
|
|
881
912
|
|
|
913
|
+
async function runApiKeyValidation(
|
|
914
|
+
prompts: WizardPrompts,
|
|
915
|
+
providerId: KnownProviderId,
|
|
916
|
+
key: string,
|
|
917
|
+
): Promise<'accepted' | 'retry'> {
|
|
918
|
+
const provider = KNOWN_PROVIDERS[providerId]
|
|
919
|
+
const s = spinner()
|
|
920
|
+
s.start(`Checking your ${provider.name} key...`)
|
|
921
|
+
let result: KeyValidationResult
|
|
922
|
+
try {
|
|
923
|
+
result = await prompts.validateApiKey(providerId, key)
|
|
924
|
+
} catch {
|
|
925
|
+
s.stop(`Couldn't reach ${provider.name} to verify the key. Saving it anyway.`)
|
|
926
|
+
return 'accepted'
|
|
927
|
+
}
|
|
928
|
+
if (result.kind === 'ok') {
|
|
929
|
+
s.stop(`${provider.name} key looks good.`)
|
|
930
|
+
return 'accepted'
|
|
931
|
+
}
|
|
932
|
+
if (result.kind === 'skipped') {
|
|
933
|
+
s.stop(`Couldn't reach ${provider.name} to verify the key. Saving it anyway.`)
|
|
934
|
+
return 'accepted'
|
|
935
|
+
}
|
|
936
|
+
s.error(`${provider.name} rejected the key (HTTP ${result.status}).`)
|
|
937
|
+
const dashboardUrl = API_KEY_DASHBOARD_URL[providerId]
|
|
938
|
+
const lines = [
|
|
939
|
+
'The provider says this key is not valid.',
|
|
940
|
+
'Common causes: typo, expired key, wrong account, or pasting a project-scoped key.',
|
|
941
|
+
]
|
|
942
|
+
if (dashboardUrl) {
|
|
943
|
+
lines.push('', `Get a fresh one at ${dashboardUrl}`)
|
|
944
|
+
}
|
|
945
|
+
note(lines.join('\n'), `${provider.name} key rejected`)
|
|
946
|
+
const choice = await select<'retry' | 'accept'>({
|
|
947
|
+
message: 'What do you want to do?',
|
|
948
|
+
options: [
|
|
949
|
+
{ value: 'retry', label: 'Try a different key' },
|
|
950
|
+
{ value: 'accept', label: 'Save this key anyway', hint: 'init continues, but the agent may fail to start' },
|
|
951
|
+
],
|
|
952
|
+
initialValue: 'retry',
|
|
953
|
+
})
|
|
954
|
+
if (isCancel(choice) || choice === 'retry') return 'retry'
|
|
955
|
+
return 'accepted'
|
|
956
|
+
}
|
|
957
|
+
|
|
882
958
|
async function askApiKey(provider: (typeof KNOWN_PROVIDERS)[KnownProviderId]): Promise<StepResult<string>> {
|
|
959
|
+
const providerId = provider.id as KnownProviderId
|
|
960
|
+
const dashboardUrl = API_KEY_DASHBOARD_URL[providerId]
|
|
961
|
+
if (dashboardUrl) {
|
|
962
|
+
note(
|
|
963
|
+
[`Don't have a key yet?`, `Get one at ${dashboardUrl}`, `Then come back and paste it below.`].join('\n'),
|
|
964
|
+
`Get a ${provider.name} API key`,
|
|
965
|
+
)
|
|
966
|
+
}
|
|
883
967
|
const apiKey = await password({
|
|
884
968
|
message: `Put your ${provider.name} API key (will be saved to secrets.json)`,
|
|
885
969
|
validate: (v) => (v && v.length > 0 ? undefined : 'API key is required'),
|
|
@@ -1371,6 +1455,30 @@ function uniqueProviders(options: ModelOption[]): KnownProviderId[] {
|
|
|
1371
1455
|
return out
|
|
1372
1456
|
}
|
|
1373
1457
|
|
|
1458
|
+
// Per-provider recommended model refs. Surfaces a "(Recommended)" suffix in
|
|
1459
|
+
// the picker label and floats the entry to the top of the list (which also
|
|
1460
|
+
// makes it the default `initialValue` when the caller has no prior choice).
|
|
1461
|
+
// Kept narrow on purpose: one recommendation per provider. gpt-5.4-mini is
|
|
1462
|
+
// listed under both `openai` and `openai-codex` because the same model is
|
|
1463
|
+
// the right default whether the user authenticates with an API key or with
|
|
1464
|
+
// a ChatGPT Plus/Pro subscription. claude-sonnet-4-6 follows Anthropic's
|
|
1465
|
+
// own current-tier guidance (see the model lineup notes in providers.ts).
|
|
1466
|
+
const RECOMMENDED_MODEL_REFS: ReadonlySet<KnownModelRef> = new Set<KnownModelRef>([
|
|
1467
|
+
'openai/gpt-5.4-mini',
|
|
1468
|
+
'openai-codex/gpt-5.4-mini',
|
|
1469
|
+
'anthropic/claude-sonnet-4-6',
|
|
1470
|
+
])
|
|
1471
|
+
|
|
1472
|
+
export function formatModelLabel(o: ModelOption): string {
|
|
1473
|
+
return RECOMMENDED_MODEL_REFS.has(o.ref) ? `${o.modelName} (Recommended)` : o.modelName
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
export function sortRecommendedFirst(options: ModelOption[]): ModelOption[] {
|
|
1477
|
+
const recommended = options.filter((o) => RECOMMENDED_MODEL_REFS.has(o.ref))
|
|
1478
|
+
const rest = options.filter((o) => !RECOMMENDED_MODEL_REFS.has(o.ref))
|
|
1479
|
+
return [...recommended, ...rest]
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1374
1482
|
function formatModelHint(o: ModelOption): string {
|
|
1375
1483
|
const parts: string[] = []
|
|
1376
1484
|
if (o.contextWindow !== null) parts.push(`${(o.contextWindow / 1000).toFixed(0)}K ctx`)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
|
|
3
|
+
import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
|
|
4
|
+
import { findAgentDir } from '@/init'
|
|
5
|
+
import { runInspect, streamLive, type LiveSourceFactory, type SessionSummary } from '@/inspect'
|
|
6
|
+
import { originLabel, shortSessionId } from '@/inspect/label'
|
|
7
|
+
|
|
8
|
+
import { cancel, c, errorLine, isCancel } from './ui'
|
|
9
|
+
|
|
10
|
+
export const inspectCommand = defineCommand({
|
|
11
|
+
meta: {
|
|
12
|
+
name: 'inspect',
|
|
13
|
+
description: 'replay a session transcript and tail live activity (host stage)',
|
|
14
|
+
},
|
|
15
|
+
args: {
|
|
16
|
+
session: {
|
|
17
|
+
type: 'positional',
|
|
18
|
+
description: 'session id or short prefix (omit to pick from a list)',
|
|
19
|
+
required: false,
|
|
20
|
+
},
|
|
21
|
+
filter: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
description:
|
|
24
|
+
'category filter: comma-separated meta/user/assistant/tool/error/done/broadcast/cron-fire; prefix with ! to exclude',
|
|
25
|
+
},
|
|
26
|
+
since: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
description: 'only events newer than this (forms: 30s, 5m, 1h, 7d)',
|
|
29
|
+
},
|
|
30
|
+
json: {
|
|
31
|
+
type: 'boolean',
|
|
32
|
+
description: 'emit one JSON event per line; requires an explicit session id',
|
|
33
|
+
default: false,
|
|
34
|
+
},
|
|
35
|
+
follow: {
|
|
36
|
+
type: 'boolean',
|
|
37
|
+
description:
|
|
38
|
+
'tail live activity after replay (default: true when the container is running); pass --no-follow to replay-then-exit',
|
|
39
|
+
default: true,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
async run({ args }) {
|
|
43
|
+
const cwd = findAgentDir(process.cwd()) ?? process.cwd()
|
|
44
|
+
const color = useColor()
|
|
45
|
+
const sessionArg = typeof args.session === 'string' ? args.session : undefined
|
|
46
|
+
const filterArg = typeof args.filter === 'string' ? args.filter : undefined
|
|
47
|
+
const sinceArg = typeof args.since === 'string' ? args.since : undefined
|
|
48
|
+
const follow = args.follow !== false
|
|
49
|
+
|
|
50
|
+
const isJson = args.json === true
|
|
51
|
+
const liveSource = !follow || isJson ? undefined : await buildLiveSource(cwd)
|
|
52
|
+
const signal = installSigintAbort()
|
|
53
|
+
|
|
54
|
+
const result = await runInspect({
|
|
55
|
+
agentDir: cwd,
|
|
56
|
+
...(sessionArg !== undefined ? { sessionIdOrPrefix: sessionArg } : {}),
|
|
57
|
+
...(filterArg !== undefined ? { filter: filterArg } : {}),
|
|
58
|
+
...(sinceArg !== undefined ? { since: sinceArg } : {}),
|
|
59
|
+
json: isJson,
|
|
60
|
+
color,
|
|
61
|
+
selectSession: clackSelect,
|
|
62
|
+
...(liveSource !== undefined ? { liveSource } : {}),
|
|
63
|
+
signal,
|
|
64
|
+
stdout: (line) => process.stdout.write(`${line}\n`),
|
|
65
|
+
stderr: (line) => process.stderr.write(`${line}\n`),
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
if (!result.ok) {
|
|
69
|
+
process.stderr.write(`${errorLine(result.reason)}\n`)
|
|
70
|
+
process.exit(result.exitCode)
|
|
71
|
+
}
|
|
72
|
+
process.exit(result.exitCode)
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
async function buildLiveSource(cwd: string): Promise<LiveSourceFactory | undefined> {
|
|
77
|
+
const precheck = await requireContainerRunning({ cwd })
|
|
78
|
+
if (!precheck.ok) {
|
|
79
|
+
process.stderr.write(`${c.yellow('⚠')} ${precheck.reason}; tailing live events disabled\n`)
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
const port = await resolveHostPort({ cwd })
|
|
83
|
+
const token = await resolveTuiToken({ cwd })
|
|
84
|
+
const baseUrl = new URL(`ws://127.0.0.1:${port}/inspect`)
|
|
85
|
+
if (token !== null) baseUrl.searchParams.set('token', token)
|
|
86
|
+
const url = baseUrl.toString()
|
|
87
|
+
return ({ sessionId, sinceMs, signal, onSubscribed }) =>
|
|
88
|
+
streamLive({
|
|
89
|
+
url,
|
|
90
|
+
sessionId,
|
|
91
|
+
...(sinceMs !== undefined ? { sinceMs } : {}),
|
|
92
|
+
...(signal !== undefined ? { signal } : {}),
|
|
93
|
+
...(onSubscribed !== undefined ? { onSubscribed } : {}),
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function installSigintAbort(): AbortSignal {
|
|
98
|
+
const ctrl = new AbortController()
|
|
99
|
+
const onSig = (): void => {
|
|
100
|
+
ctrl.abort()
|
|
101
|
+
}
|
|
102
|
+
process.once('SIGINT', onSig)
|
|
103
|
+
process.once('SIGTERM', onSig)
|
|
104
|
+
return ctrl.signal
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function useColor(): boolean {
|
|
108
|
+
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') return false
|
|
109
|
+
if (process.env.FORCE_COLOR === '0') return false
|
|
110
|
+
if (process.env.FORCE_COLOR) return true
|
|
111
|
+
return Boolean(process.stdout.isTTY)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function clackSelect(sessions: SessionSummary[]): Promise<SessionSummary | null> {
|
|
115
|
+
const { select } = await import('@clack/prompts')
|
|
116
|
+
const picked = await select<string>({
|
|
117
|
+
message: `Pick a session to inspect (showing ${sessions.length})`,
|
|
118
|
+
options: sessions.map((s) => ({
|
|
119
|
+
value: s.sessionId,
|
|
120
|
+
label: formatRowLabel(s),
|
|
121
|
+
...(s.firstPrompt !== null ? { hint: truncate(s.firstPrompt, 60) } : { hint: '(no prompt)' }),
|
|
122
|
+
})),
|
|
123
|
+
initialValue: sessions[0]?.sessionId,
|
|
124
|
+
})
|
|
125
|
+
if (isCancel(picked)) {
|
|
126
|
+
cancel('Cancelled.')
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
return sessions.find((s) => s.sessionId === picked) ?? null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function formatRowLabel(s: SessionSummary): string {
|
|
133
|
+
const id = shortSessionId(s.sessionId)
|
|
134
|
+
const label = s.origin === null ? '(unknown origin)' : originLabel(s.origin)
|
|
135
|
+
const when = formatRelative(s.mtimeMs)
|
|
136
|
+
return `${c.cyan(id)} ${label} ${c.dim(when)}`
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function formatRelative(ms: number): string {
|
|
140
|
+
const diff = Date.now() - ms
|
|
141
|
+
if (diff < 60_000) return 'just now'
|
|
142
|
+
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
|
|
143
|
+
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
|
|
144
|
+
return `${Math.floor(diff / 86_400_000)}d ago`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function truncate(text: string, max: number): string {
|
|
148
|
+
const oneline = text.replace(/\s+/g, ' ').trim()
|
|
149
|
+
if (oneline.length <= max) return oneline
|
|
150
|
+
return `${oneline.slice(0, max)}…`
|
|
151
|
+
}
|
package/src/cli/role.ts
CHANGED
|
@@ -95,8 +95,13 @@ const listSub = defineCommand({
|
|
|
95
95
|
},
|
|
96
96
|
async run() {
|
|
97
97
|
const cwd = findAgentDir(process.cwd()) ?? process.cwd()
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
// Diagnostic command: route through `loadConfigSyncOrDefaults` (same
|
|
99
|
+
// soft-fail pattern as PR #288's `status`/`doctor` and the follow-up for
|
|
100
|
+
// `model list`) so a broken `typeclaw.json` doesn't crash the very
|
|
101
|
+
// command users reach for to see which roles the agent thinks it has.
|
|
102
|
+
// Defaults have no `roles` block, so the empty-state hint fires next.
|
|
103
|
+
const { loadConfigSyncOrDefaults } = await import('@/config')
|
|
104
|
+
const config = loadConfigSyncOrDefaults(cwd)
|
|
100
105
|
if (!config.roles || Object.keys(config.roles).length === 0) {
|
|
101
106
|
console.log(c.dim('No roles declared. Run `typeclaw role claim` to add one, or edit typeclaw.json by hand.'))
|
|
102
107
|
return
|
package/src/cli/tunnel.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { join } from 'node:path'
|
|
|
4
4
|
import { select, text, isCancel, cancel, log } from '@clack/prompts'
|
|
5
5
|
import { defineCommand } from 'citty'
|
|
6
6
|
|
|
7
|
-
import { loadConfigSync } from '@/config'
|
|
7
|
+
import { loadConfigSync, validateConfig } from '@/config'
|
|
8
8
|
import { resolveHostPort, resolveTuiToken } from '@/container'
|
|
9
9
|
import { findAgentDir, isInitialized } from '@/init'
|
|
10
10
|
import type { ClientMessage, ServerMessage, TunnelLogsServerMessage, TunnelSnapshot } from '@/shared'
|
|
@@ -168,6 +168,15 @@ export async function runTunnelAddFlow(
|
|
|
168
168
|
args: AddArgs,
|
|
169
169
|
prompts: TunnelPrompts = defaultPrompts,
|
|
170
170
|
): Promise<LiveResult<TunnelConfig>> {
|
|
171
|
+
// Strict gate before any read: a malformed or schema-invalid `typeclaw.json`
|
|
172
|
+
// would otherwise throw out of the subsequent `loadConfigSync` and surface
|
|
173
|
+
// as an uncaught exception instead of the clean exit-1-with-reason that
|
|
174
|
+
// every other LiveResult consumer expects. Same fence PR #288 documented
|
|
175
|
+
// for the `start`/`restart`/`reload` path: destructive paths route through
|
|
176
|
+
// `validateConfig` so the file's invariants are checked once, up front,
|
|
177
|
+
// and the rest of the flow can lean on them.
|
|
178
|
+
const validation = validateConfig(cwd)
|
|
179
|
+
if (!validation.ok) return { ok: false, reason: validation.reason }
|
|
171
180
|
const config = loadConfigSync(cwd)
|
|
172
181
|
if (config.tunnels.some((entry) => entry.name === args.name))
|
|
173
182
|
return { ok: false, reason: `tunnel "${args.name}" already exists` }
|
|
@@ -206,6 +215,9 @@ export async function runTunnelAddFlow(
|
|
|
206
215
|
}
|
|
207
216
|
|
|
208
217
|
export function runTunnelRemoveFlow(cwd: string, args: RemoveArgs): LiveResult<{ removed: TunnelConfig }> {
|
|
218
|
+
// Same strict gate as `runTunnelAddFlow`. See the comment there for why.
|
|
219
|
+
const validation = validateConfig(cwd)
|
|
220
|
+
if (!validation.ok) return { ok: false, reason: validation.reason }
|
|
209
221
|
const config = loadConfigSync(cwd)
|
|
210
222
|
const tunnel = config.tunnels.find((entry) => entry.name === args.name)
|
|
211
223
|
if (tunnel === undefined) return { ok: false, reason: `unknown tunnel: ${args.name}` }
|
package/src/cli/ui.ts
CHANGED
|
@@ -142,6 +142,27 @@ export const SLACK_APP_MANIFEST = {
|
|
|
142
142
|
messages_tab_enabled: true,
|
|
143
143
|
messages_tab_read_only_enabled: false,
|
|
144
144
|
},
|
|
145
|
+
// Slash commands listed here appear in Slack's compose-box picker with
|
|
146
|
+
// their description as a tooltip. `url` is required by Slack's manifest
|
|
147
|
+
// schema even for Socket Mode bots, but is ignored at runtime when the
|
|
148
|
+
// app is in Socket Mode — Slack delivers `slash_commands` envelopes
|
|
149
|
+
// over the same WebSocket as message events. We point it at a
|
|
150
|
+
// deliberately-invalid placeholder (RFC 6761 reserved .invalid TLD)
|
|
151
|
+
// so a misconfigured (non-Socket-Mode) deployment fails fast rather
|
|
152
|
+
// than silently routing real slash invocations to a third-party URL.
|
|
153
|
+
slash_commands: [
|
|
154
|
+
{
|
|
155
|
+
command: '/stop',
|
|
156
|
+
description: 'Abort the current turn in this channel',
|
|
157
|
+
// usage_hint is intentionally omitted. Slack's manifest validator
|
|
158
|
+
// rejects an empty string ("Must be more than 0 characters") but
|
|
159
|
+
// the field is optional, so the cleanest answer is to leave it out
|
|
160
|
+
// rather than invent placeholder text for a command that takes no
|
|
161
|
+
// arguments.
|
|
162
|
+
url: 'https://example.invalid/typeclaw-uses-socket-mode',
|
|
163
|
+
should_escape: false,
|
|
164
|
+
},
|
|
165
|
+
],
|
|
145
166
|
},
|
|
146
167
|
oauth_config: {
|
|
147
168
|
scopes: {
|
|
@@ -150,13 +171,16 @@ export const SLACK_APP_MANIFEST = {
|
|
|
150
171
|
// write scopes (chat, files, im/mpim/groups, pins, reactions) let the
|
|
151
172
|
// agent post replies, upload attachments, open DMs, pin messages, and
|
|
152
173
|
// react to messages. `channels:join` lets the bot self-join public
|
|
153
|
-
// channels it's invited to discuss in.
|
|
174
|
+
// channels it's invited to discuss in. `commands` is required for
|
|
175
|
+
// Slack to deliver `slash_commands` envelopes — without it, slash
|
|
176
|
+
// commands registered in `features` would silently fail to route.
|
|
154
177
|
bot: [
|
|
155
178
|
'app_mentions:read',
|
|
156
179
|
'channels:history',
|
|
157
180
|
'channels:join',
|
|
158
181
|
'channels:read',
|
|
159
182
|
'chat:write',
|
|
183
|
+
'commands',
|
|
160
184
|
'emoji:read',
|
|
161
185
|
'files:read',
|
|
162
186
|
'files:write',
|
package/src/config/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from 'node:path'
|
|
|
3
3
|
|
|
4
4
|
import { commitSystemFileSync } from '@/git/system-commit'
|
|
5
5
|
|
|
6
|
-
import { configSchema,
|
|
6
|
+
import { configSchema, loadConfigSyncOrDefaults, validateConfig } from './config'
|
|
7
7
|
import {
|
|
8
8
|
KNOWN_PROVIDERS,
|
|
9
9
|
listKnownModelRefs,
|
|
@@ -33,8 +33,16 @@ export type ModelProfileEntry = {
|
|
|
33
33
|
|
|
34
34
|
export type ModelMutationResult = { ok: true } | { ok: false; reason: string }
|
|
35
35
|
|
|
36
|
+
// `listModelProfiles` is the read-only path behind `typeclaw model list`, a
|
|
37
|
+
// diagnostic command. It routes through `loadConfigSyncOrDefaults` (same
|
|
38
|
+
// soft-fail pattern as `typeclaw status` / `doctor`, PR #288) so a broken
|
|
39
|
+
// `typeclaw.json` doesn't crash the command users reach for to see what
|
|
40
|
+
// model config the agent thinks it has. Mutation paths (`setProfile`,
|
|
41
|
+
// `addProfile`, `removeProfile`) stay on the strict gate via `validateConfig`
|
|
42
|
+
// in `writeModels`, because writing through a broken-on-disk file would
|
|
43
|
+
// silently land schema-invalid bytes.
|
|
36
44
|
export function listModelProfiles(cwd: string, env: NodeJS.ProcessEnv = process.env): ModelProfileEntry[] {
|
|
37
|
-
const models =
|
|
45
|
+
const models = loadConfigSyncOrDefaults(cwd).models
|
|
38
46
|
const out: ModelProfileEntry[] = []
|
|
39
47
|
for (const [profile, refs] of Object.entries(models)) {
|
|
40
48
|
const headRef = refs[0]!
|
package/src/cron/consumer.ts
CHANGED
|
@@ -195,7 +195,7 @@ async function runPromptOnce(
|
|
|
195
195
|
}
|
|
196
196
|
: undefined
|
|
197
197
|
if (created.hooks && turnEvent !== undefined) {
|
|
198
|
-
await created.hooks.runSessionTurnStart(turnEvent)
|
|
198
|
+
await created.hooks.runSessionTurnStart({ ...turnEvent, userPrompt: job.prompt })
|
|
199
199
|
}
|
|
200
200
|
// Bridge the CronSession wrapper into the AgentSession surface the
|
|
201
201
|
// fallback helper expects:
|