typeclaw 0.3.1 → 0.4.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 (89) 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/session-meta.ts +1 -1
  6. package/src/agent/session-origin.ts +3 -2
  7. package/src/bundled-plugins/security/index.ts +3 -2
  8. package/src/channels/adapters/github/auth-app.ts +120 -0
  9. package/src/channels/adapters/github/auth-pat.ts +50 -0
  10. package/src/channels/adapters/github/auth.ts +33 -0
  11. package/src/channels/adapters/github/channel-resolver.ts +30 -0
  12. package/src/channels/adapters/github/dedup.ts +26 -0
  13. package/src/channels/adapters/github/event-allowlist.ts +8 -0
  14. package/src/channels/adapters/github/fetch-attachment.ts +5 -0
  15. package/src/channels/adapters/github/history.ts +63 -0
  16. package/src/channels/adapters/github/inbound.ts +286 -0
  17. package/src/channels/adapters/github/index.ts +286 -0
  18. package/src/channels/adapters/github/managed-path.ts +54 -0
  19. package/src/channels/adapters/github/membership.ts +35 -0
  20. package/src/channels/adapters/github/outbound.ts +145 -0
  21. package/src/channels/adapters/github/webhook-register.ts +349 -0
  22. package/src/channels/manager.ts +94 -9
  23. package/src/channels/schema.ts +31 -1
  24. package/src/channels/tunnel-bridge.ts +51 -0
  25. package/src/cli/builtins.ts +28 -0
  26. package/src/cli/channel.ts +511 -25
  27. package/src/cli/container-command-client.ts +244 -0
  28. package/src/cli/cron.ts +173 -0
  29. package/src/cli/host-command-runner.ts +150 -0
  30. package/src/cli/index.ts +42 -1
  31. package/src/cli/init.ts +256 -27
  32. package/src/cli/model.ts +4 -2
  33. package/src/cli/plugin-command-help.ts +49 -0
  34. package/src/cli/plugin-commands-dispatch.ts +112 -0
  35. package/src/cli/plugin-commands.ts +118 -0
  36. package/src/cli/tui.ts +10 -2
  37. package/src/cli/tunnel.ts +533 -0
  38. package/src/cli/ui.ts +8 -3
  39. package/src/config/config.ts +75 -0
  40. package/src/container/start.ts +30 -3
  41. package/src/cron/bridge.ts +136 -0
  42. package/src/cron/consumer.ts +45 -5
  43. package/src/cron/index.ts +19 -2
  44. package/src/cron/list.ts +105 -0
  45. package/src/cron/scheduler.ts +12 -3
  46. package/src/cron/schema.ts +11 -3
  47. package/src/doctor/checks.ts +0 -50
  48. package/src/init/dockerfile.ts +59 -13
  49. package/src/init/ensure-deps.ts +15 -4
  50. package/src/init/github-webhook-install.ts +109 -0
  51. package/src/init/index.ts +505 -9
  52. package/src/init/run-bun-install.ts +17 -3
  53. package/src/init/run-owner-claim.ts +11 -2
  54. package/src/permissions/builtins.ts +6 -1
  55. package/src/permissions/match-rule.ts +24 -2
  56. package/src/permissions/resolve.ts +1 -0
  57. package/src/plugin/define.ts +42 -1
  58. package/src/plugin/index.ts +18 -3
  59. package/src/plugin/manager.ts +2 -0
  60. package/src/plugin/registry.ts +85 -3
  61. package/src/plugin/types.ts +138 -1
  62. package/src/plugin/zod-introspect.ts +100 -0
  63. package/src/role-claim/match-rule.ts +2 -1
  64. package/src/run/index.ts +110 -3
  65. package/src/secrets/index.ts +1 -1
  66. package/src/secrets/schema.ts +21 -0
  67. package/src/server/command-runner.ts +476 -0
  68. package/src/server/index.ts +388 -5
  69. package/src/shared/index.ts +8 -0
  70. package/src/shared/protocol.ts +80 -1
  71. package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
  72. package/src/skills/typeclaw-config/SKILL.md +27 -26
  73. package/src/skills/typeclaw-cron/SKILL.md +234 -3
  74. package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
  75. package/src/skills/typeclaw-permissions/SKILL.md +5 -4
  76. package/src/skills/typeclaw-plugins/SKILL.md +251 -5
  77. package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
  78. package/src/test-helpers/wait-for.ts +50 -0
  79. package/src/tui/index.ts +35 -4
  80. package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
  81. package/src/tunnels/events.ts +14 -0
  82. package/src/tunnels/index.ts +12 -0
  83. package/src/tunnels/log-ring.ts +54 -0
  84. package/src/tunnels/manager.ts +139 -0
  85. package/src/tunnels/providers/cloudflare-quick.ts +189 -0
  86. package/src/tunnels/providers/external.ts +53 -0
  87. package/src/tunnels/quick-url-parser.ts +5 -0
  88. package/src/tunnels/types.ts +43 -0
  89. package/typeclaw.schema.json +254 -1
