typeclaw 0.11.1 → 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 (53) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/scripts/dump-system-prompt.ts +12 -11
  4. package/src/agent/index.ts +15 -22
  5. package/src/agent/loop-guard.ts +170 -0
  6. package/src/agent/model-fallback.ts +2 -1
  7. package/src/agent/multimodal/index.ts +1 -1
  8. package/src/agent/multimodal/look-at.ts +118 -55
  9. package/src/agent/plugin-tools.ts +57 -0
  10. package/src/agent/subagents.ts +2 -1
  11. package/src/agent/system-prompt.ts +28 -25
  12. package/src/agent/tools/channel-fetch-attachment.ts +45 -16
  13. package/src/agent/tools/normalize-ref.ts +11 -0
  14. package/src/bundled-plugins/reviewer/index.ts +11 -0
  15. package/src/bundled-plugins/reviewer/reviewer.ts +171 -0
  16. package/src/bundled-plugins/reviewer/skills/code-review.ts +73 -0
  17. package/src/bundled-plugins/reviewer/skills/general.ts +68 -0
  18. package/src/channels/adapters/discord-bot-classify.ts +32 -24
  19. package/src/channels/adapters/github/inbound.ts +19 -2
  20. package/src/channels/adapters/kakaotalk-attachment.ts +140 -133
  21. package/src/channels/adapters/kakaotalk-classify.ts +8 -1
  22. package/src/channels/adapters/kakaotalk.ts +19 -11
  23. package/src/channels/adapters/slack-bot-classify.ts +30 -14
  24. package/src/channels/adapters/slack-bot.ts +3 -2
  25. package/src/channels/adapters/telegram-bot-classify.ts +36 -13
  26. package/src/channels/adapters/telegram-bot.ts +3 -3
  27. package/src/channels/outbound-flood-filter.ts +57 -0
  28. package/src/channels/router.ts +93 -5
  29. package/src/channels/types.ts +52 -1
  30. package/src/cli/builtins.ts +2 -0
  31. package/src/cli/index.ts +2 -0
  32. package/src/cli/mount.ts +157 -0
  33. package/src/cli/update.ts +84 -0
  34. package/src/config/mounts-mutation.ts +161 -0
  35. package/src/init/hatching.ts +1 -1
  36. package/src/plugin/index.ts +6 -0
  37. package/src/plugin/load-skill.ts +99 -0
  38. package/src/run/bundled-plugins.ts +2 -0
  39. package/src/run/index.ts +14 -1
  40. package/src/secrets/codex-auth-json.ts +67 -0
  41. package/src/secrets/export-codex-auth-file.ts +243 -0
  42. package/src/secrets/index.ts +6 -0
  43. package/src/server/command-runner.ts +2 -1
  44. package/src/server/index.ts +3 -2
  45. package/src/shared/index.ts +7 -1
  46. package/src/shared/local-time.ts +32 -0
  47. package/src/skills/typeclaw-channel-github/SKILL.md +47 -13
  48. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +10 -11
  49. package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +8 -0
  50. package/src/skills/typeclaw-codex-cli/SKILL.md +2 -1
  51. package/src/skills/typeclaw-codex-cli/references/auth-flow.md +22 -0
  52. package/src/skills/typeclaw-kaomoji/SKILL.md +116 -0
  53. package/src/update/index.ts +155 -0
