typeclaw 0.3.1 → 0.5.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 (125) hide show
  1. package/README.md +20 -15
  2. package/auth.schema.json +113 -0
  3. package/package.json +1 -1
  4. package/secrets.schema.json +113 -0
  5. package/src/agent/auth.ts +4 -2
  6. package/src/agent/index.ts +16 -28
  7. package/src/agent/model-fallback.ts +127 -0
  8. package/src/agent/session-meta.ts +1 -1
  9. package/src/agent/session-origin.ts +3 -2
  10. package/src/agent/tools/curl-impersonate.ts +300 -0
  11. package/src/agent/tools/ddg.ts +13 -88
  12. package/src/agent/tools/webfetch/fetch.ts +105 -2
  13. package/src/agent/tools/webfetch/tool.ts +4 -0
  14. package/src/bundled-plugins/agent-browser/shim.ts +47 -0
  15. package/src/bundled-plugins/backup/subagents.ts +2 -0
  16. package/src/bundled-plugins/memory/README.md +49 -12
  17. package/src/bundled-plugins/memory/citation-superset.ts +63 -0
  18. package/src/bundled-plugins/memory/dreaming.ts +105 -17
  19. package/src/bundled-plugins/memory/index.ts +2 -2
  20. package/src/bundled-plugins/memory/memory-logger.ts +45 -26
  21. package/src/bundled-plugins/memory/strength.ts +127 -0
  22. package/src/bundled-plugins/memory/topics.ts +75 -0
  23. package/src/bundled-plugins/security/index.ts +88 -43
  24. package/src/bundled-plugins/security/permissions.ts +36 -0
  25. package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
  26. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
  27. package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
  28. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
  29. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
  30. package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
  31. package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
  32. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
  33. package/src/channels/adapters/github/auth-app.ts +120 -0
  34. package/src/channels/adapters/github/auth-pat.ts +50 -0
  35. package/src/channels/adapters/github/auth.ts +33 -0
  36. package/src/channels/adapters/github/channel-resolver.ts +30 -0
  37. package/src/channels/adapters/github/dedup.ts +26 -0
  38. package/src/channels/adapters/github/event-allowlist.ts +8 -0
  39. package/src/channels/adapters/github/fetch-attachment.ts +5 -0
  40. package/src/channels/adapters/github/history.ts +63 -0
  41. package/src/channels/adapters/github/inbound.ts +286 -0
  42. package/src/channels/adapters/github/index.ts +370 -0
  43. package/src/channels/adapters/github/managed-path.ts +54 -0
  44. package/src/channels/adapters/github/membership.ts +35 -0
  45. package/src/channels/adapters/github/outbound.ts +145 -0
  46. package/src/channels/adapters/github/webhook-register.ts +349 -0
  47. package/src/channels/manager.ts +94 -9
  48. package/src/channels/router.ts +194 -28
  49. package/src/channels/schema.ts +31 -1
  50. package/src/channels/tunnel-bridge.ts +51 -0
  51. package/src/channels/types.ts +3 -1
  52. package/src/cli/builtins.ts +28 -0
  53. package/src/cli/channel.ts +511 -25
  54. package/src/cli/container-command-client.ts +244 -0
  55. package/src/cli/cron.ts +173 -0
  56. package/src/cli/host-command-runner.ts +150 -0
  57. package/src/cli/index.ts +42 -1
  58. package/src/cli/init.ts +400 -67
  59. package/src/cli/model.ts +14 -4
  60. package/src/cli/oauth-callbacks.ts +49 -0
  61. package/src/cli/plugin-command-help.ts +49 -0
  62. package/src/cli/plugin-commands-dispatch.ts +112 -0
  63. package/src/cli/plugin-commands.ts +118 -0
  64. package/src/cli/provider.ts +3 -20
  65. package/src/cli/tui.ts +10 -2
  66. package/src/cli/tunnel.ts +533 -0
  67. package/src/cli/ui.ts +8 -3
  68. package/src/config/config.ts +134 -24
  69. package/src/config/models-mutation.ts +42 -8
  70. package/src/config/providers-mutation.ts +12 -8
  71. package/src/container/start.ts +48 -4
  72. package/src/cron/bridge.ts +136 -0
  73. package/src/cron/consumer.ts +174 -48
  74. package/src/cron/index.ts +19 -2
  75. package/src/cron/list.ts +105 -0
  76. package/src/cron/scheduler.ts +12 -3
  77. package/src/cron/schema.ts +11 -3
  78. package/src/doctor/checks.ts +0 -50
  79. package/src/init/dockerfile.ts +165 -13
  80. package/src/init/ensure-deps.ts +15 -4
  81. package/src/init/github-webhook-install.ts +109 -0
  82. package/src/init/hatching.ts +2 -2
  83. package/src/init/index.ts +519 -12
  84. package/src/init/oauth-login.ts +17 -3
  85. package/src/init/run-bun-install.ts +17 -3
  86. package/src/init/run-owner-claim.ts +11 -2
  87. package/src/permissions/builtins.ts +29 -2
  88. package/src/permissions/match-rule.ts +24 -2
  89. package/src/permissions/permissions.ts +24 -7
  90. package/src/permissions/resolve.ts +1 -0
  91. package/src/plugin/define.ts +44 -1
  92. package/src/plugin/index.ts +18 -3
  93. package/src/plugin/manager.ts +16 -0
  94. package/src/plugin/registry.ts +85 -3
  95. package/src/plugin/types.ts +144 -1
  96. package/src/plugin/zod-introspect.ts +100 -0
  97. package/src/role-claim/match-rule.ts +2 -1
  98. package/src/run/index.ts +112 -4
  99. package/src/secrets/index.ts +1 -1
  100. package/src/secrets/schema.ts +21 -0
  101. package/src/server/command-runner.ts +476 -0
  102. package/src/server/index.ts +388 -5
  103. package/src/shared/index.ts +8 -0
  104. package/src/shared/protocol.ts +80 -1
  105. package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
  106. package/src/skills/typeclaw-config/SKILL.md +27 -26
  107. package/src/skills/typeclaw-cron/SKILL.md +234 -3
  108. package/src/skills/typeclaw-memory/SKILL.md +25 -15
  109. package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
  110. package/src/skills/typeclaw-permissions/SKILL.md +35 -16
  111. package/src/skills/typeclaw-plugins/SKILL.md +251 -5
  112. package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
  113. package/src/test-helpers/wait-for.ts +50 -0
  114. package/src/tui/index.ts +70 -7
  115. package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
  116. package/src/tunnels/events.ts +14 -0
  117. package/src/tunnels/index.ts +12 -0
  118. package/src/tunnels/log-ring.ts +54 -0
  119. package/src/tunnels/manager.ts +139 -0
  120. package/src/tunnels/providers/cloudflare-quick.ts +189 -0
  121. package/src/tunnels/providers/external.ts +53 -0
  122. package/src/tunnels/quick-url-parser.ts +5 -0
  123. package/src/tunnels/types.ts +43 -0
  124. package/src/usage/report.ts +15 -12
  125. package/typeclaw.schema.json +311 -26
package/README.md CHANGED
@@ -35,6 +35,7 @@ TypeClaw is the agent I wanted to use:
35
35
  - 🔄 **Hot reload** — change `typeclaw.json`, `typeclaw reload` — no restart for most fields
36
36
  - 🔁 **Self-restart** — the agent can bounce its own container when it updates itself
37
37
  - 🌐 **Auto port-forward** — dev servers inside the container appear on `localhost`, even loopback-only ones
38
+ - 🌍 **Public tunnels** — Cloudflare Quick (zero signup) or bring-your-own external URL; the agent self-registers GitHub webhooks at the resulting public URL
38
39
  - 🎼 **Compose** — orchestrate multiple agents across multiple folders
39
40
 
40
41
  ### 🌱 Self-improving, in detail
@@ -68,20 +69,23 @@ That's it. The agent is now alive, listening on a websocket, ready to receive pr
68
69
 
69
70
  ## CLI
70
71
 
71
- | Command | Purpose |
72
- | ----------------------------------- | ---------------------------------------------------------------------------------- |
73
- | `typeclaw init` | Scaffold a new agent folder |
74
- | `typeclaw start` | Build and run the container |
75
- | `typeclaw stop` | Stop the container |
76
- | `typeclaw restart` | `stop` then `start` |
77
- | `typeclaw status` | Show container + daemon registration state |
78
- | `typeclaw logs` | Stream container stdout/stderr with local timestamps; `-f` to follow |
79
- | `typeclaw tui` | Attach a terminal UI over the agent's websocket |
80
- | `typeclaw shell` | Open a shell inside the running container |
81
- | `typeclaw reload` | Push a live config reload to the running agent |
82
- | `typeclaw compose` | Orchestrate multiple agents |
83
- | `typeclaw channel add <kind>` | Wire a new channel adapter (Slack, Discord, Telegram, KakaoTalk) |
84
- | `typeclaw channel reauth kakaotalk` | Re-authenticate KakaoTalk after a stale-token 401 or to rotate the stored password |
72
+ | Command | Purpose |
73
+ | ----------------------------------- | ----------------------------------------------------------------------------------- |
74
+ | `typeclaw init` | Scaffold a new agent folder |
75
+ | `typeclaw start` | Build and run the container |
76
+ | `typeclaw stop` | Stop the container |
77
+ | `typeclaw restart` | `stop` then `start` |
78
+ | `typeclaw status` | Show container + daemon registration state |
79
+ | `typeclaw logs` | Stream container stdout/stderr with local timestamps; `-f` to follow |
80
+ | `typeclaw tui` | Attach a terminal UI over the agent's websocket |
81
+ | `typeclaw shell` | Open a shell inside the running container |
82
+ | `typeclaw reload` | Push a live config reload to the running agent |
83
+ | `typeclaw compose` | Orchestrate multiple agents |
84
+ | `typeclaw cron list` | List every cron job registered in the running agent (user `cron.json` + plugins) |
85
+ | `typeclaw channel add <kind>` | Wire a new channel adapter (Slack, Discord, Telegram, KakaoTalk, GitHub) |
86
+ | `typeclaw channel set <kind>` | Rotate the credentials of an already-configured channel (bot/app tokens, PAT, etc.) |
87
+ | `typeclaw channel reauth kakaotalk` | Re-authenticate KakaoTalk after a stale-token 401 or to rotate the stored password |
88
+ | `typeclaw tunnel ...` | Add/list/status/remove public tunnels and inspect tunnel logs |
85
89
 
86
90
  ## Configuration
87
91
 
@@ -107,7 +111,8 @@ my-agent/
107
111
  - `plugins` — list of plugin module specifiers
108
112
  - `channels` — `slack-bot` / `discord-bot` config
109
113
  - `portForward` — allow/deny list for auto port forwarding (default: `*`)
110
- - `dockerfile` — toggles for `gh`, `python`, `tmux`, `ffmpeg`, plus `append` lines
114
+ - `tunnels` — declare public URLs for inbound webhooks and ad-hoc exposure (`cloudflare-quick` or `external`)
115
+ - `dockerfile` — toggles for `gh`, `python`, `tmux`, `ffmpeg`, `cjkFonts`, plus `append` lines
111
116
  - `memory` — idle window and dreaming schedule for the memory plugin
112
117
 
113
118
  `Dockerfile` and `.gitignore` are owned by TypeClaw and rewritten on every `start` — edit `src/init/dockerfile.ts` and re-run `start --build` to ship template changes.
package/auth.schema.json CHANGED
@@ -142,6 +142,119 @@
142
142
  }
143
143
  }
144
144
  },
145
+ "github": {
146
+ "type": "object",
147
+ "properties": {
148
+ "auth": {
149
+ "oneOf": [
150
+ {
151
+ "type": "object",
152
+ "properties": {
153
+ "type": {
154
+ "type": "string",
155
+ "const": "pat"
156
+ },
157
+ "token": {
158
+ "anyOf": [
159
+ {
160
+ "type": "string",
161
+ "minLength": 1
162
+ },
163
+ {
164
+ "type": "object",
165
+ "properties": {
166
+ "value": {
167
+ "type": "string",
168
+ "minLength": 1
169
+ },
170
+ "env": {
171
+ "type": "string",
172
+ "minLength": 1
173
+ }
174
+ }
175
+ }
176
+ ]
177
+ }
178
+ },
179
+ "required": [
180
+ "type",
181
+ "token"
182
+ ]
183
+ },
184
+ {
185
+ "type": "object",
186
+ "properties": {
187
+ "type": {
188
+ "type": "string",
189
+ "const": "app"
190
+ },
191
+ "appId": {
192
+ "type": "integer",
193
+ "exclusiveMinimum": 0,
194
+ "maximum": 9007199254740991
195
+ },
196
+ "privateKey": {
197
+ "anyOf": [
198
+ {
199
+ "type": "string",
200
+ "minLength": 1
201
+ },
202
+ {
203
+ "type": "object",
204
+ "properties": {
205
+ "value": {
206
+ "type": "string",
207
+ "minLength": 1
208
+ },
209
+ "env": {
210
+ "type": "string",
211
+ "minLength": 1
212
+ }
213
+ }
214
+ }
215
+ ]
216
+ },
217
+ "installationId": {
218
+ "type": "integer",
219
+ "exclusiveMinimum": 0,
220
+ "maximum": 9007199254740991
221
+ }
222
+ },
223
+ "required": [
224
+ "type",
225
+ "appId",
226
+ "privateKey"
227
+ ]
228
+ }
229
+ ]
230
+ },
231
+ "webhookSecret": {
232
+ "anyOf": [
233
+ {
234
+ "type": "string",
235
+ "minLength": 1
236
+ },
237
+ {
238
+ "type": "object",
239
+ "properties": {
240
+ "value": {
241
+ "type": "string",
242
+ "minLength": 1
243
+ },
244
+ "env": {
245
+ "type": "string",
246
+ "minLength": 1
247
+ }
248
+ }
249
+ }
250
+ ]
251
+ }
252
+ },
253
+ "required": [
254
+ "auth",
255
+ "webhookSecret"
256
+ ]
257
+ },
145
258
  "telegram-bot": {
146
259
  "type": "object",
147
260
  "properties": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -142,6 +142,119 @@
142
142
  }