@@ -10,14 +10,28 @@ import { runPluginDoctorChecks, runPluginDoctorFix } from '@/agent/doctor'
10
10
  import { detectProviderError } from '@/agent/provider-error'
11
11
  import type { SessionOrigin } from '@/agent/session-origin'
12
12
  import type { ChannelRouter } from '@/channels/router'
13
+ import { aggregateCronList, type CronListEntry, loadCron } from '@/cron'
13
14
  import type { HookBus } from '@/plugin'
14
15
  import type { BrokerWsData, ContainerBroker } from '@/portbroker'
15
16
  import type { ReloadAllResult, ReloadRegistry } from '@/reload'
16
17
  import type { ClaimController, ClaimResultEvent } from '@/role-claim'
17
18
  import type { PluginRuntime, PluginRuntimeState } from '@/run/plugin-runtime'
19
+ import type { CommandOutbound, CommandRunner } from '@/server/command-runner'
18
20
  import type { SessionFactory } from '@/sessions'
19
- import type { ClientMessage, PromptDelivery, QueueStateItem, ReloadResultPayload, ServerMessage } from '@/shared'
21
+ import type {
22
+ ClientMessage,
23
+ CronListEntryPayload,
24
+ CronListSourcePayload,
25
+ PromptDelivery,
26
+ QueueStateItem,
27
+ ReloadResultPayload,
28
+ ServerMessage,
29
+ TunnelLogsClientMessage,
30
+ TunnelLogsServerMessage,
31
+ TunnelSnapshot,
32
+ } from '@/shared'
20
33
  import type { Stream, StreamMessage, StreamMessageId, Unsubscribe } from '@/stream'
34
+ import type { TunnelManager } from '@/tunnels'
21
35
 
22
36
  export type ReloadAllFn = () => Promise<ReloadAllResult>
23
37
  export type CreateSessionFn = (options?: CreateSessionOptions) => Promise<AgentSession | CreateSessionResult>