@@ -0,0 +1,243 @@
1
+ import {
2
+ chmodSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ readlinkSync,
6
+ renameSync,
7
+ statSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from 'node:fs'
11
+ import { homedir } from 'node:os'
12
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
13
+
14
+ import { decodeCodexAccessTokenExpiryMs, emitCodexAuthJson } from './codex-auth-json'
15
+ import type { ProviderCredential, Providers } from './schema'
16
+ import { SecretsBackend } from './storage'
17
+
18
+ const FILE_MODE = 0o600
19
+ const DIR_MODE = 0o700
20
+
21
+ export type ExportCodexAuthFileResult =
22
+ | { action: 'skipped'; reason: SkipReason }
23
+ | { action: 'wrote'; path: string }
24
+ | { action: 'failed'; reason: string }
25
+
26
+ export type SkipReason =
27
+ | 'codex-cli-disabled'
28
+ | 'no-openai-codex-credential'
29
+ | 'credential-not-oauth'
30
+ | 'on-disk-is-fresher'
31
+
32
+ export type ExportCodexAuthFileOptions = {
33
+ codexCliEnabled: boolean
34
+ providers: Providers
35
+ homeDir?: string
36
+ now?: () => number
37
+ log?: (message: string) => void
38
+ }
39
+
40
+ // Writes typeclaw's openai-codex OAuth credential to $HOME/.codex/auth.json
41
+ // when it's safe to do so. The Dockerfile entrypoint shim symlinks
42
+ // $HOME/.codex/auth.json to /agent/.typeclaw/home/.codex/auth.json on every
43
+ // boot, so the write follows the symlink and lands on the persistent
44
+ // host-side path — that's the stable contract from src/init/dockerfile.ts
45
+ // "link_persistent_home_files" and we MUST use it instead of writing to
46
+ // /agent/.typeclaw/home/ directly.
47
+ //
48
+ // Three guards, cheapest first. The first two return without ever touching
49
+ // the filesystem, which keeps the 90% case (users who don't enable Codex
50
+ // CLI) at zero overhead on every container start.
51
+ export function exportCodexAuthFileIfApplicable(options: ExportCodexAuthFileOptions): ExportCodexAuthFileResult {
52
+ if (!options.codexCliEnabled) return { action: 'skipped', reason: 'codex-cli-disabled' }
53
+
54
+ const credential = options.providers['openai-codex']
55
+ if (credential === undefined) return { action: 'skipped', reason: 'no-openai-codex-credential' }
56
+ if (credential.type !== 'oauth') return { action: 'skipped', reason: 'credential-not-oauth' }
57
+
58
+ const targetPath = join(options.homeDir ?? homedir(), '.codex', 'auth.json')
59
+
60
+ try {
61
+ if (!shouldOverwrite(targetPath, credential, options.now ?? Date.now)) {
62
+ return { action: 'skipped', reason: 'on-disk-is-fresher' }
63
+ }
64
+ const contents = emitCodexAuthJson(credential)
65
+ writeAtomic(targetPath, contents)
66
+ return { action: 'wrote', path: targetPath }
67
+ } catch (err) {
68
+ const reason = err instanceof Error ? err.message : String(err)
69
+ options.log?.(`exportCodexAuthFile: ${reason}`)
70
+ return { action: 'failed', reason }
71
+ }
72
+ }
73
+
74
+ // Newer-wins: skip the write unless typeclaw's stored credential is
75
+ // strictly fresher than the on-disk JWT. Codex CLI rotates tokens
76
+ // in-place (it rewrites auth.json with a refreshed access_token whose
77
+ // JWT exp is later), so on a restart the file may legitimately be ahead
78
+ // of secrets.json. We must not clobber that.
79
+ //
80
+ // Ties skip: when expiries match, there's nothing to gain from a write,
81
+ // and avoiding the I/O keeps the steady state at zero churn after the
82
+ // first boot. The only writes we ever do are first-write (B1), recovery
83
+ // (B6), or refresh-from-typeclaw-side (B3).
84
+ //
85
+ // On any error reading or parsing the on-disk file (missing, corrupt JSON,
86
+ // missing JWT, undecodable exp), we return true. That's the "we have a
87
+ // valid credential, the file is unusable, replace it" fallback case (B1
88
+ // and B6 in the design doc).
89
+ function shouldOverwrite(
90
+ targetPath: string,
91
+ credential: ProviderCredential & { expires?: unknown; access?: unknown },
92
+ now: () => number,
93
+ ): boolean {
94
+ let raw: string
95
+ try {
96
+ raw = readFileSync(targetPath, 'utf8')
97
+ } catch {
98
+ return true
99
+ }
100
+
101
+ let parsed: unknown
102
+ try {
103
+ parsed = JSON.parse(raw)
104
+ } catch {
105
+ return true
106
+ }
107
+
108
+ const onDiskAccess = readOnDiskAccessToken(parsed)
109
+ if (onDiskAccess === null) return true
110
+
111
+ const onDiskExpiry = decodeCodexAccessTokenExpiryMs(onDiskAccess)
112
+ if (onDiskExpiry === null) return true
113
+
114
+ const credentialExpiry = readCredentialExpiry(credential, now)
115
+ return credentialExpiry > onDiskExpiry
116
+ }
117
+
118
+ function readOnDiskAccessToken(parsed: unknown): string | null {
119
+ if (typeof parsed !== 'object' || parsed === null) return null
120
+ const tokens = (parsed as Record<string, unknown>)['tokens']
121
+ if (typeof tokens !== 'object' || tokens === null) return null
122
+ const access = (tokens as Record<string, unknown>)['access_token']
123
+ return typeof access === 'string' && access.length > 0 ? access : null
124
+ }
125
+
126
+ // Resolution order for the credential's expiry:
127
+ // 1. The `expires` field pi-ai writes (absolute ms epoch).
128
+ // 2. The JWT `exp` claim decoded from `access`.
129
+ // 3. Now — guarantees we still write on first boot when the credential
130
+ // lacks both, rather than silently skipping forever.
131
+ function readCredentialExpiry(credential: { expires?: unknown; access?: unknown }, now: () => number): number {
132
+ if (typeof credential.expires === 'number' && Number.isFinite(credential.expires)) {
133
+ return credential.expires
134
+ }
135
+ if (typeof credential.access === 'string') {
136
+ const fromJwt = decodeCodexAccessTokenExpiryMs(credential.access)
137
+ if (fromJwt !== null) return fromJwt
138
+ }
139
+ return now()
140
+ }
141
+
142
+ // Atomic temp-then-rename, mirroring src/secrets/storage.ts's
143
+ // writeEnvelopeAtomic. The directory is created with 0700 and the file
144
+ // with 0600 because $HOME/.codex/auth.json holds a long-lived refresh
145
+ // token — leaking it via lax permissions defeats the whole point of
146
+ // running typeclaw on a multi-user host. The 0600 chmod after rename is
147
+ // belt-and-suspenders: writeFileSync's `mode` is applied at create time,
148
+ // but umask can mask it down on some filesystems.
149
+ //
150
+ // Symlink preservation: the entrypoint shim
151
+ // (src/init/dockerfile.ts link_persistent_home_files) installs
152
+ // $HOME/.codex/auth.json as a symlink to
153
+ // /agent/.typeclaw/home/.codex/auth.json on every boot. POSIX rename(2)
154
+ // replaces the directory entry at the destination atomically — it does
155
+ // NOT follow symlinks — so a naive `renameSync(tmp, $HOME/.codex/auth.json)`
156
+ // would replace the symlink with a regular file, leaving the persistent
157
+ // path empty. Next boot the shim recreates the symlink (force-removing
158
+ // our file), the persistent path is still empty, and Codex's in-place
159
+ // token refresh is silently lost on every restart.
160
+ //
161
+ // Fix: resolve the symlink target with readlinkSync and rename against
162
+ // the real path so the symlink itself is preserved. The temp file MUST
163
+ // live alongside the real target (same filesystem) because renameSync
164
+ // across filesystems fails with EXDEV — $HOME is the container's
165
+ // overlayfs, but the symlink target is a bind-mounted host path.
166
+ function writeAtomic(targetPath: string, contents: string): void {
167
+ const realTarget = resolveSymlinkTarget(targetPath)
168
+ const dir = dirname(realTarget)
169
+ mkdirSync(dir, { recursive: true, mode: DIR_MODE })
170
+ const tmp = `${realTarget}.${process.pid}.${Date.now()}.tmp`
171
+ writeFileSync(tmp, contents, { encoding: 'utf8', mode: FILE_MODE })
172
+ try {
173
+ renameSync(tmp, realTarget)
174
+ } catch (err) {
175
+ try {
176
+ unlinkSync(tmp)
177
+ } catch {
178
+ // best-effort cleanup of the temp file when rename fails
179
+ }
180
+ throw err
181
+ }
182
+ // statSync + chmodSync rather than unconditional chmod so a 0644 file
183
+ // installed by something else stays visible in tests (we WANT to overwrite
184
+ // permissions when we own the file).
185
+ try {
186
+ statSync(realTarget)
187
+ chmodSync(realTarget, FILE_MODE)
188
+ } catch {
189
+ // ignore — file vanished between rename and chmod is benign
190
+ }
191
+ }
192
+
193
+ // Returns the absolute path renameSync should target. When `path` is a
194
+ // symlink (production: $HOME/.codex/auth.json -> /agent/.typeclaw/home/...),
195
+ // returns the resolved absolute target so we write through the link
196
+ // instead of replacing it. Otherwise (tests, or first boot before the
197
+ // shim installs the symlink — though the shim runs before the agent in
198
+ // production), returns the path unchanged.
199
+ //
200
+ // readlinkSync throws EINVAL when the path exists but isn't a symlink,
201
+ // and ENOENT when nothing is there. Either case → write to the original
202
+ // path; the parent-dir mkdir + atomic rename handle the rest. We don't
203
+ // distinguish errno because both have the same fallback.
204
+ function resolveSymlinkTarget(path: string): string {
205
+ let link: string
206
+ try {
207
+ link = readlinkSync(path)
208
+ } catch {
209
+ return path
210
+ }
211
+ return isAbsolute(link) ? link : resolve(dirname(path), link)
212
+ }
213
+
214
+ export type ExportCodexAuthFileForAgentOptions = {
215
+ agentDir: string
216
+ codexCliEnabled: boolean
217
+ homeDir?: string
218
+ log?: (message: string) => void
219
+ }
220
+
221
+ // Boot-time convenience wrapper for src/run/index.ts. Mirrors
222
+ // hydrateChannelEnvFromSecrets's contract: takes agentDir, never throws,
223
+ // returns a result the caller can ignore. Secrets-file read failures are
224
+ // caught and surfaced as a 'failed' result so the agent boot is not blocked
225
+ // by a missing or malformed secrets.json — same non-fatal policy hydrate
226
+ // uses on the channels slice.
227
+ export function exportCodexAuthFileForAgent(options: ExportCodexAuthFileForAgentOptions): ExportCodexAuthFileResult {
228
+ if (!options.codexCliEnabled) return { action: 'skipped', reason: 'codex-cli-disabled' }
229
+ let providers: Providers
230
+ try {
231
+ providers = new SecretsBackend(join(options.agentDir, 'secrets.json')).tryReadProvidersSync()
232
+ } catch (err) {
233
+ const reason = err instanceof Error ? err.message : String(err)
234
+ options.log?.(`exportCodexAuthFile: ${reason}`)
235
+ return { action: 'failed', reason }
236
+ }
237
+ return exportCodexAuthFileIfApplicable({
238
+ codexCliEnabled: options.codexCliEnabled,
239
+ providers,
240
+ ...(options.homeDir !== undefined ? { homeDir: options.homeDir } : {}),
241
+ ...(options.log !== undefined ? { log: options.log } : {}),
242
+ })
243
+ }
@@ -7,3 +7,9 @@ export { type Secret } from './resolve'
7
7
  export { hydrateChannelEnvFromSecrets } from './hydrate'
8
8
 
9
9
  export { migrateKakaotalkCredentials } from './migrate-kakaotalk'
10
+
11
+ export {
12
+ type ExportCodexAuthFileResult,
13
+ exportCodexAuthFileForAgent,
14
+ exportCodexAuthFileIfApplicable,
15
+ } from './export-codex-auth-file'
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  createSessionWithDispose,
3
+ renderTurnTimeAnchor,
3
4
  type CreateSessionOptions,
4
5
  type CreateSessionResult,
5
6
  type SessionOrigin,
@@ -392,7 +393,7 @@ export async function runPromptForCommand(args: {
392
393
  })
393
394
  const detachAbort = bindSignalToSession(args.signal, session)
394
395
  try {
395
- await session.prompt(args.text)
396
+ await session.prompt(`${renderTurnTimeAnchor()}\n\n${args.text}`)
396
397
  return session.getLastAssistantText() ?? ''
397
398
  } finally {
398
399
  detachAbort()
@@ -3,6 +3,7 @@ import type { Server as BunServer, ServerWebSocket } from 'bun'
3
3
 
4
4
  import {
5
5
  createSessionWithDispose as defaultCreateSessionWithDispose,
6
+ renderTurnTimeAnchor,
6
7
  type AgentSession,
7
8
  type CreateSessionOptions,
8
9
  type CreateSessionResult,
@@ -711,7 +712,7 @@ export function createServer({
711
712
  })
712
713
  }
713
714
  try {
714
- await state.session.prompt(msg.text)
715
+ await state.session.prompt(`${renderTurnTimeAnchor()}\n\n${msg.text}`)
715
716
  send(ws, { type: 'done' })
716
717
  } catch (err) {
717
718
  const message = err instanceof Error ? err.message : String(err)
@@ -951,7 +952,7 @@ async function drain(ws: Ws, state: SessionState, agentDir: string | undefined,
951
952
 
952
953
  await fireTurnStart(item.text)
953
954
  try {
954
- await state.session.prompt(item.text)
955
+ await state.session.prompt(`${renderTurnTimeAnchor()}\n\n${item.text}`)
955
956
  send(ws, { type: 'done' })
956
957
  } catch (err) {
957
958
  const message = err instanceof Error ? err.message : String(err)
@@ -24,4 +24,10 @@ export {
24
24
  type TunnelSnapshot,
25
25
  } from './protocol'
26
26
 
27
- export { formatLocalDate, formatLocalDateTime, resolveLocalTimezoneName } from './local-time'
27
+ export {
28
+ formatLocalDate,
29
+ formatLocalDateTime,
30
+ formatLocalWeekday,
31
+ type LocalWeekday,
32
+ resolveLocalTimezoneName,
33
+ } from './local-time'
@@ -36,3 +36,35 @@ export function resolveLocalTimezoneName(): string {
36
36
  return 'UTC'
37
37
  }
38
38
  }
39
+
40
+ // English + Korean weekday name pair for a given Date. The per-turn time
41
+ // anchor renders both so the model has the answer to "what day is it"
42
+ // without computing weekday-from-ISO-date — a step LLMs get wrong often
43
+ // enough to matter, especially when answering in a non-English language.
44
+ // Pre-computing in both candidate reply languages removes the arithmetic
45
+ // step entirely instead of trusting the model to do it correctly each
46
+ // turn.
47
+ //
48
+ // Uses Intl.DateTimeFormat with explicit locales. No `timeZone` option:
49
+ // the container's local clock is already host-local (the entrypoint
50
+ // propagates TZ via `-e TZ=<host-tz>`), so the runtime's default zone is
51
+ // the one the user sees. Both locales fall back to the hand-rolled
52
+ // 7-entry lookup if Intl throws (no-tzdata, locked-down sandbox) — the
53
+ // fallback names stay readable and never make the prefix empty.
54
+ const WEEKDAYS_EN = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] as const
55
+ const WEEKDAYS_KO = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'] as const
56
+
57
+ export type LocalWeekday = { en: string; ko: string }
58
+
59
+ export function formatLocalWeekday(date: Date = new Date()): LocalWeekday {
60
+ const dow = date.getDay()
61
+ const fallback: LocalWeekday = { en: WEEKDAYS_EN[dow]!, ko: WEEKDAYS_KO[dow]! }
62
+ try {
63
+ return {
64
+ en: new Intl.DateTimeFormat('en-US', { weekday: 'long' }).format(date),
65
+ ko: new Intl.DateTimeFormat('ko-KR', { weekday: 'long' }).format(date),
66
+ }
67
+ } catch {
68
+ return fallback
69
+ }
70
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: typeclaw-channel-github
3
- description: Use this skill BEFORE every `channel_reply` or `channel_send` call whose adapter is `github`, AND before composing replies to GitHub-originated inbounds, AND before opening new issues or PRs with `gh`, AND ALWAYS when an inbound says "requested your review on PR #N" or "requested a review from team @… on PR #N" (the agent has been assigned as a reviewer and must do a real code review with line-by-line comments via `gh api`). GitHub renders **real markdown** — `**bold**`, `## headings`, `| tables |`, fenced code blocks, and `inline code` all render natively. Use rich markdown freely. GitHub cannot send file attachments via API — do not call `channel_send` with attachments on github chats. GitHub has no typing indicator. PR review threads use `thread` keyed on the root comment id; reply to a thread to stay in it, or omit `thread` to post a top-level issue/PR comment. To open new issues or PRs use the `gh` CLI — `GH_TOKEN` is pre-set by the adapter. Read this skill before composing anything on GitHub.
3
+ description: Use this skill BEFORE every `channel_reply` or `channel_send` call whose adapter is `github`, AND before composing replies to GitHub-originated inbounds, AND before opening new issues or PRs with `gh`, AND ALWAYS when an inbound says "requested your review on PR #N" or "requested a review from team @… on PR #N" (the agent has been assigned as a reviewer and must delegate the analysis to the `reviewer` subagent, then translate its findings into line-by-line comments via `gh api`). GitHub renders **real markdown** — `**bold**`, `## headings`, `| tables |`, fenced code blocks, and `inline code` all render natively. Use rich markdown freely. GitHub cannot send file attachments via API — do not call `channel_send` with attachments on github chats. GitHub has no typing indicator. PR review threads use `thread` keyed on the root comment id; reply to a thread to stay in it, or omit `thread` to post a top-level issue/PR comment. To open new issues or PRs use the `gh` CLI — `GH_TOKEN` is pre-set by the adapter. Read this skill before composing anything on GitHub.
4
4
  ---
5
5
 
6
6
  GitHub renders normal Markdown in issues, PRs, discussions, and review comments. Use headings, lists, tables, fenced code blocks, links, and inline code when they improve clarity.
@@ -25,27 +25,56 @@ For App auth, `GH_TOKEN` is an installation access token that refreshes automati
25
25
 
26
26
  ## Reviewing pull requests
27
27
 
28
- When an incoming message says **"requested your review on PR #N"** (or "requested a review from team @… on PR #N"), you have been assigned as a reviewer. Do a real code review and post line-by-line comments via `gh api`. Do **not** just reply in the channel — the user wants feedback on the diff.
28
+ When an incoming message says **"requested your review on PR #N"** (or "requested a review from team @… on PR #N"), you have been assigned as a reviewer. Do **not** review inline yourself and do **not** just reply in the channel — delegate the analysis to the bundled `reviewer` subagent, then translate its findings into line-by-line comments via `gh api`.
29
+
30
+ Why delegate: the `reviewer` subagent runs on the `deep` model profile, loads a curated `code-review` skill on demand, and produces a structured `<review>` block with severity-tagged findings. You are the integration layer between that output and GitHub's review API.
29
31
 
30
32
  ### Workflow
31
33
 
32
- 1. **Read the diff and context**:
34
+ 1. **Confirm the target.** Capture the PR number, the repo, and the head SHA — you'll need the SHA to read files at the revision the reviewer analyzed.
33
35
 
34
36
  ```sh
35
- gh pr diff <N> --repo owner/repo
36
37
  gh pr view <N> --repo owner/repo --json title,body,baseRefName,headRefOid,files
37
38
  ```
38
39
 
39
- 2. **Submit a multi-comment review** in one API call by piping a JSON payload to `gh api --input -`. `comments[]` accepts line-level entries; each one lands on the diff exactly like a human reviewer's inline comment:
40
+ 2. **Spawn the `reviewer` subagent with the PR target.** Use `run_in_background: true` so you stay responsive while the deep model works. Pass the PR URL (or `owner/repo#N`) plus any context the requester gave you (focus areas, specific files, etc.) so the reviewer knows what the requester cares about.
41
+
42
+ The reviewer will fetch the diff itself (`gh pr diff`, `gh api /repos/.../pulls/<n>`), load the matching skill (`code-review` for a code PR; `general` for a mixed-format change), and return a `<review>` block.
43
+
44
+ 3. **Wait for the completion `<system-reminder>`,** then call `subagent_output({ task_id })` to read the reviewer's final assistant message. The structured payload looks like:
45
+
46
+ ```xml
47
+ <review>
48
+ <summary>...</summary>
49
+ <findings>
50
+ <finding severity="blocker|concern|nit|praise" location="path:line">
51
+ <issue>...</issue>
52
+ <evidence>...</evidence>
53
+ <suggestion>...</suggestion>
54
+ </finding>
55
+ </findings>
56
+ <verdict>approve | request-changes | comment</verdict>
57
+ </review>
58
+ ```
59
+
60
+ 4. **Translate findings into a `gh api` review payload.** Each `<finding>` with `severity` of `blocker`, `concern`, or `nit` and a `location="path:line"` becomes one entry in `comments[]`. Compose the inline `body` from the reviewer's `<issue>` + `<evidence>` + `<suggestion>` — preserve the reviewer's wording, do not paraphrase. Findings whose `location` is `general` (no file:line anchor) go into the top-level review `body` instead. **Skip `praise` findings when building `comments[]`** — they are not actionable, and inline praise comments are exactly the noise the reviewer is supposed to filter out at the source; if you want to surface them, weave them into the top-level review `body` alongside the summary. Map the reviewer's `<verdict>` to the GitHub `event`:
61
+
62
+ | Reviewer verdict | GitHub `event` |
63
+ | ----------------- | ----------------- |
64
+ | `approve` | `APPROVE` |
65
+ | `request-changes` | `REQUEST_CHANGES` |
66
+ | `comment` | `COMMENT` |
67
+
68
+ Then submit the review in one API call:
40
69
 
41
70
  ```sh
42
71
  cat <<'JSON' | gh api -X POST /repos/owner/repo/pulls/<N>/reviews --input -
43
72
  {
44
73
  "event": "COMMENT",
45
- "body": "Overall: looks good with a few nits.",
74
+ "body": "<reviewer's <summary> goes here>",
46
75
  "comments": [
47
- { "path": "src/foo.ts", "line": 42, "side": "RIGHT", "body": "nit: prefer `const` here." },
48
- { "path": "src/bar.ts", "line": 10, "side": "RIGHT", "body": "Consider extracting this branch into a helper." }
76
+ { "path": "src/foo.ts", "line": 42, "side": "RIGHT", "body": "<issue + evidence + suggestion from the reviewer's finding>" },
77
+ { "path": "src/bar.ts", "line": 10, "side": "RIGHT", "body": "..." }
49
78
  ]
50
79
  }
51
80
  JSON
@@ -53,17 +82,22 @@ When an incoming message says **"requested your review on PR #N"** (or "requeste
53
82
 
54
83
  **Always use `--input -` with a quoted heredoc (`<<'JSON'`) for review bodies.** Do **not** use `-f body=...` or `-F 'comments[][body]=...'`: those go through shell argument parsing, so backticks (\`) trigger command substitution and have to be backslash-escaped, which leaks the literal `\` into the rendered comment. The quoted heredoc passes the JSON through untouched — backticks, newlines, and `${...}` all survive verbatim. The same applies to any other `gh api` POST whose body contains backticks, embedded newlines, or shell metacharacters.
55
84
 
56
- 3. **Then** post a one-line summary with `channel_reply` so the conversation has a human-readable trace pointing at the review.
85
+ 5. **Post a one-line summary with `channel_reply`** so the conversation has a human-readable trace pointing at the review (e.g., "Posted review on PR #N: <verdict>, N findings.").
57
86
 
58
87
  ### Rules
59
88
 
60
- - Use `event=COMMENT` by default. Use `APPROVE` only when you have high confidence the PR is ready to merge. Use `REQUEST_CHANGES` only when the PR has clear blockers not for nits.
61
- - **Only post comments that the author needs to act on.** Do not post praise ("looks good", "nice refactor", "great work"), affirmations of correct code, or restatements of what a line does. If every comment in your review is positive, post a top-level summary via `channel_reply` instead of a review — or skip commenting and just `APPROVE`. Inline comments are for changes, questions, and blockers, not validation.
89
+ - **Always delegate to the `reviewer` subagent.** Do not perform the review craft yourself. The reviewer is the source of truth for severity, evidence quality, and what counts as a finding. Your job is mechanics: spawn, wait, translate, post.
90
+ - **Trust the verdict.** Use the GitHub `event` mapped from the reviewer's `<verdict>`. Do not upgrade `comment` `APPROVE` to seem agreeable, and do not downgrade `request-changes` `COMMENT` to soften the tone. The reviewer chose deliberately.
91
+ - **No actionable findings → no inline review post.** A finding is "actionable" if its severity is `blocker`, `concern`, or `nit`. If the reviewer returns zero actionable findings:
92
+ - `approve` verdict → post a plain `APPROVE` with the `<summary>` as the review body (no `comments[]` array).
93
+ - `comment` verdict → post the summary as a top-level PR comment via `gh api -X POST /repos/.../issues/<N>/comments` instead of submitting an empty review.
94
+ - `request-changes` verdict → submit `REQUEST_CHANGES` with the `<summary>` as the review body and no `comments[]` array. This combination is rare (the reviewer's contract says `request-changes` requires at least one blocker or load-bearing concern), so if it happens, faithfully encode the verdict and trust the reviewer's reasoning is in the summary.
95
+ - **Preserve the reviewer's wording.** Inline comment bodies should reflect the reviewer's `<issue>`, `<evidence>`, and `<suggestion>` verbatim (modulo markdown formatting). Paraphrasing dilutes the analysis — the deep-model reviewer chose those words on purpose.
62
96
  - `line` is a line number **in the file**, not a position in the diff. `side: RIGHT` is the new revision (default for additions); `side: LEFT` is the old revision (use for comments on removed lines).
63
97
  - For multi-line comments, also set `start_line` and `start_side` (same semantics).
64
- - If you need to read whole files at the PR's head SHA, use `gh api /repos/owner/repo/contents/<path>?ref=<headRefOid>`.
98
+ - If you need to read whole files at the PR's head SHA, use `gh api /repos/owner/repo/contents/<path>?ref=<headRefOid>`. The reviewer can do this itself, but you may need to as well — e.g., when validating a finding's `location` against the actual file before posting.
65
99
  - The bundled `agent-browser` is **not** for PR reviews — `gh api` is faster and more reliable. Only use the browser when the API genuinely can't reach what you need.
66
- - A `review_request_removed` event means the requester un-assigned you. Stop any in-progress review work; do not post a partial review.
100
+ - A `review_request_removed` event means the requester un-assigned you. Cancel any in-flight reviewer subagent (`subagent_cancel`) and do not post a partial review.
67
101
 
68
102
  ### Self-loop safety
69
103
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: typeclaw-channel-kakaotalk
3
- description: Use this skill BEFORE every `channel_reply` or `channel_send` call whose adapter is `kakaotalk`, AND before calling `channel_fetch_attachment` against a KakaoTalk URL. KakaoTalk renders messages as plain text — `**bold**`, `## headings`, `| tables |`, fenced code blocks, and other markdown all appear literally. There is no `@mention` syntax, no message threads, no replies-with-quote, and no outbound stickers. Outbound file attachments (photos, videos, audio, generic files, multi-photo galleries) ARE supported — pass them via `attachments[]` on `channel_send` / `channel_reply` and the adapter routes by MIME. Inbound photos / files / video / audio CAN be downloaded via `channel_fetch_attachment` (the placeholder text includes the URL); inbound stickers are metadata-only and cannot be fetched. URLs expire ~3 days after the message arrives. Read this skill before composing or fetching anything on KakaoTalk.
3
+ description: Use this skill BEFORE every `channel_reply` or `channel_send` call whose adapter is `kakaotalk`, AND before fetching/viewing KakaoTalk inbound attachments. KakaoTalk renders messages as plain text — `**bold**`, `## headings`, `| tables |`, fenced code blocks, and other markdown all appear literally. There is no `@mention` syntax, no message threads, no replies-with-quote, and no outbound stickers. Outbound file attachments (photos, videos, audio, generic files, multi-photo galleries) ARE supported — pass them via `attachments[]` on `channel_send` / `channel_reply` and the adapter routes by MIME. Inbound attachments appear as `[KakaoTalk attachment #N: ...]`; fetch with `channel_fetch_attachment({ attachment_id: N })` or view images with `look_at_channel_attachment({ attachment_id: N })`. Read this skill before composing or fetching anything on KakaoTalk.
4
4
  ---
5
5
 
6
6
  # typeclaw-channel-kakaotalk
@@ -37,24 +37,23 @@ If you produce any of the following, KakaoTalk will render it literally and the
37
37
 
38
38
  ## Inbound attachments and stickers
39
39
 
40
- Even though you cannot SEND attachments or stickers, you DO receive them. The adapter surfaces incoming non-text content by appending a `[KakaoTalk message with ...]` placeholder to the inbound text (same convention as Slack/Discord/Telegram). Examples of what you'll see:
40
+ Even though you cannot SEND stickers, you DO receive attachments and stickers. The adapter surfaces incoming non-text content by appending a ref-free `[KakaoTalk attachment #N: <kind> <metadata>]` placeholder to the inbound text (same convention as Slack/Discord/Telegram). Examples of what you'll see:
41
41
 
42
- - A photo (with no caption): `[KakaoTalk message with photo 1320x2868 (image/jpeg) https://talk.kakaocdn.net/...]`
43
- - A photo with a caption: `look at this\n[KakaoTalk message with photo 1320x2868 (image/jpeg) https://...]`
44
- - A file: `[KakaoTalk message with file spec.pdf (application/pdf) size=12345 https://...]`
45
- - A video / audio (with a usable URL): `[KakaoTalk message with video (keys=[dur,url]) https://talk.kakaocdn.net/...]`. The SDK leaves video / audio / multiphoto payloads opaque, so we list the keys that were present alongside the URL when one exists; when no URL is present the placeholder is just `[KakaoTalk message with video keys=[...]]` and there is nothing for you to fetch.
46
- - A sticker / emoticon: `[KakaoTalk message with sticker (sticker) pack=4412724 path=4412724.emot_001.webp]`
47
- - An animated sticker: `[KakaoTalk message with sticker (sticker_ani) pack=... path=...]`
42
+ - A photo (with no caption): `[KakaoTalk attachment #1: photo 1320x2868 image/jpeg]`
43
+ - A photo with a caption: `look at this\n[KakaoTalk attachment #1: photo 1320x2868 image/jpeg]`
44
+ - A file: `[KakaoTalk attachment #1: file application/pdf name=spec.pdf size=12345]`
45
+ - A video / audio / multiphoto: `[KakaoTalk attachment #1: video video/mp4]` or `[KakaoTalk attachment #1: multiphoto]`
46
+ - A sticker / emoticon: `[KakaoTalk attachment #1: sticker name=4412724.emot_001.webp]`
48
47
 
49
48
  ### Fetching attachment bytes
50
49
 
51
- For photos, files, and any video / audio / multiphoto whose placeholder includes a `https://...kakaocdn.net/...` URL, call `channel_fetch_attachment` with that URL as the `ref` to download the bytes. The adapter validates the host (only `*.kakaocdn.net` is accepted — you cannot use this tool as a generic web fetcher) and returns the raw buffer plus mimetype.
50
+ For photos, files, and any video / audio / multiphoto with an attachment token, call `channel_fetch_attachment` with the numeric `attachment_id` from the token to download the bytes. To view an image directly, call `look_at_channel_attachment` with the same `attachment_id`.
52
51
 
53
52
  Use this when you actually need to look at the content — e.g. the user sends a screenshot and asks "what's in this?". The download lands in your inbox directory and you can pass it to a vision-capable inspection tool or read it directly depending on the file type.
54
53
 
55
- **Expiry caveat**: KakaoCDN URLs are pre-signed with an `expires=` timestamp baked into the query string empirically ~3 days after the message arrived. Fetch promptly. If the URL has expired you will get a `403` error with the hint _"likely an expired pre-signed URL; ask the sender to re-share"_ — relay that to the user verbatim rather than guessing the cause.
54
+ If no attachment token appears in the inbound text, no attachment was sent. Do not invent attachment ids the tool will reject ids that do not appear in the current turn.
56
55
 
57
- **Stickers cannot be fetched** as bytes through this tool. The sticker placeholder carries `pack=` and `path=` identifiers (KakaoTalk sticker pack metadata), not a downloadable URL. Treat stickers as descriptive metadata only — acknowledge them ("cute sticker") without trying to "see" them.
56
+ **Stickers cannot be fetched** as bytes through this tool. Treat stickers as descriptive metadata only — acknowledge them ("cute sticker") without trying to "see" them.
58
57
 
59
58
  If the inbound text is JUST a sticker (no accompanying text), the agent still gets a routed event — stickers count as engagement under `reply` and `dm` triggers (group chats with only sticker activity will not trigger `mention` because aliases require text matching).
60
59
 
@@ -48,6 +48,14 @@ You do NOT need to manually escape any of `_ * [ ] ( ) ~ \` > # + - = | { } . !`
48
48
 
49
49
  URLs containing **unescaped parentheses** (Wikipedia-style `Foo_(bar)`) intentionally fall back to escaped literal text rather than render as a link — the adapter cannot disambiguate the closing `)` from a content paren. If you need to link such a URL, percent-encode the parens in the URL (`%28`, `%29`) before putting it in the link.
50
50
 
51
+ ## Inbound attachments
52
+
53
+ Inbound Telegram messages with photos or documents show a ref-free attachment token in the text: `[Telegram attachment #N: <kind> <metadata>]`, for example `[Telegram attachment #1: photo 1280x960]` or `[Telegram attachment #1: file application/pdf name=spec.pdf]`.
54
+
55
+ - To download the attachment, call `channel_fetch_attachment` with `attachment_id: N`.
56
+ - To view an image, call `look_at_channel_attachment` with `attachment_id: N`.
57
+ - If no attachment token appears in the inbound, no attachment was sent. Do not invent attachment ids — the tool will reject ids that do not appear in the current turn.
58
+
51
59
  ## When the user says "your formatting looks broken"
52
60
 
53
61
  Three classes of failure to triage in this order:
@@ -38,7 +38,8 @@ If `codex` is installed but no credential is set up, you have to broker the auth
38
38
 
39
39
  **Decision rule, top to bottom:**
40
40
 
41
- 1. **Already authenticated?** Check both env (`env | grep -E '^(OPENAI_API_KEY|CODEX_API_KEY)='`) and on-disk (`test -f ~/.codex/auth.json`). If either resolves, skip auth entirely.
41
+ 0. **typeclaw may have already done it for you.** If the agent was initialized with the `openai-codex` provider (the user pasted/ran OAuth into typeclaw itself), typeclaw writes `~/.codex/auth.json` automatically on every container start — provided `docker.file.codexCli: true` is set. Check `test -f ~/.codex/auth.json && jq -e '.tokens.access_token' ~/.codex/auth.json >/dev/null`; if both succeed, skip auth and go straight to delegation. The file is refreshed on every start via the newer-wins compare in `src/secrets/export-codex-auth-file.ts`, so a stale credential gets replaced without user intervention as long as the typeclaw-side credential is fresher.
42
+ 1. **Already authenticated some other way?** Check both env (`env | grep -E '^(OPENAI_API_KEY|CODEX_API_KEY)='`) and on-disk (`test -f ~/.codex/auth.json`). If either resolves, skip auth entirely.
42
43
  2. **User has an OpenAI API account** (api.openai.com billing, no ChatGPT Plus/Pro subscription) → API key path.
43
44
  3. **User has a ChatGPT Plus / Pro / Team / Enterprise subscription and wants to use their subscription credits** → OAuth path via `codex login`.
44
45
  4. **User is unsure** → ask which kind of OpenAI account they have. Both paths are equally low-friction. Pick by account shape, not by flow complexity.
@@ -4,6 +4,28 @@ Deep dive for the auth paths. Read it when `SKILL.md`'s "First-time auth (intera
4
4
 
5
5
  The two paths are intentionally symmetric: in both, the user produces one artifact on their side, pastes it to you, you validate it, you do read-modify-write on `.env` (or write `~/.codex/auth.json`), you offer a restart. Only the credential medium differs.
6
6
 
7
+ ## Path 0 — typeclaw-managed OAuth (auto-export, no user action)
8
+
9
+ Before walking the user through either interactive path, check whether typeclaw has already provisioned `~/.codex/auth.json` from its own secrets store. This is the canonical state for users who configured `openai-codex` as their typeclaw model backend during `typeclaw init`:
10
+
11
+ - `typeclaw init` ran the OAuth flow against pi-ai and wrote the credential to `secrets.json#providers.openai-codex` (shape: `{ type: 'oauth', access, refresh, expires, accountId }`).
12
+ - `docker.file.codexCli: true` is set in `typeclaw.json`, so the Codex CLI is installed in the container.
13
+ - On every `typeclaw start` / `typeclaw restart`, `src/run/index.ts`'s boot sequence calls `exportCodexAuthFileForAgent`, which:
14
+ - Returns early (zero filesystem touches) if `codexCli` is off or no `openai-codex` credential exists.
15
+ - Otherwise emits the modern `~/.codex/auth.json` shape (`{ tokens: { access_token, refresh_token, account_id? } }`) — no top-level `expires`, because Codex CLI re-derives expiry from the JWT on every load.
16
+ - Compares the JWT `exp` claim in the on-disk access token against typeclaw's stored expiry. If the on-disk token is the same or newer (Codex CLI rotated it in-place since the last typeclaw write), the file is left alone — no clobber. If typeclaw's copy is strictly fresher (the user re-pasted OAuth), the file is replaced atomically.
17
+
18
+ Detection check before launching the interactive flow:
19
+
20
+ ```sh
21
+ test -f ~/.codex/auth.json \
22
+ && jq -e '.tokens.access_token' ~/.codex/auth.json >/dev/null
23
+ ```
24
+
25
+ If both succeed, the credential is ready; skip Paths A and B and proceed to delegation. If only the first succeeds but the second fails (file exists but no `tokens.access_token`), the file is either an API-key shape (legacy) or corrupt — the runtime exporter's next-start pass will overwrite it from `secrets.json` if typeclaw has a valid OAuth credential, but for the current delegation you can either re-run `typeclaw restart` to force the resync, or fall back to interactive Path A / Path B.
26
+
27
+ If the user has `docker.file.codexCli: true` but typeclaw was initialized with a non-`openai-codex` model backend (e.g. `anthropic`, `openai`, `fireworks`), Path 0 won't fire — the auto-export's gate-2 returns because `secrets.json#providers.openai-codex` is absent. The user's manually-pasted `~/.codex/auth.json` (if any) is never touched in that case. Fall through to Path A or Path B.
28
+
7
29
  ## Path A — API key (`OPENAI_API_KEY`)
8
30
 
9
31
  The API key path is direct. Summary: