typeclaw 0.32.1 → 0.34.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 (65) hide show
  1. package/auth.schema.json +66 -0
  2. package/cron.schema.json +26 -2
  3. package/package.json +1 -1
  4. package/secrets.schema.json +66 -0
  5. package/src/agent/index.ts +7 -3
  6. package/src/agent/session-origin.ts +17 -0
  7. package/src/agent/subagent-completion-reminder.ts +14 -1
  8. package/src/agent/subagent-drain.ts +2 -0
  9. package/src/agent/subagents.ts +21 -7
  10. package/src/agent/tools/channel-disengage.ts +66 -0
  11. package/src/agent/tools/channel-log.ts +3 -2
  12. package/src/agent/tools/spawn-subagent.ts +25 -5
  13. package/src/agent/tools/subagent-output.ts +13 -1
  14. package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
  15. package/src/bundled-plugins/memory/memory-logger.ts +7 -0
  16. package/src/bundled-plugins/researcher/researcher.ts +14 -11
  17. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +2 -0
  18. package/src/channels/adapters/line-channel-resolver.ts +129 -0
  19. package/src/channels/adapters/line-classify.ts +80 -0
  20. package/src/channels/adapters/line-format.ts +11 -0
  21. package/src/channels/adapters/line.ts +350 -0
  22. package/src/channels/engagement.ts +4 -2
  23. package/src/channels/manager.ts +65 -6
  24. package/src/channels/router.ts +186 -41
  25. package/src/channels/schema.ts +6 -1
  26. package/src/cli/channel.ts +112 -1
  27. package/src/cli/cron.ts +22 -4
  28. package/src/cli/init.ts +267 -82
  29. package/src/cli/model.ts +5 -1
  30. package/src/cli/oauth-callbacks.ts +5 -4
  31. package/src/cli/provider.ts +41 -10
  32. package/src/config/providers.ts +366 -7
  33. package/src/cron/consumer.ts +33 -0
  34. package/src/cron/count-state.ts +208 -0
  35. package/src/cron/index.ts +4 -17
  36. package/src/cron/list.ts +24 -6
  37. package/src/cron/scheduler.ts +84 -9
  38. package/src/cron/schema.ts +100 -13
  39. package/src/doctor/channel-checks.ts +28 -0
  40. package/src/hostd/daemon.ts +14 -6
  41. package/src/hostd/protocol.ts +6 -2
  42. package/src/init/gitignore.ts +1 -1
  43. package/src/init/index.ts +36 -3
  44. package/src/init/line-auth.ts +98 -0
  45. package/src/init/models-dev.ts +3 -0
  46. package/src/init/run-owner-claim.ts +1 -0
  47. package/src/init/validate-api-key.ts +15 -0
  48. package/src/inspect/label.ts +1 -0
  49. package/src/permissions/match-rule.ts +28 -12
  50. package/src/permissions/resolve.ts +8 -1
  51. package/src/role-claim/match-rule.ts +5 -1
  52. package/src/run/index.ts +41 -4
  53. package/src/secrets/line-store.ts +112 -0
  54. package/src/secrets/oauth-xai.ts +342 -0
  55. package/src/secrets/schema.ts +25 -0
  56. package/src/secrets/storage.ts +2 -0
  57. package/src/server/index.ts +17 -4
  58. package/src/shared/protocol.ts +4 -1
  59. package/src/skills/typeclaw-channel-line/SKILL.md +46 -0
  60. package/src/skills/typeclaw-channels/SKILL.md +153 -0
  61. package/src/skills/typeclaw-config/SKILL.md +54 -184
  62. package/src/skills/typeclaw-config/references/dockerfile.md +66 -0
  63. package/src/skills/typeclaw-cron/SKILL.md +68 -14
  64. package/src/skills/typeclaw-permissions/SKILL.md +3 -3
  65. package/typeclaw.schema.json +185 -3
