typeclaw 0.9.2 → 0.11.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/package.json +2 -2
- package/src/agent/index.ts +46 -11
- package/src/agent/restart-handoff/index.ts +91 -0
- package/src/agent/restart-handoff/paths.ts +11 -0
- package/src/agent/session-origin.ts +30 -10
- package/src/agent/subagent-completion-reminder.ts +4 -2
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tools/restart.ts +42 -1
- package/src/agent/tools/skip-response.ts +157 -0
- package/src/bundled-plugins/memory/README.md +18 -2
- package/src/bundled-plugins/memory/index.ts +108 -6
- package/src/bundled-plugins/memory/memory-logger.ts +33 -24
- package/src/bundled-plugins/security/index.ts +19 -17
- package/src/bundled-plugins/security/permissions.ts +9 -8
- package/src/bundled-plugins/security/policies/cron-promotion.ts +26 -9
- package/src/bundled-plugins/security/policies/git-exfil.ts +23 -15
- package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
- package/src/bundled-plugins/security/policies/role-promotion.ts +25 -18
- package/src/channels/adapters/github/auth-app.ts +53 -9
- package/src/channels/adapters/github/auth-pat.ts +4 -1
- package/src/channels/adapters/github/auth.ts +10 -0
- package/src/channels/adapters/github/event-permissions.ts +83 -0
- package/src/channels/adapters/github/inbound.ts +126 -1
- package/src/channels/adapters/github/index.ts +60 -66
- package/src/channels/adapters/github/outbound.ts +65 -17
- package/src/channels/adapters/github/permission-guidance.ts +169 -0
- package/src/channels/adapters/github/team-membership.ts +56 -0
- package/src/channels/router.ts +313 -10
- package/src/channels/schema.ts +22 -0
- package/src/channels/types.ts +1 -1
- package/src/cli/channel.ts +135 -38
- package/src/cli/cron.ts +1 -1
- package/src/cli/init.ts +133 -86
- package/src/cli/inspect-controller.ts +66 -0
- package/src/cli/inspect.ts +99 -14
- package/src/cli/role.ts +2 -2
- package/src/cli/run.ts +24 -5
- package/src/cli/tui.ts +34 -10
- package/src/cli/tunnel.ts +453 -14
- package/src/config/config.ts +35 -7
- package/src/config/providers.ts +82 -56
- package/src/cron/bridge.ts +25 -4
- package/src/hostd/daemon.ts +44 -24
- package/src/hostd/portbroker-manager.ts +19 -3
- package/src/init/dockerfile.ts +52 -0
- package/src/init/env-file.ts +66 -0
- package/src/init/gitignore.ts +8 -0
- package/src/init/hatching.ts +32 -5
- package/src/init/index.ts +131 -39
- package/src/init/validate-api-key.ts +31 -0
- package/src/inspect/index.ts +47 -6
- package/src/inspect/loop.ts +31 -0
- package/src/inspect/replay.ts +15 -1
- package/src/permissions/builtins.ts +29 -21
- package/src/permissions/permissions.ts +32 -5
- package/src/role-claim/code.ts +9 -9
- package/src/role-claim/controller.ts +3 -2
- package/src/role-claim/match-rule.ts +14 -19
- package/src/role-claim/pending.ts +2 -2
- package/src/run/codex-fetch-observer.ts +377 -0
- package/src/run/index.ts +12 -2
- package/src/server/index.ts +59 -1
- package/src/shared/protocol.ts +1 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +45 -1
- package/src/skills/typeclaw-codex-cli/SKILL.md +1 -1
- package/src/skills/typeclaw-codex-cli/references/auth-flow.md +14 -1
- package/src/skills/typeclaw-config/SKILL.md +7 -1
- package/src/skills/typeclaw-config/references/recommended-mounts.md +233 -0
- package/src/skills/typeclaw-permissions/SKILL.md +24 -18
- package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
- package/src/tui/index.ts +17 -5
- package/src/tunnels/index.ts +1 -0
- package/src/tunnels/manager.ts +18 -0
- package/src/tunnels/providers/cloudflare-named.ts +224 -0
- package/src/tunnels/types.ts +17 -1
- package/typeclaw.schema.json +120 -7
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Pure controller for the inspect CLI's esc/ctrl-c key dispatch.
|
|
2
|
+
// Owns the AbortController lifecycle and the bare-ESC debounce timer,
|
|
3
|
+
// independent of process.stdin / TTY raw mode (which is wired in src/cli/inspect.ts).
|
|
4
|
+
// Extracted for testability: the lifecycle bug we want to pin is "armForStream's
|
|
5
|
+
// signal must remain valid across pause()/resume() cycles" — verifying that without
|
|
6
|
+
// a real TTY requires this seam.
|
|
7
|
+
|
|
8
|
+
export type EscChunkResult = { sigint: boolean }
|
|
9
|
+
|
|
10
|
+
export type EscController = {
|
|
11
|
+
armForStream: () => AbortSignal
|
|
12
|
+
onChunk: (chunk: Buffer) => EscChunkResult
|
|
13
|
+
clearPending: () => void
|
|
14
|
+
dispose: () => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createEscController({ debounceMs }: { debounceMs: number }): EscController {
|
|
18
|
+
let currentCtrl: AbortController | null = null
|
|
19
|
+
let pendingEsc: ReturnType<typeof setTimeout> | null = null
|
|
20
|
+
|
|
21
|
+
const clearPending = (): void => {
|
|
22
|
+
if (pendingEsc !== null) {
|
|
23
|
+
clearTimeout(pendingEsc)
|
|
24
|
+
pendingEsc = null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
armForStream: () => {
|
|
30
|
+
clearPending()
|
|
31
|
+
currentCtrl = new AbortController()
|
|
32
|
+
return currentCtrl.signal
|
|
33
|
+
},
|
|
34
|
+
onChunk: (chunk) => {
|
|
35
|
+
if (chunk.length === 0) return { sigint: false }
|
|
36
|
+
if (chunk[0] === 0x03) {
|
|
37
|
+
// Ctrl-C in raw mode arrives as a byte (terminal driver does not generate
|
|
38
|
+
// SIGINT). Surface to the caller so it can re-issue SIGINT via the OS;
|
|
39
|
+
// we deliberately keep the AbortController lifecycle separate from SIGINT.
|
|
40
|
+
return { sigint: true }
|
|
41
|
+
}
|
|
42
|
+
if (chunk.length === 1 && chunk[0] === 0x1b) {
|
|
43
|
+
// Bare ESC: schedule the abort. A follow-up byte within debounceMs (CSI
|
|
44
|
+
// sequences from arrow keys, mouse, paste) cancels the pending fire.
|
|
45
|
+
// Snapshot currentCtrl so a late-firing timer can't abort a controller
|
|
46
|
+
// created by a subsequent armForStream() call.
|
|
47
|
+
clearPending()
|
|
48
|
+
const ctrl = currentCtrl
|
|
49
|
+
pendingEsc = setTimeout(() => {
|
|
50
|
+
pendingEsc = null
|
|
51
|
+
ctrl?.abort()
|
|
52
|
+
}, debounceMs)
|
|
53
|
+
return { sigint: false }
|
|
54
|
+
}
|
|
55
|
+
// Any other byte arriving within the ESC window is the second byte of a CSI
|
|
56
|
+
// sequence; cancel the pending abort.
|
|
57
|
+
clearPending()
|
|
58
|
+
return { sigint: false }
|
|
59
|
+
},
|
|
60
|
+
clearPending,
|
|
61
|
+
dispose: () => {
|
|
62
|
+
clearPending()
|
|
63
|
+
currentCtrl = null
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/cli/inspect.ts
CHANGED
|
@@ -2,15 +2,18 @@ import { defineCommand } from 'citty'
|
|
|
2
2
|
|
|
3
3
|
import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
|
|
4
4
|
import { findAgentDir } from '@/init'
|
|
5
|
-
import {
|
|
5
|
+
import { runInspectLoop, streamLive, type LiveSourceFactory, type SessionSummary } from '@/inspect'
|
|
6
6
|
import { originLabel, shortSessionId } from '@/inspect/label'
|
|
7
7
|
|
|
8
|
+
import { createEscController } from './inspect-controller'
|
|
8
9
|
import { cancel, c, errorLine, isCancel } from './ui'
|
|
9
10
|
|
|
11
|
+
const ESC_LISTEN_DELAY_MS = 50
|
|
12
|
+
|
|
10
13
|
export const inspectCommand = defineCommand({
|
|
11
14
|
meta: {
|
|
12
15
|
name: 'inspect',
|
|
13
|
-
description: '
|
|
16
|
+
description: 'observe a session: replay the transcript, then tail live activity (host stage)',
|
|
14
17
|
},
|
|
15
18
|
args: {
|
|
16
19
|
session: {
|
|
@@ -32,12 +35,6 @@ export const inspectCommand = defineCommand({
|
|
|
32
35
|
description: 'emit one JSON event per line; requires an explicit session id',
|
|
33
36
|
default: false,
|
|
34
37
|
},
|
|
35
|
-
follow: {
|
|
36
|
-
type: 'boolean',
|
|
37
|
-
description:
|
|
38
|
-
'tail live activity after replay (default: true when the container is running); pass --no-follow to replay-then-exit',
|
|
39
|
-
default: true,
|
|
40
|
-
},
|
|
41
38
|
},
|
|
42
39
|
async run({ args }) {
|
|
43
40
|
const cwd = findAgentDir(process.cwd()) ?? process.cwd()
|
|
@@ -45,26 +42,39 @@ export const inspectCommand = defineCommand({
|
|
|
45
42
|
const sessionArg = typeof args.session === 'string' ? args.session : undefined
|
|
46
43
|
const filterArg = typeof args.filter === 'string' ? args.filter : undefined
|
|
47
44
|
const sinceArg = typeof args.since === 'string' ? args.since : undefined
|
|
48
|
-
const follow = args.follow !== false
|
|
49
45
|
|
|
50
46
|
const isJson = args.json === true
|
|
51
|
-
const liveSource =
|
|
47
|
+
const liveSource = isJson ? undefined : await buildLiveSource(cwd)
|
|
52
48
|
const signal = installSigintAbort()
|
|
49
|
+
const escListener = isJson ? null : createEscListener()
|
|
50
|
+
const liveHint = escListener === null ? undefined : escHintLine(color)
|
|
53
51
|
|
|
54
|
-
const result = await
|
|
52
|
+
const result = await runInspectLoop({
|
|
55
53
|
agentDir: cwd,
|
|
56
54
|
...(sessionArg !== undefined ? { sessionIdOrPrefix: sessionArg } : {}),
|
|
57
55
|
...(filterArg !== undefined ? { filter: filterArg } : {}),
|
|
58
56
|
...(sinceArg !== undefined ? { since: sinceArg } : {}),
|
|
59
57
|
json: isJson,
|
|
60
58
|
color,
|
|
61
|
-
selectSession:
|
|
59
|
+
selectSession: (sessions, selectOpts) => {
|
|
60
|
+
escListener?.pause()
|
|
61
|
+
return clackSelect(sessions, selectOpts?.initialSessionId).finally(() => {
|
|
62
|
+
escListener?.resume()
|
|
63
|
+
})
|
|
64
|
+
},
|
|
62
65
|
...(liveSource !== undefined ? { liveSource } : {}),
|
|
63
66
|
signal,
|
|
67
|
+
newEscSignal: () => {
|
|
68
|
+
if (escListener === null) return new AbortController().signal
|
|
69
|
+
return escListener.armForStream()
|
|
70
|
+
},
|
|
71
|
+
...(liveHint !== undefined ? { liveHint } : {}),
|
|
64
72
|
stdout: (line) => process.stdout.write(`${line}\n`),
|
|
65
73
|
stderr: (line) => process.stderr.write(`${line}\n`),
|
|
66
74
|
})
|
|
67
75
|
|
|
76
|
+
escListener?.stop()
|
|
77
|
+
|
|
68
78
|
if (!result.ok) {
|
|
69
79
|
process.stderr.write(`${errorLine(result.reason)}\n`)
|
|
70
80
|
process.exit(result.exitCode)
|
|
@@ -104,6 +114,74 @@ function installSigintAbort(): AbortSignal {
|
|
|
104
114
|
return ctrl.signal
|
|
105
115
|
}
|
|
106
116
|
|
|
117
|
+
type EscListener = {
|
|
118
|
+
armForStream: () => AbortSignal
|
|
119
|
+
pause: () => void
|
|
120
|
+
resume: () => void
|
|
121
|
+
stop: () => void
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function createEscListener(): EscListener | null {
|
|
125
|
+
const stdin = process.stdin
|
|
126
|
+
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') return null
|
|
127
|
+
|
|
128
|
+
const ctrl = createEscController({ debounceMs: ESC_LISTEN_DELAY_MS })
|
|
129
|
+
let active = false
|
|
130
|
+
|
|
131
|
+
const onData = (chunk: Buffer): void => {
|
|
132
|
+
const { sigint } = ctrl.onChunk(chunk)
|
|
133
|
+
if (sigint) process.kill(process.pid, 'SIGINT')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const start = (): void => {
|
|
137
|
+
if (active) return
|
|
138
|
+
active = true
|
|
139
|
+
stdin.setRawMode(true)
|
|
140
|
+
stdin.resume()
|
|
141
|
+
stdin.on('data', onData)
|
|
142
|
+
}
|
|
143
|
+
const stop = (): void => {
|
|
144
|
+
if (!active) return
|
|
145
|
+
active = false
|
|
146
|
+
stdin.off('data', onData)
|
|
147
|
+
try {
|
|
148
|
+
stdin.setRawMode(false)
|
|
149
|
+
} catch {
|
|
150
|
+
/* terminal already torn down */
|
|
151
|
+
}
|
|
152
|
+
stdin.pause()
|
|
153
|
+
ctrl.clearPending()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
armForStream: () => {
|
|
158
|
+
const signal = ctrl.armForStream()
|
|
159
|
+
start()
|
|
160
|
+
return signal
|
|
161
|
+
},
|
|
162
|
+
pause: () => {
|
|
163
|
+
stop()
|
|
164
|
+
},
|
|
165
|
+
resume: () => {
|
|
166
|
+
// Resume the listener WITHOUT replacing the AbortController.
|
|
167
|
+
// The signal returned by armForStream() is held by the live source
|
|
168
|
+
// through streamSession's combinedSignal; replacing the controller
|
|
169
|
+
// here would orphan that signal so a subsequent ESC press could
|
|
170
|
+
// not abort the live tail.
|
|
171
|
+
start()
|
|
172
|
+
},
|
|
173
|
+
stop: () => {
|
|
174
|
+
ctrl.dispose()
|
|
175
|
+
stop()
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function escHintLine(color: boolean): string {
|
|
181
|
+
const text = '(press esc to return to session list)'
|
|
182
|
+
return color ? `\u001b[2m${text}\u001b[0m` : text
|
|
183
|
+
}
|
|
184
|
+
|
|
107
185
|
function useColor(): boolean {
|
|
108
186
|
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') return false
|
|
109
187
|
if (process.env.FORCE_COLOR === '0') return false
|
|
@@ -111,8 +189,15 @@ function useColor(): boolean {
|
|
|
111
189
|
return Boolean(process.stdout.isTTY)
|
|
112
190
|
}
|
|
113
191
|
|
|
114
|
-
async function clackSelect(
|
|
192
|
+
async function clackSelect(
|
|
193
|
+
sessions: SessionSummary[],
|
|
194
|
+
initialSessionId: string | undefined,
|
|
195
|
+
): Promise<SessionSummary | null> {
|
|
115
196
|
const { select } = await import('@clack/prompts')
|
|
197
|
+
const preferred =
|
|
198
|
+
initialSessionId !== undefined && sessions.some((s) => s.sessionId === initialSessionId)
|
|
199
|
+
? initialSessionId
|
|
200
|
+
: sessions[0]?.sessionId
|
|
116
201
|
const picked = await select<string>({
|
|
117
202
|
message: `Pick a session to inspect (showing ${sessions.length})`,
|
|
118
203
|
options: sessions.map((s) => ({
|
|
@@ -120,7 +205,7 @@ async function clackSelect(sessions: SessionSummary[]): Promise<SessionSummary |
|
|
|
120
205
|
label: formatRowLabel(s),
|
|
121
206
|
...(s.firstPrompt !== null ? { hint: truncate(s.firstPrompt, 60) } : { hint: '(no prompt)' }),
|
|
122
207
|
})),
|
|
123
|
-
initialValue:
|
|
208
|
+
initialValue: preferred,
|
|
124
209
|
})
|
|
125
210
|
if (isCancel(picked)) {
|
|
126
211
|
cancel('Cancelled.')
|
package/src/cli/role.ts
CHANGED
|
@@ -11,7 +11,7 @@ const claimSub = defineCommand({
|
|
|
11
11
|
meta: {
|
|
12
12
|
name: 'claim',
|
|
13
13
|
description:
|
|
14
|
-
'claim a channel identity (Slack/Discord/etc.) for a role on this agent. Sends a code via the host CLI; you
|
|
14
|
+
'claim a channel identity (Slack/Discord/etc.) for a role on this agent. Sends a code via the host CLI; you send that code back to the bot from any chat (DM, group, channel) to prove control of the channel account. The resulting match rule grants the role to your author identity across every chat on that platform.',
|
|
15
15
|
},
|
|
16
16
|
args: {
|
|
17
17
|
as: {
|
|
@@ -57,7 +57,7 @@ const claimSub = defineCommand({
|
|
|
57
57
|
s.stop('Ready.')
|
|
58
58
|
const expiresInSec = Math.max(0, Math.round((payload.expiresAt - Date.now()) / 1000))
|
|
59
59
|
const lines = [
|
|
60
|
-
`Send this message to your bot
|
|
60
|
+
`Send this message to your bot from any chat (DM, group, or channel):`,
|
|
61
61
|
'',
|
|
62
62
|
` ${c.bold(payload.code)}`,
|
|
63
63
|
'',
|
package/src/cli/run.ts
CHANGED
|
@@ -49,21 +49,40 @@ export const run = defineCommand({
|
|
|
49
49
|
initialPrompt: args.prompt,
|
|
50
50
|
})
|
|
51
51
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
const exit = (code: number): void => {
|
|
53
|
+
process.exit(code)
|
|
54
|
+
}
|
|
55
|
+
const onSignal = (): void => {
|
|
56
|
+
void shutdown({ stop, exit })
|
|
55
57
|
}
|
|
56
58
|
process.once('SIGINT', onSignal)
|
|
57
59
|
process.once('SIGTERM', onSignal)
|
|
58
60
|
|
|
59
61
|
if (tuiPromise) {
|
|
60
62
|
await tuiPromise
|
|
61
|
-
stop
|
|
62
|
-
process.exit(0)
|
|
63
|
+
await shutdown({ stop, exit })
|
|
63
64
|
}
|
|
64
65
|
},
|
|
65
66
|
})
|
|
66
67
|
|
|
68
|
+
// Awaits `stop()` BEFORE exiting so async teardown side-effects (channel
|
|
69
|
+
// adapter teardown, in particular GitHub webhook deregistration) actually
|
|
70
|
+
// complete. The previous code called `stop()` without awaiting and then
|
|
71
|
+
// `process.exit(0)` synchronously, so the in-process DELETE /repos/.../hooks/
|
|
72
|
+
// requests never went out and webhooks survived `typeclaw stop` (which
|
|
73
|
+
// `docker stop`s the container → SIGTERM).
|
|
74
|
+
export async function shutdown(deps: {
|
|
75
|
+
stop: () => void | Promise<void>
|
|
76
|
+
exit: (code: number) => void
|
|
77
|
+
}): Promise<void> {
|
|
78
|
+
try {
|
|
79
|
+
await deps.stop()
|
|
80
|
+
deps.exit(0)
|
|
81
|
+
} catch {
|
|
82
|
+
deps.exit(1)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
67
86
|
function resolveAttachTui({
|
|
68
87
|
tui,
|
|
69
88
|
noTui,
|
package/src/cli/tui.ts
CHANGED
|
@@ -25,16 +25,40 @@ export const tui = defineCommand({
|
|
|
25
25
|
},
|
|
26
26
|
},
|
|
27
27
|
async run({ args }) {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
28
|
+
const resolveUrl: () => Promise<string> = args.url !== undefined ? async () => args.url as string : defaultUrl
|
|
29
|
+
|
|
30
|
+
let initialPrompt: string | undefined = args.prompt
|
|
31
|
+
let attempt = 0
|
|
32
|
+
const RECONNECT_MAX_ATTEMPTS = 30
|
|
33
|
+
const RECONNECT_BACKOFF_MS = 1_000
|
|
34
|
+
|
|
35
|
+
while (true) {
|
|
36
|
+
const url = await resolveUrl()
|
|
37
|
+
const tui = createTui({
|
|
38
|
+
url,
|
|
39
|
+
...(initialPrompt !== undefined ? { initialPrompt } : {}),
|
|
40
|
+
expectedVersion: CLI_VERSION,
|
|
41
|
+
onVersionMismatch: (info) => {
|
|
42
|
+
process.stderr.write(`${formatVersionMismatchWarning(info)}\n`)
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
const outcome = await tui.run()
|
|
46
|
+
if (!outcome.lostConnection) return
|
|
47
|
+
// The TUI lost its WS post-handshake (container restart, network blip,
|
|
48
|
+
// hostd hiccup). Re-resolve the URL because the host port can change
|
|
49
|
+
// across container lifecycles (see resolveHostPort), then reconnect.
|
|
50
|
+
// The initial prompt is intentionally cleared after the first cycle:
|
|
51
|
+
// on a reconnect, the agent is resuming the same session — replaying
|
|
52
|
+
// the prompt would re-send it to the LLM.
|
|
53
|
+
initialPrompt = undefined
|
|
54
|
+
attempt += 1
|
|
55
|
+
if (attempt > RECONNECT_MAX_ATTEMPTS) {
|
|
56
|
+
console.error(errorLine(`disconnected; gave up after ${RECONNECT_MAX_ATTEMPTS} reconnect attempts`))
|
|
57
|
+
process.exit(1)
|
|
58
|
+
}
|
|
59
|
+
process.stderr.write(`reconnecting (attempt ${attempt}/${RECONNECT_MAX_ATTEMPTS})...\n`)
|
|
60
|
+
await new Promise((resolve) => setTimeout(resolve, RECONNECT_BACKOFF_MS))
|
|
61
|
+
}
|
|
38
62
|
},
|
|
39
63
|
})
|
|
40
64
|
|