typeclaw 0.3.0 → 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.
- package/README.md +20 -15
- package/auth.schema.json +113 -0
- package/package.json +2 -1
- package/scripts/dump-system-prompt.ts +401 -0
- package/secrets.schema.json +113 -0
- package/src/agent/index.ts +149 -30
- package/src/agent/provider-error.ts +44 -0
- package/src/agent/session-meta.ts +43 -0
- package/src/agent/session-origin.ts +3 -2
- package/src/agent/subagents.ts +8 -0
- package/src/agent/system-prompt.ts +70 -35
- package/src/bundled-plugins/security/index.ts +3 -2
- package/src/channels/adapters/github/auth-app.ts +120 -0
- package/src/channels/adapters/github/auth-pat.ts +50 -0
- package/src/channels/adapters/github/auth.ts +33 -0
- package/src/channels/adapters/github/channel-resolver.ts +30 -0
- package/src/channels/adapters/github/dedup.ts +26 -0
- package/src/channels/adapters/github/event-allowlist.ts +8 -0
- package/src/channels/adapters/github/fetch-attachment.ts +5 -0
- package/src/channels/adapters/github/history.ts +63 -0
- package/src/channels/adapters/github/inbound.ts +286 -0
- package/src/channels/adapters/github/index.ts +286 -0
- package/src/channels/adapters/github/managed-path.ts +54 -0
- package/src/channels/adapters/github/membership.ts +35 -0
- package/src/channels/adapters/github/outbound.ts +145 -0
- package/src/channels/adapters/github/webhook-register.ts +349 -0
- package/src/channels/manager.ts +94 -9
- package/src/channels/router.ts +28 -2
- package/src/channels/schema.ts +31 -1
- package/src/channels/tunnel-bridge.ts +51 -0
- package/src/cli/builtins.ts +28 -0
- package/src/cli/channel.ts +511 -25
- package/src/cli/container-command-client.ts +244 -0
- package/src/cli/cron.ts +173 -0
- package/src/cli/host-command-runner.ts +150 -0
- package/src/cli/index.ts +42 -1
- package/src/cli/init.ts +256 -27
- package/src/cli/model.ts +4 -2
- package/src/cli/plugin-command-help.ts +49 -0
- package/src/cli/plugin-commands-dispatch.ts +112 -0
- package/src/cli/plugin-commands.ts +118 -0
- package/src/cli/tui.ts +10 -2
- package/src/cli/tunnel.ts +533 -0
- package/src/cli/ui.ts +8 -3
- package/src/cli/usage.ts +30 -2
- package/src/config/config.ts +90 -4
- package/src/config/reloadable.ts +22 -4
- package/src/container/start.ts +30 -3
- package/src/cron/bridge.ts +136 -0
- package/src/cron/consumer.ts +62 -6
- package/src/cron/index.ts +19 -2
- package/src/cron/list.ts +105 -0
- package/src/cron/scheduler.ts +12 -3
- package/src/cron/schema.ts +11 -3
- package/src/doctor/checks.ts +0 -50
- package/src/init/dockerfile.ts +59 -13
- package/src/init/ensure-deps.ts +15 -4
- package/src/init/github-webhook-install.ts +109 -0
- package/src/init/index.ts +505 -9
- package/src/init/run-bun-install.ts +17 -3
- package/src/init/run-owner-claim.ts +11 -2
- package/src/permissions/builtins.ts +6 -1
- package/src/permissions/match-rule.ts +24 -2
- package/src/permissions/resolve.ts +1 -0
- package/src/plugin/define.ts +42 -1
- package/src/plugin/index.ts +18 -3
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/registry.ts +85 -3
- package/src/plugin/types.ts +138 -1
- package/src/plugin/zod-introspect.ts +100 -0
- package/src/role-claim/match-rule.ts +2 -1
- package/src/run/index.ts +119 -4
- package/src/secrets/index.ts +1 -1
- package/src/secrets/schema.ts +21 -0
- package/src/server/command-runner.ts +476 -0
- package/src/server/index.ts +393 -15
- package/src/shared/index.ts +8 -0
- package/src/shared/protocol.ts +80 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
- package/src/skills/typeclaw-config/SKILL.md +27 -26
- package/src/skills/typeclaw-cron/SKILL.md +234 -3
- package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
- package/src/skills/typeclaw-permissions/SKILL.md +5 -4
- package/src/skills/typeclaw-plugins/SKILL.md +251 -5
- package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
- package/src/test-helpers/wait-for.ts +50 -0
- package/src/tui/index.ts +35 -4
- package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
- package/src/tunnels/events.ts +14 -0
- package/src/tunnels/index.ts +12 -0
- package/src/tunnels/log-ring.ts +54 -0
- package/src/tunnels/manager.ts +139 -0
- package/src/tunnels/providers/cloudflare-quick.ts +189 -0
- package/src/tunnels/providers/external.ts +53 -0
- package/src/tunnels/quick-url-parser.ts +5 -0
- package/src/tunnels/types.ts +43 -0
- package/src/usage/aggregate.ts +30 -1
- package/src/usage/index.ts +3 -2
- package/src/usage/report.ts +103 -3
- package/src/usage/scan.ts +59 -4
- package/typeclaw.schema.json +254 -1
package/src/server/index.ts
CHANGED
|
@@ -7,16 +7,31 @@ import {
|
|
|
7
7
|
type CreateSessionResult,
|
|
8
8
|
} from '@/agent'
|
|
9
9
|
import { runPluginDoctorChecks, runPluginDoctorFix } from '@/agent/doctor'
|
|
10
|
+
import { detectProviderError } from '@/agent/provider-error'
|
|
10
11
|
import type { SessionOrigin } from '@/agent/session-origin'
|
|
11
12
|
import type { ChannelRouter } from '@/channels/router'
|
|
13
|
+
import { aggregateCronList, type CronListEntry, loadCron } from '@/cron'
|
|
12
14
|
import type { HookBus } from '@/plugin'
|
|
13
15
|
import type { BrokerWsData, ContainerBroker } from '@/portbroker'
|
|
14
16
|
import type { ReloadAllResult, ReloadRegistry } from '@/reload'
|
|
15
17
|
import type { ClaimController, ClaimResultEvent } from '@/role-claim'
|
|
16
18
|
import type { PluginRuntime, PluginRuntimeState } from '@/run/plugin-runtime'
|
|
19
|
+
import type { CommandOutbound, CommandRunner } from '@/server/command-runner'
|
|
17
20
|
import type { SessionFactory } from '@/sessions'
|
|
18
|
-
import type {
|
|
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'
|
|
19
33
|
import type { Stream, StreamMessage, StreamMessageId, Unsubscribe } from '@/stream'
|
|
34
|
+
import type { TunnelManager } from '@/tunnels'
|
|
20
35
|
|
|
21
36
|
export type ReloadAllFn = () => Promise<ReloadAllResult>
|
|
22
37
|
export type CreateSessionFn = (options?: CreateSessionOptions) => Promise<AgentSession | CreateSessionResult>
|
|
@@ -45,6 +60,7 @@ export type ServerOptions = {
|
|
|
45
60
|
// sessions. Omit to keep TUI-only behavior (used by tests + non-container
|
|
46
61
|
// dev runs).
|
|
47
62
|
containerBroker?: ContainerBroker
|
|
63
|
+
tunnelManager?: TunnelManager
|
|
48
64
|
// Optional logger for server-side events. Defaults to `consoleLogger`
|
|
49
65
|
// which writes to stdout/stderr so `typeclaw logs` surfaces every event.
|
|
50
66
|
// Tests inject a fake logger to assert on captured output.
|
|
@@ -55,6 +71,14 @@ export type ServerOptions = {
|
|
|
55
71
|
// `claim_started` / `claim_completed` / `claim_error` back over the
|
|
56
72
|
// same connection. Omitted in tests that don't exercise the flow.
|
|
57
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
|
|
58
82
|
}
|
|
59
83
|
|
|
60
84
|
const consoleLogger: ServerLogger = {
|
|
@@ -66,8 +90,17 @@ const consoleLogger: ServerLogger = {
|
|
|
66
90
|
export type Server = ReturnType<typeof createServer>
|
|
67
91
|
|
|
68
92
|
type TuiWsData = { kind: 'tui'; sessionId: string }
|
|
69
|
-
|
|
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
|
|
70
100
|
type Ws = ServerWebSocket<TuiWsData>
|
|
101
|
+
type CommandWs = ServerWebSocket<CommandWsData>
|
|
102
|
+
type TunnelLogsWs = ServerWebSocket<TunnelLogsWsData>
|
|
103
|
+
type AnyOwnerWs = Ws | CommandWs
|
|
71
104
|
|
|
72
105
|
type QueuedPrompt = {
|
|
73
106
|
streamMessageId: StreamMessageId
|
|
@@ -94,8 +127,35 @@ type SessionState = {
|
|
|
94
127
|
dispose: () => Promise<void>
|
|
95
128
|
}
|
|
96
129
|
|
|
97
|
-
|
|
98
|
-
|
|
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)
|
|
99
159
|
}
|
|
100
160
|
|
|
101
161
|
export function createServer({
|
|
@@ -112,10 +172,107 @@ export function createServer({
|
|
|
112
172
|
runtimeVersion,
|
|
113
173
|
tuiToken,
|
|
114
174
|
containerBroker,
|
|
175
|
+
tunnelManager,
|
|
115
176
|
logger = consoleLogger,
|
|
116
177
|
claimController,
|
|
178
|
+
commandRunnerFactory,
|
|
117
179
|
}: ServerOptions) {
|
|
118
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
|
+
}
|
|
119
276
|
|
|
120
277
|
function start(): BunServer<WsData> {
|
|
121
278
|
const bunServer = Bun.serve<WsData>({
|
|
@@ -128,6 +285,30 @@ export function createServer({
|
|
|
128
285
|
if (server.upgrade(req, { data })) return
|
|
129
286
|
return new Response('upgrade failed', { status: 400 })
|
|
130
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
|
+
}
|
|
131
312
|
if (isWebSocketUpgrade(req) && tuiToken !== undefined && url.searchParams.get('token') !== tuiToken) {
|
|
132
313
|
return new Response('unauthorized', { status: 401 })
|
|
133
314
|
}
|
|
@@ -142,6 +323,15 @@ export function createServer({
|
|
|
142
323
|
containerBroker?.open(rawWs as ServerWebSocket<BrokerWsData>)
|
|
143
324
|
return
|
|
144
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
|
|
145
335
|
const ws = rawWs as Ws
|
|
146
336
|
try {
|
|
147
337
|
const sessionManager = sessionFactory?.createPersisted()
|
|
@@ -212,7 +402,11 @@ export function createServer({
|
|
|
212
402
|
})
|
|
213
403
|
}
|
|
214
404
|
|
|
215
|
-
send(ws, {
|
|
405
|
+
send(ws, {
|
|
406
|
+
type: 'connected',
|
|
407
|
+
sessionId: sessionFileId,
|
|
408
|
+
...(runtimeVersion !== undefined ? { serverVersion: runtimeVersion } : {}),
|
|
409
|
+
})
|
|
216
410
|
console.log(`session ${sessionFileId}: open`)
|
|
217
411
|
} catch (err) {
|
|
218
412
|
const message = err instanceof Error ? err.message : String(err)
|
|
@@ -226,6 +420,22 @@ export function createServer({
|
|
|
226
420
|
await containerBroker?.message(rawWs as ServerWebSocket<BrokerWsData>, raw as string | Buffer)
|
|
227
421
|
return
|
|
228
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
|
+
}
|
|
229
439
|
const ws = rawWs as Ws
|
|
230
440
|
const msg = JSON.parse(String(raw)) as ClientMessage
|
|
231
441
|
const state = sessionStates.get(ws)
|
|
@@ -312,6 +522,21 @@ export function createServer({
|
|
|
312
522
|
return
|
|
313
523
|
}
|
|
314
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
|
+
|
|
315
540
|
if (msg.type === 'abort') {
|
|
316
541
|
if (!state) return
|
|
317
542
|
await state.session.abort()
|
|
@@ -369,12 +594,31 @@ export function createServer({
|
|
|
369
594
|
}
|
|
370
595
|
return
|
|
371
596
|
}
|
|
597
|
+
|
|
598
|
+
handleCommandFrame(ws, msg)
|
|
372
599
|
},
|
|
373
600
|
async close(rawWs) {
|
|
374
601
|
if (rawWs.data.kind === 'portbroker') {
|
|
375
602
|
containerBroker?.close(rawWs as ServerWebSocket<BrokerWsData>)
|
|
376
603
|
return
|
|
377
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
|
+
}
|
|
378
622
|
const ws = rawWs as Ws
|
|
379
623
|
const state = sessionStates.get(ws)
|
|
380
624
|
state?.unsubBroadcast?.()
|
|
@@ -383,6 +627,10 @@ export function createServer({
|
|
|
383
627
|
if (state?.activeClaimCode !== null && state?.activeClaimCode !== undefined && claimController) {
|
|
384
628
|
claimController.cancelClaim(state.activeClaimCode)
|
|
385
629
|
}
|
|
630
|
+
commandRunner?.abortForOwner(ws)
|
|
631
|
+
for (const [callId, owner] of callIdToWs) {
|
|
632
|
+
if (owner === ws) callIdToWs.delete(callId)
|
|
633
|
+
}
|
|
386
634
|
try {
|
|
387
635
|
if (state && state.runtimeSnapshot !== null) {
|
|
388
636
|
await state.runtimeSnapshot.hooks.runSessionEnd({ sessionId: state.sessionFileId, origin: state.origin })
|
|
@@ -458,16 +706,10 @@ function forwardSessionEvents(ws: Ws, session: AgentSession, logger: ServerLogge
|
|
|
458
706
|
}
|
|
459
707
|
|
|
460
708
|
function forwardAssistantError(ws: Ws, message: unknown, logger: ServerLogger, sessionFileId: string): void {
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
// 'aborted' is fired when the user hits Escape — don't surface it as an
|
|
466
|
-
// error message because the TUI already shows abort feedback elsewhere.
|
|
467
|
-
if (m.stopReason === 'aborted') return
|
|
468
|
-
const text = typeof m.errorMessage === 'string' && m.errorMessage.length > 0 ? m.errorMessage : 'LLM call failed'
|
|
469
|
-
logger.error(`[server] ${sessionFileId}: LLM call failed: ${text}`)
|
|
470
|
-
send(ws, { type: 'error', message: text })
|
|
709
|
+
const detected = detectProviderError(message)
|
|
710
|
+
if (detected === null) return
|
|
711
|
+
logger.error(`[server] ${sessionFileId}: LLM call failed: ${detected.message}`)
|
|
712
|
+
send(ws, { type: 'error', message: detected.message })
|
|
471
713
|
}
|
|
472
714
|
|
|
473
715
|
function enqueuePrompt(
|
|
@@ -621,6 +863,142 @@ async function handleDoctorFix(
|
|
|
621
863
|
send(ws, { type: 'doctor_fix_result', requestId, result })
|
|
622
864
|
}
|
|
623
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
|
+
|
|
624
1002
|
async function handleReload(
|
|
625
1003
|
ws: Ws,
|
|
626
1004
|
reloadAll: ReloadAllFn | undefined,
|
package/src/shared/index.ts
CHANGED
|
@@ -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'
|
package/src/shared/protocol.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|