@@ -46,6 +60,7 @@ export type ServerOptions = {
46
60
  // sessions. Omit to keep TUI-only behavior (used by tests + non-container
47
61
  // dev runs).
48
62
  containerBroker?: ContainerBroker
63
+ tunnelManager?: TunnelManager
49
64
  // Optional logger for server-side events. Defaults to `consoleLogger`
50
65
  // which writes to stdout/stderr so `typeclaw logs` surfaces every event.
51
66
  // Tests inject a fake logger to assert on captured output.
@@ -56,6 +71,14 @@ export type ServerOptions = {
56
71
  // `claim_started` / `claim_completed` / `claim_error` back over the
57
72
  // same connection. Omitted in tests that don't exercise the flow.
58
73
  claimController?: ClaimController
74
+ // Optional command runner factory. The server invokes this once at start
75
+ // with an `outbound` callback wired to send `command_stdout` / `command_stderr`
76
+ // / `command_exit` / `command_error` frames back to the originating WS for
77
+ // a given callId. The server owns the callId→ws map; the runner is
78
+ // transport-agnostic. Omitted in tests that don't exercise plugin commands;
79
+ // without it the four `exec_command`-family messages are answered with
80
+ // `command_error` so the host CLI sees a clean failure.
81
+ commandRunnerFactory?: (outbound: CommandOutbound) => CommandRunner
59
82
  }
60
83
 
61
84
  const consoleLogger: ServerLogger = {
@@ -67,8 +90,17 @@ const consoleLogger: ServerLogger = {
67
90
  export type Server = ReturnType<typeof createServer>
68
91
 
69
92
  type TuiWsData = { kind: 'tui'; sessionId: string }
70
- type WsData = TuiWsData | BrokerWsData
93
+ // Command-class connections skip TUI session bootstrap. Used by the host
94
+ // CLI's container-command-client. Authenticated with the same TUI token
95
+ // because both surfaces are owner-equivalent (a process that holds the
96
+ // TUI token can already do anything the TUI can).
97
+ type CommandWsData = { kind: 'command' }
98
+ type TunnelLogsWsData = { kind: 'tunnel-logs'; unsubscribe: Unsubscribe | null }
99
+ type WsData = TuiWsData | CommandWsData | TunnelLogsWsData | BrokerWsData
71
100
  type Ws = ServerWebSocket<TuiWsData>
101
+ type CommandWs = ServerWebSocket<CommandWsData>
102
+ type TunnelLogsWs = ServerWebSocket<TunnelLogsWsData>
103
+ type AnyOwnerWs = Ws | CommandWs
72
104
 
73
105
  type QueuedPrompt = {
74
106
  streamMessageId: StreamMessageId
@@ -95,8 +127,35 @@ type SessionState = {
95
127
  dispose: () => Promise<void>
96
128
  }
97
129
 
98
- function send(ws: Ws, msg: ServerMessage) {
99
- ws.send(JSON.stringify(msg))
130
+ // Swallows the Bun-thrown error when a command's async cleanup emits a
131
+ // final frame after its ws has begun closing. Returns false on failure so
132
+ // callers can debounce subsequent sends for the same callId.
133
+ export function safeWsSend(ws: { send: (data: string) => void }, msg: ServerMessage): boolean {
134
+ try {
135
+ ws.send(JSON.stringify(msg))
136
+ return true
137
+ } catch {
138
+ return false
139
+ }
140
+ }
141
+
142
+ function send(ws: Ws, msg: ServerMessage): boolean {
143
+ return safeWsSend(ws, msg)
144
+ }
145
+
146
+ function sendTunnelLog(ws: TunnelLogsWs, msg: TunnelLogsServerMessage): boolean {
147
+ try {
148
+ ws.send(JSON.stringify(msg))
149
+ return true
150
+ } catch {
151
+ return false
152
+ }
153
+ }
154
+
155
+ function encodeBase64(bytes: Uint8Array): string {
156
+ let s = ''
157
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i] ?? 0)
158
+ return btoa(s)
100
159
  }
101
160
 
102
161
  export function createServer({
@@ -113,10 +172,107 @@ export function createServer({
113
172
  runtimeVersion,
114
173
  tuiToken,
115
174
  containerBroker,
175
+ tunnelManager,
116
176
  logger = consoleLogger,
117
177
  claimController,
178
+ commandRunnerFactory,
118
179
  }: ServerOptions) {
119
180
  const sessionStates = new WeakMap<Ws, SessionState>()
181
+ const callIdToWs = new Map<string, AnyOwnerWs>()
182
+ const commandRunner: CommandRunner | undefined = commandRunnerFactory
183
+ ? commandRunnerFactory({
184
+ stdout(callId, chunk) {
185
+ const ws = callIdToWs.get(callId)
186
+ if (ws) safeWsSend(ws, { type: 'command_stdout', callId, chunk: encodeBase64(chunk) })
187
+ },
188
+ stderr(callId, chunk) {
189
+ const ws = callIdToWs.get(callId)
190
+ if (ws) safeWsSend(ws, { type: 'command_stderr', callId, chunk: encodeBase64(chunk) })
191
+ },
192
+ exit(callId, code) {
193
+ const ws = callIdToWs.get(callId)
194
+ callIdToWs.delete(callId)
195
+ if (ws) safeWsSend(ws, { type: 'command_exit', callId, code })
196
+ },
197
+ error(callId, message) {
198
+ const ws = callIdToWs.get(callId)
199
+ if (ws) safeWsSend(ws, { type: 'command_error', callId, message })
200
+ },
201
+ })
202
+ : undefined
203
+
204
+ // Shared command-frame dispatcher: both TUI-class and command-class
205
+ // connections route through here. Non-command ClientMessage types are
206
+ // silently dropped so command-class connections (which only ever speak
207
+ // exec_command/command_stdin/command_stdin_end/command_abort) can reuse
208
+ // the same handler as the TUI without spreading switch logic.
209
+ function handleCommandFrame(ws: AnyOwnerWs, msg: ClientMessage): void {
210
+ if (msg.type === 'exec_command') {
211
+ if (!commandRunner) {
212
+ safeWsSend(ws, {
213
+ type: 'command_error',
214
+ callId: msg.callId,
215
+ message: 'plugin commands are not enabled on this agent',
216
+ })
217
+ safeWsSend(ws, { type: 'command_exit', callId: msg.callId, code: 1 })
218
+ return
219
+ }
220
+ // Guard at the WS layer BEFORE registering the callId→ws mapping:
221
+ // if another connection (or this one) is already running a command
222
+ // with this callId, refuse the replay. Without this check, a stale
223
+ // or malicious client could overwrite the mapping and steal the
224
+ // original command's output frames.
225
+ if (callIdToWs.has(msg.callId)) {
226
+ safeWsSend(ws, {
227
+ type: 'command_error',
228
+ callId: msg.callId,
229
+ message: `callId "${msg.callId}" is already in flight`,
230
+ })
231
+ safeWsSend(ws, { type: 'command_exit', callId: msg.callId, code: 1 })
232
+ return
233
+ }
234
+ // Parse the optional parent-origin JSON: invalid JSON falls back to
235
+ // the synthetic owner origin rather than rejecting the command, so a
236
+ // malformed env var on the caller's side doesn't break the whole
237
+ // dispatch. The runner uses the parsed value as spawnedByOrigin
238
+ // verbatim — the trust boundary is the WS auth token, not JSON shape.
239
+ let parentOrigin: SessionOrigin | undefined
240
+ if (msg.parentOriginJson !== undefined) {
241
+ try {
242
+ parentOrigin = JSON.parse(msg.parentOriginJson) as SessionOrigin
243
+ } catch {
244
+ parentOrigin = undefined
245
+ }
246
+ }
247
+ callIdToWs.set(msg.callId, ws)
248
+ commandRunner.start(
249
+ {
250
+ callId: msg.callId,
251
+ name: msg.name,
252
+ args: msg.args,
253
+ ...(msg.isolated !== undefined ? { isolated: msg.isolated } : {}),
254
+ ...(parentOrigin !== undefined ? { parentOrigin } : {}),
255
+ },
256
+ ws,
257
+ )
258
+ return
259
+ }
260
+ if (msg.type === 'command_stdin') {
261
+ if (!commandRunner) return
262
+ commandRunner.feedStdin(msg.callId, msg.chunk)
263
+ return
264
+ }
265
+ if (msg.type === 'command_stdin_end') {
266
+ if (!commandRunner) return
267
+ commandRunner.endStdin(msg.callId)
268
+ return
269
+ }
270
+ if (msg.type === 'command_abort') {
271
+ if (!commandRunner) return
272
+ commandRunner.abort(msg.callId, msg.reason)
273
+ return
274
+ }
275
+ }
120
276
 
121
277
  function start(): BunServer<WsData> {
122
278
  const bunServer = Bun.serve<WsData>({
@@ -129,6 +285,30 @@ export function createServer({
129
285
  if (server.upgrade(req, { data })) return
130
286
  return new Response('upgrade failed', { status: 400 })
131
287
  }
288
+ if (url.pathname === '/tunnel-logs') {
289
+ if (isWebSocketUpgrade(req) && tuiToken !== undefined && url.searchParams.get('token') !== tuiToken) {
290
+ return new Response('unauthorized', { status: 401 })
291
+ }
292
+ const data: TunnelLogsWsData = { kind: 'tunnel-logs', unsubscribe: null }
293
+ if (server.upgrade(req, { data })) return
294
+ return new Response('upgrade failed', { status: 400 })
295
+ }
296
+ // `/commands` is the dedicated host-CLI proxy path. It skips TUI
297
+ // session creation (which costs an AgentSession spawn per command
298
+ // invocation) but uses the same tuiToken because both surfaces
299
+ // are owner-equivalent.
300
+ // `/commands` is the dedicated host-CLI proxy path. It skips TUI
301
+ // session creation (which costs an AgentSession spawn per command
302
+ // invocation) but uses the same tuiToken because both surfaces
303
+ // are owner-equivalent.
304
+ if (url.pathname === '/commands') {
305
+ if (isWebSocketUpgrade(req) && tuiToken !== undefined && url.searchParams.get('token') !== tuiToken) {
306
+ return new Response('unauthorized', { status: 401 })
307
+ }
308
+ const data: CommandWsData = { kind: 'command' }
309
+ if (server.upgrade(req, { data })) return
310
+ return new Response('upgrade failed', { status: 400 })
311
+ }
132
312
  if (isWebSocketUpgrade(req) && tuiToken !== undefined && url.searchParams.get('token') !== tuiToken) {
133
313
  return new Response('unauthorized', { status: 401 })
134
314
  }
@@ -143,6 +323,15 @@ export function createServer({
143
323
  containerBroker?.open(rawWs as ServerWebSocket<BrokerWsData>)
144
324
  return
145
325
  }
326
+ if (rawWs.data.kind === 'command') {
327
+ // Command-class connections are pure transport for plugin-command
328
+ // dispatch. No AgentSession is created, no plugin lifecycle hooks
329
+ // fire, no `connected` frame is sent. The host CLI proxy sends
330
+ // exec_command immediately on open and tears the socket down
331
+ // when command_exit arrives.
332
+ return
333
+ }
334
+ if (rawWs.data.kind === 'tunnel-logs') return
146
335
  const ws = rawWs as Ws
147
336
  try {
148
337
  const sessionManager = sessionFactory?.createPersisted()
@@ -213,7 +402,11 @@ export function createServer({
213
402
  })
214
403
  }
215
404
 
216
- send(ws, { type: 'connected', sessionId: sessionFileId })
405
+ send(ws, {
406
+ type: 'connected',
407
+ sessionId: sessionFileId,
408
+ ...(runtimeVersion !== undefined ? { serverVersion: runtimeVersion } : {}),
409
+ })
217
410
  console.log(`session ${sessionFileId}: open`)
218
411
  } catch (err) {
219
412
  const message = err instanceof Error ? err.message : String(err)
@@ -227,6 +420,22 @@ export function createServer({
227
420
  await containerBroker?.message(rawWs as ServerWebSocket<BrokerWsData>, raw as string | Buffer)
228
421
  return
229
422
  }
423
+ // Command-class connections accept ONLY the four exec_command-
424
+ // family frames. Anything else (prompt, reload, claim_*, etc.) is
425
+ // silently dropped because there's no AgentSession or claim
426
+ // controller attached to this kind of connection. Routing through
427
+ // safeWsSend + the callIdToWs map keeps the outbound path
428
+ // transport-agnostic.
429
+ if (rawWs.data.kind === 'command') {
430
+ const cws = rawWs as CommandWs
431
+ const msg = JSON.parse(String(raw)) as ClientMessage
432
+ handleCommandFrame(cws, msg)
433
+ return
434
+ }
435
+ if (rawWs.data.kind === 'tunnel-logs') {
436
+ handleTunnelLogsMessage(rawWs as TunnelLogsWs, raw, tunnelManager)
437
+ return
438
+ }
230
439
  const ws = rawWs as Ws
231
440
  const msg = JSON.parse(String(raw)) as ClientMessage
232
441
  const state = sessionStates.get(ws)
@@ -313,6 +522,21 @@ export function createServer({
313
522
  return
314
523
  }
315
524
 
525
+ if (msg.type === 'cron_list') {
526
+ await handleCronList(ws, msg.requestId, pluginRuntime, agentDir)
527
+ return
528
+ }
529
+
530
+ if (msg.type === 'tunnel_list_request') {
531
+ handleTunnelList(ws, msg.requestId, tunnelManager)
532
+ return
533
+ }
534
+
535
+ if (msg.type === 'tunnel_status_request') {
536
+ handleTunnelStatus(ws, msg.requestId, msg.name, tunnelManager)
537
+ return
538
+ }
539
+
316
540
  if (msg.type === 'abort') {
317
541
  if (!state) return
318
542
  await state.session.abort()
@@ -370,12 +594,31 @@ export function createServer({
370
594
  }
371
595
  return
372
596
  }
597
+
598
+ handleCommandFrame(ws, msg)
373
599
  },
374
600
  async close(rawWs) {
375
601
  if (rawWs.data.kind === 'portbroker') {
376
602
  containerBroker?.close(rawWs as ServerWebSocket<BrokerWsData>)
377
603
  return
378
604
  }
605
+ if (rawWs.data.kind === 'command') {
606
+ // Command-class connections have no AgentSession, no claim
607
+ // state, and no broadcast subscriptions to tear down. Just
608
+ // abort in-flight commands tied to this ws and purge the
609
+ // callId→ws mapping so late frames don't try to route here.
610
+ const cws = rawWs as CommandWs
611
+ commandRunner?.abortForOwner(cws)
612
+ for (const [callId, owner] of callIdToWs) {
613
+ if (owner === cws) callIdToWs.delete(callId)
614
+ }
615
+ return
616
+ }
617
+ if (rawWs.data.kind === 'tunnel-logs') {
618
+ rawWs.data.unsubscribe?.()
619
+ rawWs.data.unsubscribe = null
620
+ return
621
+ }
379
622
  const ws = rawWs as Ws
380
623
  const state = sessionStates.get(ws)
381
624
  state?.unsubBroadcast?.()
@@ -384,6 +627,10 @@ export function createServer({
384
627
  if (state?.activeClaimCode !== null && state?.activeClaimCode !== undefined && claimController) {
385
628
  claimController.cancelClaim(state.activeClaimCode)
386
629
  }
630
+ commandRunner?.abortForOwner(ws)
631
+ for (const [callId, owner] of callIdToWs) {
632
+ if (owner === ws) callIdToWs.delete(callId)
633
+ }
387
634
  try {
388
635
  if (state && state.runtimeSnapshot !== null) {
389
636
  await state.runtimeSnapshot.hooks.runSessionEnd({ sessionId: state.sessionFileId, origin: state.origin })
@@ -616,6 +863,142 @@ async function handleDoctorFix(
616
863
  send(ws, { type: 'doctor_fix_result', requestId, result })
617
864
  }
618
865
 
866
+ async function handleCronList(
867
+ ws: Ws,
868
+ requestId: string,
869
+ pluginRuntime: PluginRuntime | undefined,
870
+ agentDir: string | undefined,
871
+ ): Promise<void> {
872
+ if (agentDir === undefined) {
873
+ send(ws, { type: 'cron_list_result', requestId, result: { ok: false, reason: 'agentDir not configured' } })
874
+ return
875
+ }
876
+ try {
877
+ // Snapshot the runtime once so subagent validation and the plugin
878
+ // cron-job list see the same generation, the way TUI sessions do.
879
+ // Without one snapshot, a reload landing mid-request can show user
880
+ // jobs validated against an old subagent registry alongside plugin
881
+ // jobs from a newer registry.
882
+ const snapshot = pluginRuntime?.get()
883
+ const loadResult = await loadCron(agentDir, {
884
+ // Read-only path: do not rewrite cron.json or commit the
885
+ // migration just because the user (or the agent) asked to see
886
+ // the schedule. Boot/reload still own the persistent migration.
887
+ persistMigrations: false,
888
+ ...(snapshot !== undefined ? { subagents: snapshot.subagents } : {}),
889
+ })
890
+ if (!loadResult.ok) {
891
+ send(ws, { type: 'cron_list_result', requestId, result: { ok: false, reason: loadResult.reason } })
892
+ return
893
+ }
894
+ const userJobs = loadResult.file?.jobs ?? []
895
+ const pluginJobs = snapshot?.registry.cronJobs ?? []
896
+ const nowMs = Date.now()
897
+ const entries = aggregateCronList({ userJobs, pluginJobs, now: nowMs })
898
+ send(ws, {
899
+ type: 'cron_list_result',
900
+ requestId,
901
+ result: { ok: true, jobs: entries.map(toPayload), nowMs },
902
+ })
903
+ } catch (err) {
904
+ const reason = err instanceof Error ? err.message : String(err)
905
+ send(ws, { type: 'cron_list_result', requestId, result: { ok: false, reason } })
906
+ }
907
+ }
908
+
909
+ function handleTunnelList(ws: Ws, requestId: string, tunnelManager: TunnelManager | undefined): void {
910
+ if (tunnelManager === undefined) {
911
+ send(ws, { type: 'tunnel_list_response', requestId, ok: false, error: 'tunnel manager not configured' })
912
+ return
913
+ }
914
+ send(ws, { type: 'tunnel_list_response', requestId, ok: true, tunnels: toTunnelSnapshots(tunnelManager.snapshot()) })
915
+ }
916
+
917
+ function handleTunnelStatus(ws: Ws, requestId: string, name: string, tunnelManager: TunnelManager | undefined): void {
918
+ if (tunnelManager === undefined) {
919
+ send(ws, { type: 'tunnel_status_response', requestId, ok: false, error: 'tunnel manager not configured' })
920
+ return
921
+ }
922
+ const tunnel = toTunnelSnapshots(tunnelManager.snapshot()).find((entry) => entry.name === name)
923
+ if (tunnel === undefined) {
924
+ send(ws, { type: 'tunnel_status_response', requestId, ok: false, error: `unknown tunnel: ${name}` })
925
+ return
926
+ }
927
+ send(ws, { type: 'tunnel_status_response', requestId, ok: true, tunnel })
928
+ }
929
+
930
+ function handleTunnelLogsMessage(
931
+ ws: TunnelLogsWs,
932
+ raw: string | Buffer,
933
+ tunnelManager: TunnelManager | undefined,
934
+ ): void {
935
+ let msg: TunnelLogsClientMessage
936
+ try {
937
+ msg = JSON.parse(String(raw)) as TunnelLogsClientMessage
938
+ } catch {
939
+ sendTunnelLog(ws, { type: 'error', message: 'invalid JSON' })
940
+ sendTunnelLog(ws, { type: 'end' })
941
+ ws.close()
942
+ return
943
+ }
944
+ if (msg.type !== 'subscribe' || typeof msg.name !== 'string' || typeof msg.follow !== 'boolean') {
945
+ sendTunnelLog(ws, { type: 'error', message: 'invalid tunnel log subscription' })
946
+ sendTunnelLog(ws, { type: 'end' })
947
+ ws.close()
948
+ return
949
+ }
950
+ if (tunnelManager === undefined || !tunnelManager.snapshot().some((entry) => entry.name === msg.name)) {
951
+ sendTunnelLog(ws, { type: 'error', message: `unknown tunnel: ${msg.name}` })
952
+ sendTunnelLog(ws, { type: 'end' })
953
+ ws.close()
954
+ return
955
+ }
956
+
957
+ sendTunnelLog(ws, { type: 'snapshot', lines: tunnelManager.tail(msg.name) })
958
+ if (!msg.follow) {
959
+ sendTunnelLog(ws, { type: 'end' })
960
+ ws.close()
961
+ return
962
+ }
963
+ ws.data.unsubscribe?.()
964
+ ws.data.unsubscribe = tunnelManager.subscribeToLogs(msg.name, (line) => {
965
+ sendTunnelLog(ws, { type: 'line', line })
966
+ })
967
+ }
968
+
969
+ function toTunnelSnapshots(states: ReturnType<TunnelManager['snapshot']>): TunnelSnapshot[] {
970
+ return states.map((state) => ({
971
+ name: state.name,
972
+ provider: state.provider,
973
+ for: state.for,
974
+ url: state.url,
975
+ status: state.status,
976
+ lastUrlAt: state.lastUrlAt,
977
+ detail: state.detail,
978
+ }))
979
+ }
980
+
981
+ function toPayload(entry: CronListEntry): CronListEntryPayload {
982
+ const source: CronListSourcePayload =
983
+ entry.source.kind === 'plugin'
984
+ ? { kind: 'plugin', pluginName: entry.source.pluginName, localId: entry.source.localId }
985
+ : { kind: 'user' }
986
+ return {
987
+ id: entry.id,
988
+ source,
989
+ kind: entry.kind,
990
+ schedule: entry.schedule,
991
+ enabled: entry.enabled,
992
+ nextFireMs: entry.nextFireMs,
993
+ ...(entry.timezone !== undefined ? { timezone: entry.timezone } : {}),
994
+ ...(entry.scheduledByRole !== undefined ? { scheduledByRole: entry.scheduledByRole } : {}),
995
+ ...(entry.scheduleError !== undefined ? { scheduleError: entry.scheduleError } : {}),
996
+ ...(entry.prompt !== undefined ? { prompt: entry.prompt } : {}),
997
+ ...(entry.subagent !== undefined ? { subagent: entry.subagent } : {}),
998
+ ...(entry.command !== undefined ? { command: entry.command } : {}),
999
+ }
1000
+ }
1001
+
619
1002
  async function handleReload(
620
1003
  ws: Ws,
621
1004
  reloadAll: ReloadAllFn | undefined,
@@ -4,6 +4,10 @@ export {
4
4
  type ClaimRoleChoice,
5
5
  type ClaimStartedPayload,
6
6
  type ClientMessage,
7
+ type CronListEntryPayload,
8
+ type CronListRequestId,
9
+ type CronListResultPayload,
10
+ type CronListSourcePayload,
7
11
  type DoctorCheckPayload,
8
12
  type DoctorFixPayload,
9
13
  type DoctorRequestId,
@@ -11,6 +15,10 @@ export {
11
15
  type QueueStateItem,
12
16
  type ReloadResultPayload,
13
17
  type ServerMessage,
18
+ type TunnelLogsClientMessage,
19
+ type TunnelLogsServerMessage,
20
+ type TunnelRequestId,
21
+ type TunnelSnapshot,
14
22
  } from './protocol'
15
23
 
16
24
  export { formatLocalDate, formatLocalDateTime } from './local-time'
@@ -24,6 +24,26 @@ export type DoctorFixPayload =
24
24
 
25
25
  export type ClaimRoleChoice = 'owner' | 'member' | 'trusted' | (string & {})
26
26
 
27
+ export type TunnelRequestId = string
28
+
29
+ export type TunnelSnapshot = {
30
+ name: string
31
+ provider: 'external' | 'cloudflare-quick'
32
+ for: { kind: 'channel'; name: string } | { kind: 'manual' }
33
+ url: string | null
34
+ status: 'stopped' | 'starting' | 'healthy' | 'unhealthy' | 'permanently-failed'
35
+ lastUrlAt: number | null
36
+ detail: string
37
+ }
38
+
39
+ export type TunnelLogsClientMessage = { type: 'subscribe'; name: string; follow: boolean }
40
+
41
+ export type TunnelLogsServerMessage =
42
+ | { type: 'snapshot'; lines: string[] }
43
+ | { type: 'line'; line: string }
44
+ | { type: 'error'; message: string }
45
+ | { type: 'end' }
46
+
27
47
  export type ClientMessage =
28
48
  | { type: 'prompt'; text: string; delivery?: PromptDelivery }
29
49
  | { type: 'reload'; scope?: string }
@@ -31,8 +51,50 @@ export type ClientMessage =
31
51
  | { type: 'queue_cancel'; messageId: string }
32
52
  | { type: 'doctor'; requestId: DoctorRequestId }
33
53
  | { type: 'doctor_fix'; requestId: DoctorRequestId; checkId: string }
54
+ | { type: 'cron_list'; requestId: CronListRequestId }
55
+ | { type: 'tunnel_list_request'; requestId: TunnelRequestId }
56
+ | { type: 'tunnel_status_request'; requestId: TunnelRequestId; name: string }
34
57
  | { type: 'claim_start'; code: string; role: ClaimRoleChoice; channel?: string; ttlMs: number }
35
58
  | { type: 'claim_cancel' }
59
+ | {
60
+ type: 'exec_command'
61
+ callId: string
62
+ name: string
63
+ args: unknown
64
+ isolated?: boolean
65
+ // Parent origin to stamp as spawnedByOrigin on the command's session.
66
+ // When unset, the runner stamps a synthetic TUI origin (host CLI
67
+ // operator). When set, the runner trusts the JSON verbatim as a
68
+ // SessionOrigin (e.g. cron-shaped, carrying scheduledByRole).
69
+ // Permission resolution chases through to the parent origin's role.
70
+ parentOriginJson?: string
71
+ }
72
+ | { type: 'command_stdin'; callId: string; chunk: string }
73
+ | { type: 'command_stdin_end'; callId: string }
74
+ | { type: 'command_abort'; callId: string; reason: string }
75
+
76
+ export type CronListRequestId = string
77
+
78
+ export type CronListSourcePayload = { kind: 'user' } | { kind: 'plugin'; pluginName: string; localId: string }
79
+
80
+ export type CronListEntryPayload = {
81
+ id: string
82
+ source: CronListSourcePayload
83
+ kind: 'prompt' | 'exec' | 'handler'
84
+ schedule: string
85
+ timezone?: string
86
+ enabled: boolean
87
+ scheduledByRole?: string
88
+ nextFireMs: number | null
89
+ scheduleError?: string
90
+ prompt?: string
91
+ subagent?: string
92
+ command?: readonly string[]
93
+ }
94
+
95
+ export type CronListResultPayload =
96
+ | { ok: true; jobs: CronListEntryPayload[]; nowMs: number }
97
+ | { ok: false; reason: string }
36
98
 
37
99
  export type QueueStateItem = { id: string; text: string; ts: number }
38
100
 
@@ -57,7 +119,11 @@ export type ClaimErrorPayload = {
57
119
  }
58
120
 
59
121
  export type ServerMessage =
60
- | { type: 'connected'; sessionId: string }
122
+ // serverVersion is optional so an old CLI talking to a new server still
123
+ // parses cleanly. The server impl always emits it; consumers that care
124
+ // about host/agent skew (the TUI command in particular) read it to warn
125
+ // the user when their CLI is on a different version than the container.
126
+ | { type: 'connected'; sessionId: string; serverVersion?: string }
61
127
  | { type: 'text_delta'; delta: string }
62
128
  | { type: 'tool_start'; toolCallId: string; name: string; args: unknown }
63
129
  | { type: 'tool_end'; toolCallId: string; name: string; error: boolean; result: unknown; durationMs: number }
@@ -69,6 +135,19 @@ export type ServerMessage =
69
135
  | { type: 'prompt_started'; messageId: string; text: string }
70
136
  | { type: 'doctor_result'; requestId: DoctorRequestId; checks: DoctorCheckPayload[] }
71
137
  | { type: 'doctor_fix_result'; requestId: DoctorRequestId; result: DoctorFixPayload }
138
+ | { type: 'cron_list_result'; requestId: CronListRequestId; result: CronListResultPayload }
139
+ | ({ type: 'tunnel_list_response'; requestId: TunnelRequestId } & (
140
+ | { ok: true; tunnels: TunnelSnapshot[] }
141
+ | { ok: false; error: string }
142
+ ))
143
+ | ({ type: 'tunnel_status_response'; requestId: TunnelRequestId } & (
144
+ | { ok: true; tunnel: TunnelSnapshot }
145
+ | { ok: false; error: string }
146
+ ))
72
147
  | { type: 'claim_started'; payload: ClaimStartedPayload }
73
148
  | { type: 'claim_completed'; payload: ClaimCompletedPayload }
74
149
  | { type: 'claim_error'; payload: ClaimErrorPayload }
150
+ | { type: 'command_stdout'; callId: string; chunk: string }
151
+ | { type: 'command_stderr'; callId: string; chunk: string }
152
+ | { type: 'command_exit'; callId: string; code: number }
153
+ | { type: 'command_error'; callId: string; message: string }
@@ -0,0 +1,24 @@
1
+ ---
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`. 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
+ ---
5
+
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.
7
+
8
+ - Do not send attachments on GitHub chats; the adapter rejects them.
9
+ - There is no typing indicator.
10
+ - For PR review threads, keep `thread` set to reply in-place. Omit `thread` for a top-level PR/issue comment.
11
+
12
+ ## Opening new issues and PRs
13
+
14
+ The `gh` CLI is pre-authenticated via `GH_TOKEN` (injected by the adapter at startup). Use it to open new issues or PRs:
15
+
16
+ ```sh
17
+ # Open a new issue
18
+ gh issue create --repo owner/repo --title "Bug: ..." --body "..."
19
+
20
+ # Open a new PR
21
+ gh pr create --repo owner/repo --title "Fix: ..." --head my-branch --base main --body "..."
22
+ ```
23
+
24
+ For App auth, `GH_TOKEN` is an installation access token that refreshes automatically — it stays current as long as the adapter is running.