@@ -0,0 +1,112 @@
1
+ import type { LineAccountCredentials, LineConfig } from 'agent-messenger/line'
2
+
3
+ import { sendHttp } from '@/hostd/client'
4
+
5
+ import { type LineChannelBlock, lineChannelBlockSchema } from './schema'
6
+ import { SecretsBackend } from './storage'
7
+
8
+ export type SecretsLineCredentialStoreOptions =
9
+ | { mode: 'host'; secretsPath: string }
10
+ | { mode: 'container'; secretsPath: string; hostdUrl: string; restartToken: string; containerName: string }
11
+
12
+ const EMPTY_BLOCK: LineChannelBlock = { currentAccount: null, accounts: {} }
13
+
14
+ export class SecretsLineCredentialStore {
15
+ private readonly backend: SecretsBackend
16
+ private writeChain: Promise<void> = Promise.resolve()
17
+
18
+ constructor(private readonly options: SecretsLineCredentialStoreOptions) {
19
+ this.backend = new SecretsBackend(options.secretsPath)
20
+ }
21
+
22
+ async load(): Promise<LineConfig> {
23
+ return toLineConfig(this.readBlock())
24
+ }
25
+
26
+ async save(config: LineConfig): Promise<void> {
27
+ await this.writeBlock(() => fromLineConfig(config))
28
+ }
29
+
30
+ async getAccount(id?: string): Promise<LineAccountCredentials | null> {
31
+ const config = await this.load()
32
+ if (id) return config.accounts[id] ?? null
33
+ if (!config.current_account) return null
34
+ return config.accounts[config.current_account] ?? null
35
+ }
36
+
37
+ async setAccount(account: LineAccountCredentials): Promise<void> {
38
+ await this.writeBlock((block) => {
39
+ const accounts = { ...block.accounts, [account.account_id]: account }
40
+ return { ...block, currentAccount: block.currentAccount ?? account.account_id, accounts }
41
+ })
42
+ }
43
+
44
+ async removeAccount(id: string): Promise<void> {
45
+ await this.writeBlock((block) => {
46
+ const accounts = { ...block.accounts }
47
+ delete accounts[id]
48
+ const currentAccount = block.currentAccount === id ? (Object.keys(accounts)[0] ?? null) : block.currentAccount
49
+ return { ...block, currentAccount, accounts }
50
+ })
51
+ }
52
+
53
+ async listAccounts(): Promise<Array<LineAccountCredentials & { is_current: boolean }>> {
54
+ const config = await this.load()
55
+ return Object.values(config.accounts).map((account) => ({
56
+ ...account,
57
+ is_current: account.account_id === config.current_account,
58
+ }))
59
+ }
60
+
61
+ async setCurrentAccount(id: string): Promise<void> {
62
+ await this.writeBlock((block) => ({ ...block, currentAccount: id }))
63
+ }
64
+
65
+ private readBlock(): LineChannelBlock {
66
+ const channels =
67
+ this.options.mode === 'container' ? this.backend.tryReadChannelsSync() : this.backend.readChannelsSync()
68
+ return parseBlock(channels?.line)
69
+ }
70
+
71
+ private async writeBlock(update: (current: LineChannelBlock) => LineChannelBlock): Promise<void> {
72
+ return this.enqueueWrite(async () => {
73
+ if (this.options.mode === 'container') {
74
+ const next = update(this.readBlock())
75
+ const response = await sendHttp(
76
+ {
77
+ kind: 'secrets-patch',
78
+ containerName: this.options.containerName,
79
+ patch: { channels: { line: next } },
80
+ },
81
+ { url: this.options.hostdUrl, token: this.options.restartToken },
82
+ )
83
+ if (!response.ok) throw new Error(`secrets-patch failed: ${response.reason}`)
84
+ return
85
+ }
86
+
87
+ await this.backend.updateChannelsAsync(async (channels) => {
88
+ const next = { ...channels, line: update(parseBlock(channels.line)) }
89
+ return { result: undefined, next }
90
+ })
91
+ })
92
+ }
93
+
94
+ private enqueueWrite(op: () => Promise<void>): Promise<void> {
95
+ const next = this.writeChain.then(op, op)
96
+ this.writeChain = next.catch(() => {})
97
+ return next
98
+ }
99
+ }
100
+
101
+ function parseBlock(value: unknown): LineChannelBlock {
102
+ if (value === undefined) return EMPTY_BLOCK
103
+ return lineChannelBlockSchema.parse(value)
104
+ }
105
+
106
+ function toLineConfig(block: LineChannelBlock): LineConfig {
107
+ return { current_account: block.currentAccount, accounts: block.accounts }
108
+ }
109
+
110
+ function fromLineConfig(config: LineConfig): LineChannelBlock {
111
+ return { currentAccount: config.current_account, accounts: config.accounts }
112
+ }
@@ -0,0 +1,342 @@
1
+ import { createServer, type Server } from 'node:http'
2
+
3
+ import { registerOAuthProvider } from '@mariozechner/pi-ai/oauth'
4
+ import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from '@mariozechner/pi-ai/oauth'
5
+
6
+ // xAI (Grok) OAuth 2.0. xAI runs a standard OIDC authorization server at
7
+ // auth.x.ai that supports BOTH authorization-code + PKCE (loopback callback)
8
+ // and the device-authorization grant. We implement the auth-code path with a
9
+ // localhost callback server (same UX as pi-ai's anthropic provider) plus a
10
+ // manual-paste fallback for cross-device/SSH flows.
11
+ //
12
+ // There is no public developer console to register a third-party OAuth client,
13
+ // so — like every OSS Grok integration (Grok CLI, opencode, hermes-agent,
14
+ // pi-xai-oauth) — we reuse the Grok CLI's public client id. The `plan=generic`
15
+ // query param is load-bearing: loopback OAuth against this client id is
16
+ // rejected without it. `referrer` is attribution only.
17
+ //
18
+ // Endpoints below are the live values from
19
+ // https://auth.x.ai/.well-known/openid-configuration. The token endpoint speaks
20
+ // application/x-www-form-urlencoded (OAuth2 default) — NOT JSON like Anthropic.
21
+ export const XAI_OAUTH_PROVIDER_ID = 'xai'
22
+
23
+ const CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828'
24
+ const AUTHORIZE_URL = 'https://auth.x.ai/oauth2/authorize'
25
+ const TOKEN_URL = 'https://auth.x.ai/oauth2/token'
26
+ const CALLBACK_HOST = '127.0.0.1'
27
+ const CALLBACK_PORT = 56121
28
+ const CALLBACK_PATH = '/callback'
29
+ const REDIRECT_URI = `http://${CALLBACK_HOST}:${CALLBACK_PORT}${CALLBACK_PATH}`
30
+ const SCOPES = 'openid profile email offline_access grok-cli:access api:access'
31
+ const REFERRER = 'typeclaw'
32
+ // Refresh slightly early so an in-flight request never races expiry.
33
+ const EXPIRY_SKEW_MS = 5 * 60 * 1000
34
+ const REQUEST_TIMEOUT_MS = 30_000
35
+
36
+ type XaiTokenResponse = {
37
+ access_token?: string
38
+ refresh_token?: string
39
+ expires_in?: number
40
+ token_type?: string
41
+ }
42
+
43
+ function base64UrlEncode(bytes: Uint8Array): string {
44
+ let binary = ''
45
+ for (const byte of bytes) binary += String.fromCharCode(byte)
46
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
47
+ }
48
+
49
+ // PKCE (RFC 7636, S256). pi-ai keeps its `generatePKCE` helper out of the
50
+ // public `/oauth` barrel, so we generate the verifier/challenge with Web Crypto
51
+ // directly — available in both Node and Bun.
52
+ async function generatePkce(): Promise<{ verifier: string; challenge: string }> {
53
+ const verifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)))
54
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
55
+ return { verifier, challenge: base64UrlEncode(new Uint8Array(digest)) }
56
+ }
57
+
58
+ function parseAuthorizationInput(input: string): { code?: string; state?: string } {
59
+ const value = input.trim()
60
+ if (!value) return {}
61
+ try {
62
+ const url = new URL(value)
63
+ return {
64
+ code: url.searchParams.get('code') ?? undefined,
65
+ state: url.searchParams.get('state') ?? undefined,
66
+ }
67
+ } catch {
68
+ // Not a full URL — fall through to query-string / bare-code handling.
69
+ }
70
+ if (value.includes('code=')) {
71
+ const params = new URLSearchParams(value)
72
+ return {
73
+ code: params.get('code') ?? undefined,
74
+ state: params.get('state') ?? undefined,
75
+ }
76
+ }
77
+ return { code: value }
78
+ }
79
+
80
+ type CallbackServer = {
81
+ server: Server
82
+ cancelWait: () => void
83
+ waitForCode: () => Promise<{ code: string; state: string | null } | null>
84
+ }
85
+
86
+ function successHtml(): string {
87
+ return '<!doctype html><html><body style="font-family:sans-serif;padding:2rem"><h2>xAI authentication complete.</h2><p>You can close this window and return to the terminal.</p></body></html>'
88
+ }
89
+
90
+ // The OAuth `error` query param is provider-supplied and reflected into the
91
+ // callback page, so escape it to keep a crafted callback URL
92
+ // (`?error=<script>…`) from injecting markup into the local page.
93
+ function escapeHtml(value: string): string {
94
+ return value
95
+ .replaceAll('&', '&amp;')
96
+ .replaceAll('<', '&lt;')
97
+ .replaceAll('>', '&gt;')
98
+ .replaceAll('"', '&quot;')
99
+ .replaceAll("'", '&#39;')
100
+ }
101
+
102
+ export function errorHtml(message: string): string {
103
+ return `<!doctype html><html><body style="font-family:sans-serif;padding:2rem"><h2>xAI authentication failed.</h2><p>${escapeHtml(message)}</p></body></html>`
104
+ }
105
+
106
+ // Resolves to `null` when the fixed loopback port can't be bound (EADDRINUSE,
107
+ // sandbox bind restriction). The caller then falls back to manual-paste mode
108
+ // rather than failing the whole login — the browser callback is a convenience,
109
+ // not a hard requirement, since the user can always paste the redirect URL.
110
+ function startCallbackServer(expectedState: string): Promise<CallbackServer | null> {
111
+ return new Promise((resolve) => {
112
+ let settle: ((value: { code: string; state: string | null } | null) => void) | undefined
113
+ const waitForCodePromise = new Promise<{ code: string; state: string | null } | null>((resolveWait) => {
114
+ let settled = false
115
+ settle = (value) => {
116
+ if (settled) return
117
+ settled = true
118
+ resolveWait(value)
119
+ }
120
+ })
121
+
122
+ const server = createServer((req, res) => {
123
+ try {
124
+ const url = new URL(req.url || '', `http://${CALLBACK_HOST}`)
125
+ if (url.pathname !== CALLBACK_PATH) {
126
+ res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' })
127
+ res.end(errorHtml('Callback route not found.'))
128
+ return
129
+ }
130
+ const code = url.searchParams.get('code')
131
+ const state = url.searchParams.get('state')
132
+ const error = url.searchParams.get('error')
133
+ if (error) {
134
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' })
135
+ res.end(errorHtml(`Error: ${error}`))
136
+ return
137
+ }
138
+ if (!code) {
139
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' })
140
+ res.end(errorHtml('Missing code parameter.'))
141
+ return
142
+ }
143
+ if (state !== expectedState) {
144
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' })
145
+ res.end(errorHtml('State mismatch.'))
146
+ return
147
+ }
148
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
149
+ res.end(successHtml())
150
+ settle?.({ code, state })
151
+ } catch {
152
+ res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' })
153
+ res.end('Internal error')
154
+ }
155
+ })
156
+
157
+ server.on('error', () => {
158
+ server.close()
159
+ resolve(null)
160
+ })
161
+ server.listen(CALLBACK_PORT, CALLBACK_HOST, () => {
162
+ resolve({
163
+ server,
164
+ cancelWait: () => settle?.(null),
165
+ waitForCode: () => waitForCodePromise,
166
+ })
167
+ })
168
+ })
169
+ }
170
+
171
+ export type FetchFn = (input: string, init: RequestInit) => Promise<Response>
172
+
173
+ async function postForm(url: string, body: Record<string, string>, fetchImpl: FetchFn): Promise<XaiTokenResponse> {
174
+ const response = await fetchImpl(url, {
175
+ method: 'POST',
176
+ headers: {
177
+ 'Content-Type': 'application/x-www-form-urlencoded',
178
+ Accept: 'application/json',
179
+ },
180
+ body: new URLSearchParams(body).toString(),
181
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
182
+ })
183
+ const text = await response.text()
184
+ if (!response.ok) {
185
+ throw new Error(`xAI OAuth request failed. status=${response.status}; url=${url}; body=${text}`)
186
+ }
187
+ try {
188
+ return JSON.parse(text) as XaiTokenResponse
189
+ } catch {
190
+ throw new Error(`xAI OAuth returned invalid JSON. url=${url}; body=${text}`)
191
+ }
192
+ }
193
+
194
+ function toCredentials(token: XaiTokenResponse): OAuthCredentials {
195
+ if (!token.access_token || !token.refresh_token || token.expires_in === undefined) {
196
+ throw new Error('xAI OAuth response missing access_token, refresh_token, or expires_in')
197
+ }
198
+ return {
199
+ access: token.access_token,
200
+ refresh: token.refresh_token,
201
+ expires: Date.now() + token.expires_in * 1000 - EXPIRY_SKEW_MS,
202
+ }
203
+ }
204
+
205
+ async function exchangeAuthorizationCode(
206
+ code: string,
207
+ verifier: string,
208
+ fetchImpl: FetchFn,
209
+ ): Promise<OAuthCredentials> {
210
+ const token = await postForm(
211
+ TOKEN_URL,
212
+ {
213
+ grant_type: 'authorization_code',
214
+ client_id: CLIENT_ID,
215
+ code,
216
+ redirect_uri: REDIRECT_URI,
217
+ code_verifier: verifier,
218
+ },
219
+ fetchImpl,
220
+ )
221
+ return toCredentials(token)
222
+ }
223
+
224
+ export async function loginXai(callbacks: OAuthLoginCallbacks, fetchImpl: FetchFn = fetch): Promise<OAuthCredentials> {
225
+ const { verifier, challenge } = await generatePkce()
226
+ const server = await startCallbackServer(verifier)
227
+ let code: string | undefined
228
+ try {
229
+ const authParams = new URLSearchParams({
230
+ client_id: CLIENT_ID,
231
+ response_type: 'code',
232
+ redirect_uri: REDIRECT_URI,
233
+ scope: SCOPES,
234
+ code_challenge: challenge,
235
+ code_challenge_method: 'S256',
236
+ state: verifier,
237
+ plan: 'generic',
238
+ referrer: REFERRER,
239
+ })
240
+ callbacks.onAuth({
241
+ url: `${AUTHORIZE_URL}?${authParams.toString()}`,
242
+ instructions:
243
+ 'Complete login in your browser. Grok shows a code to copy on the "could not establish connection" page — paste that code here. If the browser is on another machine, paste the code (or the final redirect URL) here.',
244
+ })
245
+
246
+ if (server && callbacks.onManualCodeInput) {
247
+ // Race the local callback server against a manual paste: whichever lands
248
+ // a code first wins (cross-device/SSH logins can't reach the loopback).
249
+ let manualInput: string | undefined
250
+ let manualError: Error | undefined
251
+ const manualPromise = callbacks
252
+ .onManualCodeInput()
253
+ .then((input) => {
254
+ manualInput = input
255
+ server.cancelWait()
256
+ })
257
+ .catch((err) => {
258
+ manualError = err instanceof Error ? err : new Error(String(err))
259
+ server.cancelWait()
260
+ })
261
+
262
+ const result = await server.waitForCode()
263
+ if (manualError) throw manualError
264
+ if (result?.code) {
265
+ code = result.code
266
+ } else if (manualInput) {
267
+ code = parseManualCode(manualInput, verifier)
268
+ }
269
+ if (!code) {
270
+ await manualPromise
271
+ if (manualError) throw manualError
272
+ if (manualInput) {
273
+ code = parseManualCode(manualInput, verifier)
274
+ }
275
+ }
276
+ } else if (server) {
277
+ const result = await server.waitForCode()
278
+ if (result?.code) code = result.code
279
+ } else if (callbacks.onManualCodeInput) {
280
+ // No callback server bound — manual paste is the only path to a code.
281
+ code = parseManualCode(await callbacks.onManualCodeInput(), verifier)
282
+ }
283
+
284
+ if (!code) {
285
+ const input = await callbacks.onPrompt({
286
+ message: 'Paste the authorization code or full redirect URL:',
287
+ placeholder: REDIRECT_URI,
288
+ })
289
+ code = parseManualCode(input, verifier)
290
+ }
291
+
292
+ if (!code) throw new Error('Missing authorization code')
293
+
294
+ callbacks.onProgress?.('Exchanging authorization code for tokens...')
295
+ return await exchangeAuthorizationCode(code, verifier, fetchImpl)
296
+ } finally {
297
+ server?.server.close()
298
+ }
299
+ }
300
+
301
+ function parseManualCode(input: string, verifier: string): string | undefined {
302
+ const parsed = parseAuthorizationInput(input)
303
+ if (parsed.state && parsed.state !== verifier) throw new Error('OAuth state mismatch')
304
+ return parsed.code
305
+ }
306
+
307
+ export async function refreshXaiToken(refreshToken: string, fetchImpl: FetchFn = fetch): Promise<OAuthCredentials> {
308
+ const token = await postForm(
309
+ TOKEN_URL,
310
+ {
311
+ grant_type: 'refresh_token',
312
+ client_id: CLIENT_ID,
313
+ refresh_token: refreshToken,
314
+ },
315
+ fetchImpl,
316
+ )
317
+ // Some OAuth servers omit a rotated refresh token on refresh; keep the prior
318
+ // one so the credential stays usable across the next cycle.
319
+ return toCredentials({ ...token, refresh_token: token.refresh_token ?? refreshToken })
320
+ }
321
+
322
+ export const xaiOAuthProvider: OAuthProviderInterface = {
323
+ id: XAI_OAUTH_PROVIDER_ID,
324
+ name: 'xAI (Grok)',
325
+ usesCallbackServer: true,
326
+ login: loginXai,
327
+ refreshToken: (credentials) => refreshXaiToken(credentials.refresh),
328
+ getApiKey: (credentials) => credentials.access,
329
+ }
330
+
331
+ let registered = false
332
+
333
+ // pi-ai ships no built-in xAI OAuth provider, so we register ours. Idempotent
334
+ // and called from `createSecretsStoreForAgent` — the single chokepoint both the
335
+ // init-time login path and the container-runtime auth/refresh path go through —
336
+ // so the provider is always present before `AuthStorage.login()` /
337
+ // `getApiKey()` look it up via `getOAuthProvider('xai')`.
338
+ export function registerXaiOAuthProvider(): void {
339
+ if (registered) return
340
+ registerOAuthProvider(xaiOAuthProvider)
341
+ registered = true
342
+ }
@@ -55,6 +55,28 @@ const githubChannelSchema = z.object({
55
55
  webhookSecret: secretFieldSchema,
56
56
  })
57
57
 
58
+ const lineDeviceSchema = z.enum(['DESKTOPWIN', 'DESKTOPMAC', 'ANDROID', 'ANDROIDSECONDARY', 'IOS', 'IOSIPAD'])
59
+
60
+ // LINE persists a long-lived auth token (+ optional certificate that lets a
61
+ // later re-login skip the e-mail/PIN step on the same device). There is no
62
+ // encrypted-password / renewal-cron path the way KakaoTalk has — LINE tokens
63
+ // don't expire on a fixed short schedule, so the renewal fields are absent by
64
+ // design.
65
+ export const lineAccountRecordSchema = z.object({
66
+ account_id: z.string(),
67
+ auth_token: z.string(),
68
+ certificate: z.string().optional(),
69
+ device: lineDeviceSchema,
70
+ display_name: z.string().optional(),
71
+ created_at: z.string(),
72
+ updated_at: z.string(),
73
+ })
74
+
75
+ export const lineChannelBlockSchema = z.object({
76
+ currentAccount: z.string().nullable(),
77
+ accounts: z.record(z.string(), lineAccountRecordSchema),
78
+ })
79
+
58
80
  // Encrypted password envelope produced by src/secrets/encryption.ts. Optional
59
81
  // in the schema because legacy v2 accounts (pre-renewal feature) don't have
60
82
  // one; the renewal cron treats a missing envelope as "reauth required" and
@@ -109,6 +131,7 @@ export const channelsSchema = z
109
131
  'discord-bot': discordBotChannelSchema.optional(),
110
132
  github: githubChannelSchema.optional(),
111
133
  'telegram-bot': telegramBotChannelSchema.optional(),
134
+ line: lineChannelBlockSchema.optional(),
112
135
  kakaotalk: kakaoChannelBlockSchema.optional(),
113
136
  })
114
137
  .catchall(z.unknown())
@@ -130,6 +153,8 @@ export type Channels = z.infer<typeof channelsSchema>
130
153
  export type GithubPatAuthBlock = z.infer<typeof githubPatAuthSchema>
131
154
  export type GithubAppAuthBlock = z.infer<typeof githubAppAuthSchema>
132
155
  export type GithubSecretsBlock = z.infer<typeof githubChannelSchema>
156
+ export type LineAccountRecord = z.infer<typeof lineAccountRecordSchema>
157
+ export type LineChannelBlock = z.infer<typeof lineChannelBlockSchema>
133
158
  export type KakaoAccountRecord = z.infer<typeof kakaoAccountRecordSchema>
134
159
  export type PendingLoginRecord = z.infer<typeof kakaoPendingLoginRecordSchema>
