typeclaw 0.12.0 → 0.13.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.
Files changed (52) hide show
  1. package/package.json +1 -1
  2. package/scripts/dump-system-prompt.ts +12 -11
  3. package/src/agent/index.ts +15 -22
  4. package/src/agent/loop-guard.ts +170 -0
  5. package/src/agent/model-fallback.ts +2 -1
  6. package/src/agent/multimodal/index.ts +1 -1
  7. package/src/agent/multimodal/look-at.ts +118 -55
  8. package/src/agent/plugin-tools.ts +57 -0
  9. package/src/agent/subagents.ts +2 -1
  10. package/src/agent/system-prompt.ts +28 -25
  11. package/src/agent/tools/channel-fetch-attachment.ts +45 -16
  12. package/src/agent/tools/normalize-ref.ts +11 -0
  13. package/src/bundled-plugins/reviewer/index.ts +11 -0
  14. package/src/bundled-plugins/reviewer/reviewer.ts +171 -0
  15. package/src/bundled-plugins/reviewer/skills/code-review.ts +73 -0
  16. package/src/bundled-plugins/reviewer/skills/general.ts +68 -0
  17. package/src/channels/adapters/discord-bot-classify.ts +32 -24
  18. package/src/channels/adapters/github/inbound.ts +19 -2
  19. package/src/channels/adapters/kakaotalk-attachment.ts +140 -133
  20. package/src/channels/adapters/kakaotalk-classify.ts +8 -1
  21. package/src/channels/adapters/kakaotalk.ts +19 -11
  22. package/src/channels/adapters/slack-bot-classify.ts +30 -14
  23. package/src/channels/adapters/slack-bot.ts +3 -2
  24. package/src/channels/adapters/telegram-bot-classify.ts +36 -13
  25. package/src/channels/adapters/telegram-bot.ts +3 -3
  26. package/src/channels/outbound-flood-filter.ts +57 -0
  27. package/src/channels/router.ts +93 -5
  28. package/src/channels/types.ts +52 -1
  29. package/src/cli/builtins.ts +1 -0
  30. package/src/cli/index.ts +1 -0
  31. package/src/cli/mount.ts +157 -0
  32. package/src/cli/update.ts +6 -4
  33. package/src/config/mounts-mutation.ts +161 -0
  34. package/src/init/hatching.ts +1 -1
  35. package/src/plugin/index.ts +6 -0
  36. package/src/plugin/load-skill.ts +99 -0
  37. package/src/run/bundled-plugins.ts +2 -0
  38. package/src/run/index.ts +14 -1
  39. package/src/secrets/codex-auth-json.ts +67 -0
  40. package/src/secrets/export-codex-auth-file.ts +243 -0
  41. package/src/secrets/index.ts +6 -0
  42. package/src/server/command-runner.ts +2 -1
  43. package/src/server/index.ts +3 -2
  44. package/src/shared/index.ts +7 -1
  45. package/src/shared/local-time.ts +32 -0
  46. package/src/skills/typeclaw-channel-github/SKILL.md +47 -13
  47. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +10 -11
  48. package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +8 -0
  49. package/src/skills/typeclaw-codex-cli/SKILL.md +2 -1
  50. package/src/skills/typeclaw-codex-cli/references/auth-flow.md +22 -0
  51. package/src/skills/typeclaw-kaomoji/SKILL.md +116 -0
  52. package/src/update/index.ts +95 -26
