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.
- package/README.md +20 -15
- package/auth.schema.json +113 -0
- package/package.json +1 -1
- package/secrets.schema.json +113 -0
- package/src/agent/session-meta.ts +1 -1
- package/src/agent/session-origin.ts +3 -2
- 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/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/config/config.ts +75 -0
- package/src/container/start.ts +30 -3
- package/src/cron/bridge.ts +136 -0
- package/src/cron/consumer.ts +45 -5
- 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 +110 -3
- 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 +388 -5
- 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/typeclaw.schema.json +254 -1
package/src/server/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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, {
|
|
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,
|
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.
|