143
143
  }
144
144
  },
145
+ "github": {
146
+ "type": "object",
147
+ "properties": {
148
+ "auth": {
149
+ "oneOf": [
150
+ {
151
+ "type": "object",
152
+ "properties": {
153
+ "type": {
154
+ "type": "string",
155
+ "const": "pat"
156
+ },
157
+ "token": {
158
+ "anyOf": [
159
+ {
160
+ "type": "string",
161
+ "minLength": 1
162
+ },
163
+ {
164
+ "type": "object",
165
+ "properties": {
166
+ "value": {
167
+ "type": "string",
168
+ "minLength": 1
169
+ },
170
+ "env": {
171
+ "type": "string",
172
+ "minLength": 1
173
+ }
174
+ }
175
+ }
176
+ ]
177
+ }
178
+ },
179
+ "required": [
180
+ "type",
181
+ "token"
182
+ ]
183
+ },
184
+ {
185
+ "type": "object",
186
+ "properties": {
187
+ "type": {
188
+ "type": "string",
189
+ "const": "app"
190
+ },
191
+ "appId": {
192
+ "type": "integer",
193
+ "exclusiveMinimum": 0,
194
+ "maximum": 9007199254740991
195
+ },
196
+ "privateKey": {
197
+ "anyOf": [
198
+ {
199
+ "type": "string",
200
+ "minLength": 1
201
+ },
202
+ {
203
+ "type": "object",
204
+ "properties": {
205
+ "value": {
206
+ "type": "string",
207
+ "minLength": 1
208
+ },
209
+ "env": {
210
+ "type": "string",
211
+ "minLength": 1
212
+ }
213
+ }
214
+ }
215
+ ]
216
+ },
217
+ "installationId": {
218
+ "type": "integer",
219
+ "exclusiveMinimum": 0,
220
+ "maximum": 9007199254740991
221
+ }
222
+ },
223
+ "required": [
224
+ "type",
225
+ "appId",
226
+ "privateKey"
227
+ ]
228
+ }
229
+ ]
230
+ },
231
+ "webhookSecret": {
232
+ "anyOf": [
233
+ {
234
+ "type": "string",
235
+ "minLength": 1
236
+ },
237
+ {
238
+ "type": "object",
239
+ "properties": {
240
+ "value": {
241
+ "type": "string",
242
+ "minLength": 1
243
+ },
244
+ "env": {
245
+ "type": "string",
246
+ "minLength": 1
247
+ }
248
+ }
249
+ }
250
+ ]
251
+ }
252
+ },
253
+ "required": [
254
+ "auth",
255
+ "webhookSecret"
256
+ ]
257
+ },
145
258
  "telegram-bot": {
146
259
  "type": "object",
147
260
  "properties": {
package/src/agent/auth.ts CHANGED
@@ -83,8 +83,10 @@ export function getAuthFor(providerId: KnownProviderId): Auth {
83
83
 
84
84
  // Back-compat shim for callers that still want the `default` profile's auth
85
85
  // (the main session path). Equivalent to `getAuthFor(provider-of-default)`.
86
+ // Uses the head of the fallback chain; auth for the rest of the chain is
87
+ // resolved lazily when fallback actually fires.
86
88
  export function getAuth(): Auth {
87
- const defaultRef = getConfig().models.default
89
+ const defaultRef = getConfig().models.default[0]!
88
90
  return getAuthFor(providerForModelRef(defaultRef))
89
91
  }
90
92
 
@@ -98,7 +100,7 @@ function hasAnyCredentialInEnv(apiKeyEnv: string | null): boolean {
98
100
 
99
101
  function missingCredentialMessage(providerId: KnownProviderId): string {
100
102
  const provider = KNOWN_PROVIDERS[providerId]
101
- const defaultRef = getConfig().models.default
103
+ const defaultRef = getConfig().models.default[0]!
102
104
  const defaultProviderId = providerForModelRef(defaultRef)
103
105
  // For the `default` profile, name the model in the error message (matches
104
106
  // pre-multi-model behavior). For any other profile, the user is mixing
@@ -8,7 +8,7 @@ import type { AgentSession, ToolDefinition } from '@mariozechner/pi-coding-agent
8
8
  import { loadMemory } from '@/bundled-plugins/memory/load-memory'
9
9
  import type { ChannelRouter } from '@/channels/router'
10
10
  import { getConfig, resolveModel, resolveProfile } from '@/config'
11
- import { providerForModelRef } from '@/config/providers'
11
+ import { providerForModelRef, type KnownModelRef } from '@/config/providers'
12
12
  import type { PermissionService } from '@/permissions'
13
13
  import type {
14
14
  BuiltinToolRef,
@@ -134,6 +134,12 @@ export type CreateSessionOptions = {
134
134
  // overrides) so different sessions on the same agent can run different
135
135
  // models without per-session config edits.
136
136
  profile?: string
137
+ // Override the resolved ref directly, bypassing `profile` resolution. Used
138
+ // by the model-fallback helper (`promptWithFallback`) to recreate a session
139
+ // pinned to the next ref in the chain after the previous one failed. When
140
+ // set, `profile` is still recorded for the fallback-warning bookkeeping;
141
+ // the profile→refs resolution is skipped.
142
+ refOverride?: KnownModelRef
137
143
  // Defensive ceiling on cumulative bytes of tool-result text per session,
138
144
  // applied to the named tools only. See `src/agent/tool-result-budget.ts`
139
145
  // for the rationale. Intended for subagents that read large files
@@ -161,10 +167,14 @@ export async function createSession(options: CreateSessionOptions = {}): Promise
161
167
 
162
168
  export async function createSessionWithDispose(options: CreateSessionOptions = {}): Promise<CreateSessionResult> {
163
169
  const resolved = resolveProfile(getConfig().models, options.profile)
164
- if (resolved.fellBackToDefault && options.profile !== undefined && options.profile !== 'default') {
165
- warnProfileFallbackOnce(options.profile, resolved.ref)
166
- }
167
- const { authStorage, modelRegistry } = getAuthFor(providerForModelRef(resolved.ref))
170
+ // Unknown profiles silently fall back to `default`. The fallback is by design
171
+ // (see `resolveProfile`) and surfacing a warning here just creates noise on
172
+ // every memory-logger / dreaming subagent spawn for advanced users who know
173
+ // exactly what they're doing.
174
+ // `refOverride` lets the model-fallback helper pin a specific entry from
175
+ // the chain when it recreates a session after the previous ref failed.
176
+ const activeRef: KnownModelRef = options.refOverride ?? resolved.ref
177
+ const { authStorage, modelRegistry } = getAuthFor(providerForModelRef(activeRef))
168
178
 
169
179
  const materializedSkills =
170
180
  options.plugins && options.plugins.registry.skills.length > 0
@@ -279,7 +289,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
279
289
  ? customToolsPreBudget.map((t) => wrapToolDefinitionWithBudget(t, sessionBudget, sessionBudgetState))
280
290
  : customToolsPreBudget
281
291
 
282
- const model = resolveModel(resolved.ref)
292
+ const model = resolveModel(activeRef)
283
293
  const { session } = await createAgentSession({
284
294
  model,
285
295
  sessionManager,
@@ -737,25 +747,3 @@ function resolveRoleContext(
737
747
  export function getBundledSkillsDir(): string {
738
748
  return join(dirname(fileURLToPath(import.meta.url)), '..', 'skills')
739
749
  }
740
-
741
- // Profile-fallback warning is fired once per (profile, ref) pair per process.
742
- // Without rate-limiting, every memory-logger spawn (~every idle event) would
743
- // emit a fresh warning when the user has only `default` configured — tens of
744
- // warnings per channel session is noise the operator will learn to ignore.
745
- // The pair includes `ref` so a config reload that changes `default` re-warns.
746
- const profileFallbackWarned = new Set<string>()
747
-
748
- function warnProfileFallbackOnce(profile: string, ref: string): void {
749
- const key = `${profile}\x00${ref}`
750
- if (profileFallbackWarned.has(key)) return
751
- profileFallbackWarned.add(key)
752
- console.warn(
753
- `[agent] unknown model profile "${profile}"; falling back to "default" (${ref}). Add it under \`models\` in typeclaw.json to remove this warning. (further occurrences suppressed)`,
754
- )
755
- }
756
-
757
- // Test-only: clear the rate-limit cache so a test can assert the warning fires
758
- // once after rate-limit reset.
759
- export function __resetProfileFallbackWarningsForTesting(): void {
760
- profileFallbackWarned.clear()
761
- }
@@ -0,0 +1,127 @@
1
+ import { resolveProfile } from '@/config'
2
+ import type { Models } from '@/config/config'
3
+ import type { KnownModelRef } from '@/config/providers'
4
+
5
+ import type { AgentSession } from './index'
6
+ import { subscribeProviderErrors } from './provider-error'
7
+
8
+ // Result of a single fallback-aware prompt run.
9
+ // - `refUsed` is the ref whose session ultimately handled the turn.
10
+ // - `attempts` lists every ref that was tried, in order, with the failure
11
+ // reason for each attempt that didn't make it through. `attempts.length`
12
+ // is always >= 1; the last entry succeeded iff `success: true`.
13
+ // - `session` / `dispose` are the session that handled the turn (or attempted
14
+ // the final entry, on full-chain failure). Callers that need to keep using
15
+ // the session for subsequent turns store these in their state; callers that
16
+ // tear down per-turn (cron) just call `dispose()` and discard.
17
+ export type FallbackPromptResult = {
18
+ success: boolean
19
+ refUsed: KnownModelRef
20
+ attempts: FallbackAttempt[]
21
+ session: AgentSession
22
+ dispose: () => Promise<void>
23
+ // When `success === false`, this is the error from the final attempt.
24
+ lastError?: Error
25
+ }
26
+
27
+ export type FallbackAttempt = {
28
+ ref: KnownModelRef
29
+ // 'hard' = session.prompt() threw. 'soft' = pi-coding-agent surfaced an
30
+ // upstream error via stopReason: 'error' on the final assistant message.
31
+ // 'success' = the turn finished cleanly.
32
+ outcome: 'hard' | 'soft' | 'success'
33
+ errorMessage?: string
34
+ }
35
+
36
+ // Build the ordered list of refs to attempt for a given profile. Single-ref
37
+ // profiles produce a length-1 chain; the fallback path is then a no-op in
38
+ // practice (the first attempt either succeeds or the error propagates).
39
+ //
40
+ // Exported so callers can introspect the chain (e.g. logs, telemetry) before
41
+ // firing the prompt — useful for `[cron] ${jobId}: trying chain a → b → c`.
42
+ export function resolveFallbackChain(models: Models, profile: string | undefined): KnownModelRef[] {
43
+ return resolveProfile(models, profile).refs
44
+ }
45
+
46
+ // Drives one `session.prompt(text)` call with full fallback semantics:
47
+ //
48
+ // 1. Create a session bound to `refs[0]` via `createSessionForRef`.
49
+ // 2. Subscribe to provider-error events so soft errors (pi-coding-agent's
50
+ // `stopReason: 'error'` shape) trigger fallback in addition to throws.
51
+ // 3. Await `session.prompt(text)`.
52
+ // 4. If the prompt threw OR a soft error fired during the turn:
53
+ // - dispose the failed session
54
+ // - advance to `refs[i+1]` and retry (only if a fallback is available)
55
+ // 5. Return the session that handled the turn (or the last-tried session
56
+ // on full-chain failure), the ref used, and the attempt log.
57
+ //
58
+ // The wrapper intentionally does NOT swallow the final failure: when every
59
+ // ref in the chain has been exhausted, the returned `success: false` plus
60
+ // `lastError` lets the caller surface the failure however it already does
61
+ // (console.error in the server drain, channel reaction in the router,
62
+ // cron-job status). This keeps the helper composable with the existing
63
+ // error-handling code at each call site.
64
+ export async function promptWithFallback(opts: {
65
+ refs: KnownModelRef[]
66
+ text: string
67
+ createSessionForRef: (ref: KnownModelRef) => Promise<{ session: AgentSession; dispose: () => Promise<void> }>
68
+ // Called after each non-final attempt so callers can log the per-attempt
69
+ // failure with their own context (sessionId, channel key, job id, ...).
70
+ onAttemptFailed?: (attempt: FallbackAttempt) => void
71
+ }): Promise<FallbackPromptResult> {
72
+ if (opts.refs.length === 0) {
73
+ throw new Error('promptWithFallback: refs[] must be non-empty')
74
+ }
75
+ const attempts: FallbackAttempt[] = []
76
+ let lastError: Error | undefined
77
+ for (let i = 0; i < opts.refs.length; i++) {
78
+ const ref = opts.refs[i]!
79
+ const isLast = i === opts.refs.length - 1
80
+ const { session, dispose } = await opts.createSessionForRef(ref)
81
+ // Capture the first soft error per attempt. The `subscribeProviderErrors`
82
+ // listener fires synchronously off the `message_end` event, which lands
83
+ // BEFORE `session.prompt()` resolves — so by the time `await` returns,
84
+ // `softError` is populated if a soft error occurred.
85
+ let softError: Error | undefined
86
+ const unsub = subscribeProviderErrors(session, (err) => {
87
+ if (!softError) softError = new Error(err.message)
88
+ })
89
+ try {
90
+ try {
91
+ await session.prompt(opts.text)
92
+ } catch (err) {
93
+ const error = err instanceof Error ? err : new Error(String(err))
94
+ const attempt: FallbackAttempt = { ref, outcome: 'hard', errorMessage: error.message }
95
+ attempts.push(attempt)
96
+ lastError = error
97
+ if (!isLast) opts.onAttemptFailed?.(attempt)
98
+ unsub()
99
+ await dispose()
100
+ if (isLast) {
101
+ return { success: false, refUsed: ref, attempts, session, dispose: async () => {}, lastError }
102
+ }
103
+ continue
104
+ }
105
+ if (softError !== undefined) {
106
+ const attempt: FallbackAttempt = { ref, outcome: 'soft', errorMessage: softError.message }
107
+ attempts.push(attempt)
108
+ lastError = softError
109
+ if (!isLast) opts.onAttemptFailed?.(attempt)
110
+ unsub()
111
+ await dispose()
112
+ if (isLast) {
113
+ return { success: false, refUsed: ref, attempts, session, dispose: async () => {}, lastError }
114
+ }
115
+ continue
116
+ }
117
+ attempts.push({ ref, outcome: 'success' })
118
+ unsub()
119
+ return { success: true, refUsed: ref, attempts, session, dispose }
120
+ } catch (err) {
121
+ unsub()
122
+ await dispose()
123
+ throw err
124
+ }
125
+ }
126
+ throw new Error('promptWithFallback: unreachable — loop terminated without returning')
127
+ }
@@ -8,7 +8,7 @@ export type SessionMetaPayload = {
8
8
 
9
9
  export type MinimalSessionOrigin =
10
10
  | { kind: 'tui' }
11
- | { kind: 'cron'; jobId: string; jobKind: 'prompt' | 'exec' | 'subagent' }
11
+ | { kind: 'cron'; jobId: string; jobKind: 'prompt' | 'exec' | 'subagent' | 'handler' }
12
12
  | { kind: 'channel'; adapter: string; workspace: string; chat: string; thread: string | null }
13
13
  | { kind: 'subagent'; subagent: string; parentSessionId: string }
14
14
 
@@ -25,7 +25,7 @@ export type SessionOrigin =
25
25
  | {
26
26
  kind: 'cron'
27
27
  jobId: string
28
- jobKind: 'prompt' | 'exec' | 'subagent'
28
+ jobKind: 'prompt' | 'exec' | 'subagent' | 'handler'
29
29
  scheduledByRole?: string
30
30
  scheduledByOrigin?: SessionOrigin | { kind: 'config-file' }
31
31
  }
@@ -78,6 +78,7 @@ type PlatformInfo = {
78
78
  const PLATFORM_INFO: Record<AdapterId, PlatformInfo> = {
79
79
  'slack-bot': { displayName: 'Slack', mentionMode: 'angle-id' },
80
80
  'discord-bot': { displayName: 'Discord', mentionMode: 'angle-id' },
81
+ github: { displayName: 'GitHub', mentionMode: 'at-username' },
81
82
  'telegram-bot': { displayName: 'Telegram', mentionMode: 'at-username' },
82
83
  kakaotalk: { displayName: 'KakaoTalk', mentionMode: 'alias' },
83
84
  }
@@ -150,7 +151,7 @@ function renderTuiOrigin(): string {
150
151
  ].join('\n')
151
152
  }
152
153
 
153
- function renderCronOrigin(origin: { jobId: string; jobKind: 'prompt' | 'exec' | 'subagent' }): string {
154
+ function renderCronOrigin(origin: { jobId: string; jobKind: 'prompt' | 'exec' | 'subagent' | 'handler' }): string {
154
155
  return [
155
156
  '## Session origin',
156
157
  '',