@@ -0,0 +1,157 @@
1
+ import { defineCommand } from 'citty'
2
+
3
+ import { addMount, listMounts, removeMount, type MountListEntry } from '@/config/mounts-mutation'
4
+ import { findAgentDir, isInitialized } from '@/init'
5
+
6
+ import { c, errorLine, successLine } from './ui'
7
+
8
+ const listSub = defineCommand({
9
+ meta: {
10
+ name: 'list',
11
+ description: 'list host directories mounted into the agent container',
12
+ },
13
+ args: {
14
+ json: {
15
+ type: 'boolean',
16
+ description: 'emit mounts as JSON',
17
+ default: false,
18
+ },
19
+ },
20
+ async run({ args }) {
21
+ const cwd = ensureAgentDir()
22
+ const mounts = listMounts(cwd)
23
+ if (args.json) {
24
+ process.stdout.write(`${JSON.stringify({ mounts }, null, 2)}\n`)
25
+ return
26
+ }
27
+ process.stdout.write(`${formatMountList(mounts)}\n`)
28
+ },
29
+ })
30
+
31
+ const addSub = defineCommand({
32
+ meta: {
33
+ name: 'add',
34
+ description: 'add a host directory mount to typeclaw.json',
35
+ },
36
+ args: {
37
+ name: {
38
+ type: 'positional',
39
+ description: 'mount name; appears inside the container at /agent/mounts/<name>',
40
+ required: true,
41
+ },
42
+ path: {
43
+ type: 'positional',
44
+ description: 'host directory path to expose inside the container',
45
+ required: true,
46
+ },
47
+ 'read-only': {
48
+ type: 'boolean',
49
+ description: 'mount read-only inside the container',
50
+ default: false,
51
+ },
52
+ description: {
53
+ type: 'string',
54
+ description: 'optional human-readable note stored in typeclaw.json',
55
+ required: false,
56
+ },
57
+ },
58
+ async run({ args }) {
59
+ const cwd = ensureAgentDir()
60
+ const result = addMount(cwd, args.name, args.path, {
61
+ readOnly: args['read-only'] === true,
62
+ ...(args.description !== undefined ? { description: args.description } : {}),
63
+ })
64
+ if (!result.ok) {
65
+ console.error(errorLine(result.reason))
66
+ process.exit(1)
67
+ }
68
+ process.stdout.write(`${successLine(`Added mount "${result.entry.name}".`)}\n`)
69
+ process.stdout.write(`${formatMountEntry(result.entry)}\n`)
70
+ process.stdout.write(`${c.dim('Apply change:')} ${c.cyan('typeclaw restart')}\n`)
71
+ },
72
+ })
73
+
74
+ const removeSub = defineCommand({
75
+ meta: {
76
+ name: 'remove',
77
+ description: 'remove a host directory mount from typeclaw.json',
78
+ },
79
+ args: {
80
+ name: {
81
+ type: 'positional',
82
+ description: 'mount name to remove',
83
+ required: true,
84
+ },
85
+ },
86
+ async run({ args }) {
87
+ const cwd = ensureAgentDir()
88
+ const result = removeMount(cwd, args.name)
89
+ if (!result.ok) {
90
+ console.error(errorLine(result.reason))
91
+ process.exit(1)
92
+ }
93
+ process.stdout.write(`${successLine(`Removed mount "${result.removed.name}".`)}\n`)
94
+ process.stdout.write(`${c.dim('Apply change:')} ${c.cyan('typeclaw restart')}\n`)
95
+ },
96
+ })
97
+
98
+ export const mountCommand = defineCommand({
99
+ meta: {
100
+ name: 'mount',
101
+ description: 'manage host directories mounted into the agent container',
102
+ },
103
+ subCommands: {
104
+ list: listSub,
105
+ add: addSub,
106
+ remove: removeSub,
107
+ },
108
+ })
109
+
110
+ export function formatMountList(mounts: readonly MountListEntry[]): string {
111
+ if (mounts.length === 0) return c.dim('No mounts configured.')
112
+
113
+ const nameWidth = Math.max(4, ...mounts.map((m) => m.name.length))
114
+ const modeWidth = 'MODE'.length
115
+ const statusWidth = Math.max(6, ...mounts.map((m) => m.status.length))
116
+ const lines: string[] = []
117
+ lines.push(
118
+ c.dim(
119
+ `${'NAME'.padEnd(nameWidth)} ${'MODE'.padEnd(modeWidth)} ${'STATUS'.padEnd(statusWidth)} HOST PATH -> CONTAINER PATH`,
120
+ ),
121
+ )
122
+ for (const mount of mounts) {
123
+ const mode = mount.readOnly ? 'ro' : 'rw'
124
+ const statusText = mount.status.padEnd(statusWidth)
125
+ const status = mount.status === 'ok' ? c.green(statusText) : c.red(statusText)
126
+ lines.push(
127
+ `${mount.name.padEnd(nameWidth)} ${mode.padEnd(modeWidth)} ${status} ${mount.resolvedPath} -> ${mount.targetPath}`,
128
+ )
129
+ if (mount.description !== undefined) {
130
+ lines.push(`${' '.repeat(nameWidth + modeWidth + statusWidth + 6)}${c.dim(mount.description)}`)
131
+ }
132
+ if (mount.statusReason !== undefined) {
133
+ lines.push(`${' '.repeat(nameWidth + modeWidth + statusWidth + 6)}${c.yellow(mount.statusReason)}`)
134
+ }
135
+ }
136
+ return lines.join('\n')
137
+ }
138
+
139
+ function formatMountEntry(mount: MountListEntry): string {
140
+ const mode = mount.readOnly ? 'read-only' : 'read-write'
141
+ const details = [
142
+ `${c.dim('host:')} ${mount.resolvedPath}`,
143
+ `${c.dim('container:')} ${mount.targetPath}`,
144
+ `${c.dim('mode:')} ${mode}`,
145
+ ]
146
+ if (mount.description !== undefined) details.push(`${c.dim('description:')} ${mount.description}`)
147
+ return details.join('\n')
148
+ }
149
+
150
+ function ensureAgentDir(): string {
151
+ const cwd = findAgentDir(process.cwd()) ?? process.cwd()
152
+ if (!isInitialized(cwd)) {
153
+ console.error(errorLine('TypeClaw config file not found. Run `typeclaw init` first, or cd into an agent folder.'))
154
+ process.exit(1)
155
+ }
156
+ return cwd
157
+ }
package/src/cli/update.ts CHANGED
@@ -9,7 +9,7 @@ const MANAGERS = ['auto', 'bun', 'npm', 'pnpm', 'yarn'] as const
9
9
  export const updateCommand = defineCommand({
10
10
  meta: {
11
11
  name: 'update',
12
- description: 'update the globally installed typeclaw CLI',
12
+ description: 'update the installed typeclaw CLI (auto-detects global vs local)',
13
13
  },
14
14
  args: {
15
15
  manager: {
@@ -42,8 +42,9 @@ export const updateCommand = defineCommand({
42
42
  return
43
43
  }
44
44
 
45
- process.stdout.write(`${c.cyan('Updating TypeClaw with:')} ${rendered}\n`)
46
- const exitCode = await runUpdateCommand(plan.command)
45
+ const scopeLabel = plan.scope === 'global' ? 'global' : `local (${plan.cwd ?? '.'})`
46
+ process.stdout.write(`${c.cyan(`Updating TypeClaw [${scopeLabel}] with:`)} ${rendered}\n`)
47
+ const exitCode = await runUpdateCommand(plan.command, plan.cwd)
47
48
  if (exitCode !== 0) {
48
49
  console.error(errorLine(`Update command exited with code ${exitCode}.`))
49
50
  process.exit(exitCode)
@@ -58,7 +59,7 @@ function parseManager(value: string | undefined): UpdateManagerSelection | null
58
59
  return (MANAGERS as readonly string[]).includes(value) ? (value as UpdateManagerSelection) : null
59
60
  }
60
61
 
61
- async function runUpdateCommand(command: string[]): Promise<number> {
62
+ async function runUpdateCommand(command: string[], cwd: string | undefined): Promise<number> {
62
63
  const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
63
64
  if (!bun) {
64
65
  console.error(errorLine('bun runtime not available'))
@@ -67,6 +68,7 @@ async function runUpdateCommand(command: string[]): Promise<number> {
67
68
  try {
68
69
  const proc = bun.spawn({
69
70
  cmd: command,
71
+ cwd,
70
72
  stdin: 'inherit',
71
73
  stdout: 'inherit',
72
74
  stderr: 'inherit',
@@ -0,0 +1,161 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { commitSystemFileSync } from '@/git/system-commit'
5
+
6
+ import {
7
+ configSchema,
8
+ expandMountPath,
9
+ loadConfigSyncOrDefaults,
10
+ mountSchema,
11
+ validateMount,
12
+ type Mount,
13
+ } from './config'
14
+
15
+ const CONFIG_FILE = 'typeclaw.json'
16
+ const MOUNT_TARGET_PREFIX = '/agent/mounts'
17
+
18
+ export type MountListEntry = Mount & {
19
+ resolvedPath: string
20
+ targetPath: string
21
+ status: 'ok' | 'error'
22
+ statusReason?: string
23
+ }
24
+
25
+ export type AddMountOptions = {
26
+ readOnly?: boolean
27
+ description?: string | undefined
28
+ }
29
+
30
+ export type AddMountResult = { ok: true; entry: MountListEntry } | { ok: false; reason: string }
31
+ export type RemoveMountResult = { ok: true; removed: MountListEntry } | { ok: false; reason: string }
32
+
33
+ export function listMounts(cwd: string): MountListEntry[] {
34
+ const mounts = loadConfigSyncOrDefaults(cwd).mounts
35
+ return mounts.map((mount) => toListEntry(mount, cwd))
36
+ }
37
+
38
+ export function addMount(cwd: string, name: string, path: string, options: AddMountOptions = {}): AddMountResult {
39
+ const mount = buildMount(name, path, options)
40
+ if (!mount.ok) return mount
41
+
42
+ const check = validateMount(mount.value, cwd)
43
+ if (!check.ok) return check
44
+
45
+ const parsed = readConfigRecord(cwd)
46
+ if (!parsed.ok) return parsed
47
+
48
+ const current = readMounts(parsed.value)
49
+ if (!current.ok) return current
50
+ if (current.value.some((m) => m.name === mount.value.name)) {
51
+ return {
52
+ ok: false,
53
+ reason: `Mount "${mount.value.name}" already exists. Remove it first with \`typeclaw mount remove ${mount.value.name}\`.`,
54
+ }
55
+ }
56
+
57
+ const next = { ...parsed.value, mounts: [...current.value, mount.value] }
58
+ const write = writeMounts(cwd, next, `mount: add ${mount.value.name}`)
59
+ if (!write.ok) return write
60
+ return { ok: true, entry: toListEntry(mount.value, cwd) }
61
+ }
62
+
63
+ export function removeMount(cwd: string, name: string): RemoveMountResult {
64
+ const trimmed = name.trim()
65
+ if (trimmed.length === 0) return { ok: false, reason: 'Mount name cannot be empty.' }
66
+
67
+ const parsed = readConfigRecord(cwd)
68
+ if (!parsed.ok) return parsed
69
+
70
+ const current = readMounts(parsed.value)
71
+ if (!current.ok) return current
72
+
73
+ const removed = current.value.find((m) => m.name === trimmed)
74
+ if (removed === undefined) return { ok: false, reason: `Mount "${trimmed}" not found in ${CONFIG_FILE}.` }
75
+
76
+ const next = { ...parsed.value, mounts: current.value.filter((m) => m.name !== trimmed) }
77
+ const write = writeMounts(cwd, next, `mount: remove ${trimmed}`)
78
+ if (!write.ok) return write
79
+ return { ok: true, removed: toListEntry(removed, cwd) }
80
+ }
81
+
82
+ function buildMount(
83
+ name: string,
84
+ path: string,
85
+ options: AddMountOptions,
86
+ ): { ok: true; value: Mount } | { ok: false; reason: string } {
87
+ const description = options.description?.trim()
88
+ const raw = {
89
+ name: name.trim(),
90
+ path,
91
+ readOnly: options.readOnly ?? false,
92
+ ...(description !== undefined && description.length > 0 ? { description } : {}),
93
+ }
94
+ const parsed = mountSchema.safeParse(raw)
95
+ if (!parsed.success) {
96
+ return { ok: false, reason: parsed.error.issues.map(formatIssue).join('; ') }
97
+ }
98
+ return { ok: true, value: parsed.data }
99
+ }
100
+
101
+ function readConfigRecord(cwd: string): { ok: true; value: Record<string, unknown> } | { ok: false; reason: string } {
102
+ try {
103
+ const raw = readFileSync(join(cwd, CONFIG_FILE), 'utf8')
104
+ const parsed = JSON.parse(raw) as unknown
105
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
106
+ return { ok: false, reason: `${CONFIG_FILE} must contain a JSON object.` }
107
+ }
108
+ return { ok: true, value: parsed as Record<string, unknown> }
109
+ } catch (error) {
110
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
111
+ return { ok: false, reason: `${CONFIG_FILE} not found at ${cwd}. Run \`typeclaw init\` first.` }
112
+ }
113
+ return { ok: false, reason: `Failed to read ${CONFIG_FILE}: ${(error as Error).message}` }
114
+ }
115
+ }
116
+
117
+ function readMounts(record: Record<string, unknown>): { ok: true; value: Mount[] } | { ok: false; reason: string } {
118
+ const parsed = configSchema.safeParse(record)
119
+ if (!parsed.success) {
120
+ return { ok: false, reason: `${CONFIG_FILE} is invalid: ${parsed.error.issues.map(formatIssue).join('; ')}` }
121
+ }
122
+ return { ok: true, value: parsed.data.mounts }
123
+ }
124
+
125
+ function writeMounts(
126
+ cwd: string,
127
+ record: Record<string, unknown>,
128
+ commitMessage: string,
129
+ ): { ok: true } | { ok: false; reason: string } {
130
+ const parsed = configSchema.safeParse(record)
131
+ if (!parsed.success) {
132
+ return { ok: false, reason: `mounts block would be invalid: ${parsed.error.issues.map(formatIssue).join('; ')}` }
133
+ }
134
+
135
+ try {
136
+ writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(record, null, 2)}\n`)
137
+ } catch (error) {
138
+ return { ok: false, reason: `Failed to write ${CONFIG_FILE}: ${(error as Error).message}` }
139
+ }
140
+
141
+ commitSystemFileSync(cwd, CONFIG_FILE, commitMessage)
142
+ return { ok: true }
143
+ }
144
+
145
+ function toListEntry(mount: Mount, cwd: string): MountListEntry {
146
+ const resolvedPath = expandMountPath(mount.path, cwd)
147
+ const targetPath = `${MOUNT_TARGET_PREFIX}/${mount.name}`
148
+ const check = validateMount(mount, cwd)
149
+ return {
150
+ ...mount,
151
+ resolvedPath,
152
+ targetPath,
153
+ status: check.ok ? 'ok' : 'error',
154
+ ...(!check.ok ? { statusReason: check.reason } : {}),
155
+ }
156
+ }
157
+
158
+ function formatIssue(issue: { path: PropertyKey[]; message: string }): string {
159
+ const path = issue.path.length > 0 ? issue.path.map(String).join('.') : '<root>'
160
+ return `${path}: ${issue.message}`
161
+ }
@@ -38,7 +38,7 @@ Routing answers:
38
38
  1. \`write\` your name into \`IDENTITY.md\` (a first-person one-liner is fine: "I am <name>.").
39
39
  2. ${q1AliasStep} The agent folder's directory name is already an implicit alias — only add the answered name explicitly when it differs from the dir name (different casing, a different word, or extra forms like "<name>" plus a Latin transliteration). This wires plain-text addressing in channels: when a user writes your name in chat without an @-mention, the engagement layer will recognize it. \`alias\` is live-reloadable.
40
40
  2. **Q2 — the user's name.** Ask what to call them. After the answer: \`write\` it to both \`IDENTITY.md\` and \`USER.md\`.
41
- 3. **Q3 — tone/personality.** Ask how they want you to show up (tone, language, formality). After the answer: \`write\` it into \`SOUL.md\`. If they shrug or don't care: **default to warm, friendly, and easygoing** — a kind colleague who genuinely likes the person they work with, uses contractions, makes small jokes, never stiff. Write that as the default into \`SOUL.md\`.
41
+ 3. **Q3 — tone/personality.** Ask how they want you to show up (tone, language, formality). After the answer: \`write\` it into \`SOUL.md\`. If they shrug or don't care: **default to warm, friendly, and easygoing** — a kind colleague who genuinely likes the person they work with, uses contractions, makes small jokes, never stiff. Write that as the default into \`SOUL.md\`. **Kaomoji affinity** — if their answer leans cute, adorable, warm, playful, soft, cozy, or is in Korean asking for 친근/귀엽/다정한 tone, append a line to \`SOUL.md\` like: \`I lean on kaomojis like (◕‿◕✿) and (。・ω・。) to carry warmth — emojis still welcome when they actually mean something, but kaomojis lead.\` This makes the bundled \`typeclaw-kaomoji\` skill auto-load later when you need it. Do not force this line if the user asked for a neutral, professional, or terse tone.
42
42
 
43
43
  **Do not ask what they want you to do, what project you'll work on, or why they installed you.** That reveals itself when they give you a real task. Probing here makes the tool feel heavy for someone just trying it out.
44
44
 
@@ -75,6 +75,12 @@ export type { PermissionService } from '@/permissions'
75
75
  export type { LoadPluginEntryFn, ResolvedPlugin } from './loader'
76
76
  export { loadPluginEntry, derivePluginNameFromPackage } from './loader'
77
77
  export { materializeSkills, type MaterializedSkills, type SkillEntry } from './skills'
78
+ export {
79
+ createLoadSkillTool,
80
+ type CreateLoadSkillToolOptions,
81
+ type LoadableSkill,
82
+ type LoadSkillArgs,
83
+ } from './load-skill'
78
84
  export {
79
85
  buildPluginCronGlobalId,
80
86
  RESERVED_COMMAND_NAMES,
@@ -0,0 +1,99 @@
1
+ import { z } from 'zod'
2
+
3
+ import { defineTool } from './define'
4
+ import type { Tool } from './types'
5
+
6
+ // One unit of curated skill content a subagent can load on demand. The
7
+ // `name` becomes a value in the tool's `name` enum; `description` is what
8
+ // the model sees in the tool description block so it can decide which
9
+ // skill to load WITHOUT having to load it first; `content` is the body
10
+ // returned by the tool when the model picks this skill.
11
+ export type LoadableSkill = {
12
+ name: string
13
+ description: string
14
+ content: string
15
+ }
16
+
17
+ export type CreateLoadSkillToolOptions = {
18
+ skills: readonly LoadableSkill[]
19
+ // Override the tool's top-level description. Defaults to a generic
20
+ // explanation of how the tool works followed by the per-skill menu.
21
+ // Plugins can pass a more specific framing (e.g. "Load a review skill
22
+ // …") so the subagent's instructions line up with the tool name.
23
+ description?: string
24
+ }
25
+
26
+ export type LoadSkillArgs = { name: string }
27
+
28
+ // Build a typed `load_skill` tool a subagent can call to fetch the body
29
+ // of a curated skill on demand. The factory closes over the `skills`
30
+ // list so:
31
+ // - the `name` parameter is a Zod enum narrowed to exactly the skill
32
+ // names the caller supplied (typo-resistant; the model sees the
33
+ // allowed values in the tool's JSON Schema),
34
+ // - the tool's `description` lists every skill's name + description so
35
+ // the model can choose the right one BEFORE calling the tool, paying
36
+ // only one tool-call's worth of context for the body it actually
37
+ // needs,
38
+ // - unknown names are rejected by parameter validation, not by the
39
+ // handler — the runtime returns the validation error before
40
+ // `execute` runs.
41
+ //
42
+ // This is the runtime-loaded counterpart to TypeClaw's startup-time
43
+ // skill discovery (`additionalSkillPaths` in `src/agent/index.ts`).
44
+ // Subagents bypass the file-based resource loader, so the startup path
45
+ // is unavailable to them; this factory gives plugin authors a typed way
46
+ // to expose curated skills to their subagents via `customTools`.
47
+ export function createLoadSkillTool(options: CreateLoadSkillToolOptions): Tool<LoadSkillArgs> {
48
+ const { skills } = options
49
+
50
+ if (skills.length === 0) {
51
+ throw new Error('createLoadSkillTool: `skills` must contain at least one entry')
52
+ }
53
+
54
+ const seen = new Set<string>()
55
+ for (const skill of skills) {
56
+ if (skill.name.length === 0) {
57
+ throw new Error('createLoadSkillTool: skill name must be non-empty')
58
+ }
59
+ if (seen.has(skill.name)) {
60
+ throw new Error(`createLoadSkillTool: duplicate skill name ${JSON.stringify(skill.name)}`)
61
+ }
62
+ seen.add(skill.name)
63
+ }
64
+
65
+ // z.enum requires a `[string, ...string[]]` tuple. Build it from the
66
+ // skill list so the JSON Schema surfaced to the model lists exactly
67
+ // the allowed values.
68
+ const names = skills.map((s) => s.name) as [string, ...string[]]
69
+
70
+ const description = options.description ?? buildDefaultDescription(skills)
71
+
72
+ return defineTool<LoadSkillArgs>({
73
+ description,
74
+ parameters: z.object({
75
+ name: z.enum(names).describe('The name of the skill to load. Must match one of the available skills.'),
76
+ }),
77
+ async execute(args) {
78
+ const skill = skills.find((s) => s.name === args.name)
79
+ if (skill === undefined) {
80
+ // Defensive: Zod enum validation should have rejected this
81
+ // before reaching the handler. Surface a clear message anyway.
82
+ const available = names.join(', ')
83
+ throw new Error(`Unknown skill ${JSON.stringify(args.name)}. Available skills: ${available}.`)
84
+ }
85
+ return {
86
+ content: [{ type: 'text' as const, text: skill.content }],
87
+ details: { name: skill.name, contentBytes: skill.content.length },
88
+ }
89
+ },
90
+ })
91
+ }
92
+
93
+ function buildDefaultDescription(skills: readonly LoadableSkill[]): string {
94
+ const menu = skills.map((s) => `- \`${s.name}\` — ${s.description}`).join('\n')
95
+ return `Load a curated skill by name. Returns the full skill body as text so you can apply it to the current task. Call this when you have identified which skill matches the task; do NOT load multiple skills speculatively.
96
+
97
+ Available skills:
98
+ ${menu}`
99
+ }
@@ -4,6 +4,7 @@ import explorerPlugin from '@/bundled-plugins/explorer'
4
4
  import guardPlugin from '@/bundled-plugins/guard'
5
5
  import memoryPlugin from '@/bundled-plugins/memory'
6
6
  import operatorPlugin from '@/bundled-plugins/operator'
7
+ import reviewerPlugin from '@/bundled-plugins/reviewer'
7
8
  import scoutPlugin from '@/bundled-plugins/scout'
8
9
  import securityPlugin from '@/bundled-plugins/security'
9
10
  import toolResultCapPlugin from '@/bundled-plugins/tool-result-cap'
@@ -41,5 +42,6 @@ export const BUNDLED_PLUGINS: ResolvedPlugin[] = [
41
42
  { name: 'agent-browser', version: undefined, source: '<bundled>', defined: agentBrowserPlugin },
42
43
  { name: 'explorer', version: undefined, source: '<bundled>', defined: explorerPlugin },
43
44
  { name: 'scout', version: undefined, source: '<bundled>', defined: scoutPlugin },
45
+ { name: 'reviewer', version: undefined, source: '<bundled>', defined: reviewerPlugin },
44
46
  { name: 'operator', version: undefined, source: '<bundled>', defined: operatorPlugin },
45
47
  ]
package/src/run/index.ts CHANGED
@@ -43,7 +43,7 @@ import type { CronHandlerContext } from '@/plugin/types'
43
43
  import { createContainerBroker, publishForwardResult } from '@/portbroker'
44
44
  import { ReloadRegistry } from '@/reload'
45
45
  import { createClaimController } from '@/role-claim'
46
- import { hydrateChannelEnvFromSecrets } from '@/secrets'
46
+ import { exportCodexAuthFileForAgent, hydrateChannelEnvFromSecrets } from '@/secrets'
47
47
  import { createServer, type Server } from '@/server'
48
48
  import {
49
49
  createCommandRunner,
@@ -181,6 +181,19 @@ export async function startAgent({
181
181
  // stay in env, the file stays user-owned. See src/secrets/hydrate.ts.
182
182
  hydrateChannelEnvFromSecrets({ agentDir: cwd })
183
183
 
184
+ // When the user has `docker.file.codexCli: true` AND a typeclaw-managed
185
+ // openai-codex OAuth credential in secrets.json, write ~/.codex/auth.json
186
+ // so the Codex CLI in the container can run without a second login. The
187
+ // exporter is failure-tolerant by design: any error (gate miss, fs error,
188
+ // corrupt file) returns a non-fatal result and the agent boot continues.
189
+ // See src/secrets/export-codex-auth-file.ts for the newer-wins compare
190
+ // that prevents clobbering Codex CLI's in-place token refreshes.
191
+ exportCodexAuthFileForAgent({
192
+ agentDir: cwd,
193
+ codexCliEnabled: cwdConfig.docker.file.codexCli,
194
+ log: (message) => console.warn(message),
195
+ })
196
+
184
197
  const claimController = createClaimController({
185
198
  cwd,
186
199
  permissions: pluginsLoaded.permissions,
@@ -0,0 +1,67 @@
1
+ import type { ProviderCredential } from './schema'
2
+
3
+ // Emit the on-disk shape Codex CLI consumes at ~/.codex/auth.json. Mirrors
4
+ // the modern (>= 0.93) shape: a single `tokens` object with access_token,
5
+ // refresh_token, and optional account_id. Codex re-derives token expiry
6
+ // from the JWT's `exp` claim on every load, so we deliberately omit a
7
+ // top-level `expires` field even though typeclaw stores one.
8
+ //
9
+ // Pre-0.93 codex used a different layout (top-level OPENAI_API_KEY +
10
+ // auth_mode discriminator + optional tokens). We don't emit that legacy
11
+ // shape — every codex install old enough to require it has been replaced
12
+ // by the version `docker.file.codexCli` installs in the container.
13
+ export function emitCodexAuthJson(credential: ProviderCredential): string {
14
+ if (credential.type !== 'oauth') {
15
+ throw new Error('emitCodexAuthJson only accepts oauth-typed credentials')
16
+ }
17
+ const fields = credential as ProviderCredential & {
18
+ access?: unknown
19
+ refresh?: unknown
20
+ accountId?: unknown
21
+ }
22
+ const access = fields.access
23
+ const refresh = fields.refresh
24
+ if (typeof access !== 'string' || access.length === 0) {
25
+ throw new Error('credential is missing a non-empty `access` field')
26
+ }
27
+ if (typeof refresh !== 'string' || refresh.length === 0) {
28
+ throw new Error('credential is missing a non-empty `refresh` field')
29
+ }
30
+
31
+ const tokens: Record<string, string> = { access_token: access, refresh_token: refresh }
32
+ if (typeof fields.accountId === 'string' && fields.accountId.length > 0) {
33
+ tokens['account_id'] = fields.accountId
34
+ }
35
+ return `${JSON.stringify({ tokens }, null, 2)}\n`
36
+ }
37
+
38
+ // Extracts the JWT `exp` claim (seconds since epoch) and converts to ms.
39
+ // Used by the runtime exporter's newer-wins compare: ~/.codex/auth.json
40
+ // carries no top-level expiry, but the JWT inside `tokens.access_token`
41
+ // does. Returns null on any decode failure; the caller treats that as
42
+ // "unknown freshness" and falls back to overwriting from typeclaw's copy.
43
+ export function decodeCodexAccessTokenExpiryMs(accessToken: string): number | null {
44
+ const parts = accessToken.split('.')
45
+ if (parts.length !== 3) return null
46
+ const middle = parts[1] ?? ''
47
+ if (middle === '') return null
48
+ // base64url → base64, then pad to a multiple of 4 (atob is strict).
49
+ const normalized = middle.replace(/-/g, '+').replace(/_/g, '/')
50
+ const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
51
+ let payload: Record<string, unknown>
52
+ try {
53
+ const decoded = typeof atob === 'function' ? atob(padded) : Buffer.from(padded, 'base64').toString('utf8')
54
+ const parsed: unknown = JSON.parse(decoded)
55
+ if (!isPlainObject(parsed)) return null
56
+ payload = parsed
57
+ } catch {
58
+ return null
59
+ }
60
+ const exp = payload['exp']
61
+ if (typeof exp !== 'number' || !Number.isFinite(exp)) return null
62
+ return Math.floor(exp * 1000)
63
+ }
64
+
65
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
66
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
67
+ }