135
160
  export type KakaoChannelBlock = z.infer<typeof kakaoChannelBlockSchema>
@@ -9,6 +9,7 @@ import {
9
9
  import lockfile from 'proper-lockfile'
10
10
 
11
11
  import { providerKeyDefaultEnv } from './defaults'
12
+ import { registerXaiOAuthProvider } from './oauth-xai'
12
13
  import { resolveSecret, type Secret } from './resolve'
13
14
  import {
14
15
  type Channels,
@@ -374,6 +375,7 @@ export class SecretsBackend implements AuthStorageBackend {
374
375
  }
375
376
 
376
377
  export function createSecretsStoreForAgent(secretsPath: string): AuthStorage {
378
+ registerXaiOAuthProvider()
377
379
  return AuthStorageImpl.fromStorage(new SecretsBackend(secretsPath))
378
380
  }
379
381
 
@@ -28,7 +28,7 @@ import {
28
28
  } from '@/agent/todo/continuation-wiring'
29
29
  import { SUBAGENT_OUTPUT_TOOL_NAME } from '@/agent/tools/subagent-output'
30
30
  import type { ChannelRouter } from '@/channels/router'
31
- import { aggregateCronList, type CronListEntry, loadCron } from '@/cron'
31
+ import { aggregateCronList, type CronJob, type CronListEntry, loadCron } from '@/cron'
32
32
  import type { McpManager } from '@/mcp'
33
33
  import type { HookBus } from '@/plugin'
34
34
  import type { BrokerWsData, ContainerBroker } from '@/portbroker'
@@ -75,6 +75,9 @@ export type ServerOptions = {
75
75
  mcpManager?: McpManager
76
76
  agentDir?: string
77
77
  pluginRuntime?: PluginRuntime
78
+ // Durable cron fire-progress lookup so `cron list` marks count-exhausted jobs
79
+ // as retired instead of showing a stale future fire time. Omit in tests/dev.
80
+ getFiredCount?: (job: CronJob) => number
78
81
  containerName?: string
79
82
  runtimeVersion?: string
80
83
  tuiToken?: string
@@ -252,6 +255,7 @@ export function createServer({
252
255
  mcpManager,
253
256
  agentDir,
254
257
  pluginRuntime,
258
+ getFiredCount,
255
259
  containerName,
256
260
  runtimeVersion,
257
261
  tuiToken,
@@ -716,7 +720,7 @@ export function createServer({
716
720
  }
717
721
 
718
722
  if (msg.type === 'cron_list') {
719
- await handleCronList(ws, msg.requestId, pluginRuntime, agentDir)
723
+ await handleCronList(ws, msg.requestId, pluginRuntime, agentDir, getFiredCount)
720
724
  return
721
725
  }
722
726
 
@@ -1186,6 +1190,7 @@ async function handleCronList(
1186
1190
  requestId: string,
1187
1191
  pluginRuntime: PluginRuntime | undefined,
1188
1192
  agentDir: string | undefined,
1193
+ getFiredCount?: (job: CronJob) => number,
1189
1194
  ): Promise<void> {
1190
1195
  if (agentDir === undefined) {
1191
1196
  send(ws, { type: 'cron_list_result', requestId, result: { ok: false, reason: 'agentDir not configured' } })
@@ -1208,7 +1213,12 @@ async function handleCronList(
1208
1213
  const userJobs = loadResult.file?.jobs ?? []
1209
1214
  const pluginJobs = snapshot?.registry.cronJobs ?? []
1210
1215
  const nowMs = Date.now()
1211
- const entries = aggregateCronList({ userJobs, pluginJobs, now: nowMs })
1216
+ const entries = aggregateCronList({
1217
+ userJobs,
1218
+ pluginJobs,
1219
+ now: nowMs,
1220
+ ...(getFiredCount !== undefined ? { firedCount: getFiredCount } : {}),
1221
+ })
1212
1222
  send(ws, {
1213
1223
  type: 'cron_list_result',
1214
1224
  requestId,
@@ -1541,9 +1551,12 @@ function toPayload(entry: CronListEntry): CronListEntryPayload {
1541
1551
  id: entry.id,
1542
1552
  source,
1543
1553
  kind: entry.kind,
1544
- schedule: entry.schedule,
1545
1554
  enabled: entry.enabled,
1546
1555
  nextFireMs: entry.nextFireMs,
1556
+ ...(entry.schedule !== undefined ? { schedule: entry.schedule } : {}),
1557
+ ...(entry.at !== undefined ? { at: entry.at } : {}),
1558
+ ...(entry.until !== undefined ? { until: entry.until } : {}),
1559
+ ...(entry.count !== undefined ? { count: entry.count } : {}),
1547
1560
  ...(entry.timezone !== undefined ? { timezone: entry.timezone } : {}),
1548
1561
  ...(entry.scheduledByRole !== undefined ? { scheduledByRole: entry.scheduledByRole } : {}),
1549
1562
  ...(entry.scheduleError !== undefined ? { scheduleError: entry.scheduleError } : {}),
@@ -165,7 +165,10 @@ export type CronListEntryPayload = {
165
165
  id: string
166
166
  source: CronListSourcePayload
167
167
  kind: 'prompt' | 'exec' | 'handler'
168
- schedule: string
168
+ schedule?: string
169
+ at?: string
170
+ until?: string
171
+ count?: number
169
172
  timezone?: string
170
173
  enabled: boolean
171
174
  scheduledByRole?: string
@@ -0,0 +1,46 @@
1
+ ---
2
+ name: typeclaw-channel-line
3
+ description: Use this skill BEFORE every `channel_reply` or `channel_send` call whose adapter is `line`. LINE 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 attachments or stickers. Inbound non-text content (images, stickers, files) is not fetchable through this adapter. Read this skill before composing anything on LINE.
4
+ ---
5
+
6
+ # typeclaw-channel-line
7
+
8
+ You are speaking through the `line` channel adapter. The adapter wraps `agent-messenger`'s LineClient/LineListener, which speaks LINE's protocol as a registered sub-device of a real user account.
9
+
10
+ This means **you are messaging as a person, not as a bot.** Other participants see your account's name and profile photo. Match their tone — the people you're talking with assume they're talking to a human.
11
+
12
+ ## What LINE does NOT support
13
+
14
+ LINE renders messages as plain text — it has no rich-text formatting. **Write plain text from the start.** The adapter strips common markdown as a safety net before sending (so an accidental `**bold**` won't leak literal asterisks), but treat that as a last-resort guard, not a license to write markdown: the strip removes _markers_, it cannot make formatting-dependent layouts like tables readable. Compose for a plain-text surface and you control the result.
15
+
16
+ Specifically, do not rely on any of the following — write the plain-text equivalent yourself:
17
+
18
+ - **Bold / italic / strikethrough** — emphasize with word choice, not `**asterisks**`.
19
+ - **Headings** — `# H1`, `## H2`, `### H3` carry no visual weight here. Lead with the point.
20
+ - **Tables** — the stripper cannot rescue a pipe-delimited table. Use bullet lists or short prose.
21
+ - **Code fences** — for short snippets, paste the code inline as plain text. For long snippets, summarize and offer to send it another way.
22
+ - **Inline code** — just write `foo`, no backticks.
23
+ - **Links with display text** — send the bare URL on its own line; the LINE client auto-links it. A `[label](url)` that slips through is reduced to `label (url)`, but a bare URL reads cleaner.
24
+ - **Mentions** — there is no `@user` syntax the protocol surfaces. Address people by name in the message body.
25
+ - **Threads / replies-with-quote** — every message is a top-level chat post. There is no per-message reply UI.
26
+ - **Outbound attachments / stickers** — the adapter sends text only. If the user asks you to send a file, image, or sticker, acknowledge the limit and offer text (e.g. paste a link to the file instead).
27
+
28
+ ## What LINE DOES support
29
+
30
+ - Plain UTF-8 text. Emoji are fine.
31
+ - URLs auto-linkify in the client. Send them bare — `https://example.com/foo`, no markdown wrapping.
32
+ - Newlines render as line breaks. Use `\n\n` to space paragraphs.
33
+
34
+ ## Inbound content
35
+
36
+ Inbound messages are text. LINE may deliver non-text content (images, stickers, files); the adapter surfaces only the text portion and you cannot fetch the bytes through this adapter. If a message arrives with no text, there is nothing for you to act on — do not invent attachment ids.
37
+
38
+ ## Chats
39
+
40
+ LINE chats fall into three workspace buckets:
41
+
42
+ - `@line-dm` — a 1:1 direct message.
43
+ - `@line-group` — a group or room (multi-party invite chat).
44
+ - `@line-square` — an OpenChat-style public community. Treat these as the most public surface; be conservative about what you say.
45
+
46
+ Engagement on group and square chats is alias-only (there is no @-mention): you are woken when someone uses one of your configured aliases, replies in a way the engagement layer tracks, or in a DM. In a 1:1 DM every message engages.